From 5f965464071788b7e93cc80abbb4ad6c035db105 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Tue, 18 Aug 2026 13:51:28 +0800 Subject: [PATCH 01/16] feat(acli): sync roadmap features from agenticCLI + gate alignment Synced from agenticCLI (817a8d8..bab7437): - /history search: full-text search across all session message stores (case-insensitive, limit, keyword highlight, multimodal flattened). - MCP stdio transport: StdioMCPClient (subprocess + newline-delimited JSON-RPC) alongside SSE; MCPServerConfig gains transport/command/args; _connect_mcp picks the transport, stdio servers auto-connect. - checkpoint//undo: write_file/delete_file snapshot before mutating; /undo reverses the most recent mutation (move/rename intentionally not checkpointed). - Formatting convergence: agenticCLI now shares the same pre-commit gate (black 23.3.0 @79 + add-trailing-comma), so synced code is gate-clean as-is. pre-commit (changed files): green. UT: 454 passed, 6 skipped. --- dashscope/acli/__init__.py | 7 +- dashscope/acli/agent.py | 6 +- dashscope/acli/agents/subagent.py | 4 +- dashscope/acli/agents/subagents.py | 5 +- dashscope/acli/cli/__init__.py | 27 +- dashscope/acli/cli/completer.py | 4 +- dashscope/acli/cli/constants.py | 3 +- dashscope/acli/cli/dispatch.py | 9 +- dashscope/acli/cli/handlers_capability.py | 17 +- dashscope/acli/cli/handlers_config.py | 8 +- dashscope/acli/cli/handlers_misc.py | 124 +++++++- dashscope/acli/cli/mcp.py | 34 +- dashscope/acli/cli/repl.py | 33 +- dashscope/acli/cli/runners.py | 46 +-- dashscope/acli/cli/streaming.py | 11 +- dashscope/acli/commands.py | 6 +- dashscope/acli/config.py | 30 +- dashscope/acli/dev.py | 20 +- dashscope/acli/eval/__init__.py | 12 +- dashscope/acli/extensions.py | 6 +- dashscope/acli/hooks.py | 8 +- dashscope/acli/mcp_stdio.py | 312 +++++++++++++++++++ dashscope/acli/memory/experience.py | 5 +- dashscope/acli/memory/tool_chains.py | 5 +- dashscope/acli/memory/trace.py | 6 +- dashscope/acli/platforms/bailian/__init__.py | 5 +- dashscope/acli/platforms/base.py | 6 +- dashscope/acli/providers/openai.py | 16 +- dashscope/acli/providers/profile.py | 5 +- dashscope/acli/providers/tongyi.py | 8 +- dashscope/acli/session.py | 19 ++ dashscope/acli/tools/checkpoint.py | 167 ++++++++++ dashscope/acli/tools/filesystem.py | 10 + dashscope/acli/tools/platform.py | 16 +- dashscope/acli/tools/session.py | 6 +- dashscope/acli/ui/tui.py | 12 +- dashscope/acli/utils/__init__.py | 11 +- 37 files changed, 781 insertions(+), 248 deletions(-) create mode 100644 dashscope/acli/mcp_stdio.py create mode 100644 dashscope/acli/tools/checkpoint.py diff --git a/dashscope/acli/__init__.py b/dashscope/acli/__init__.py index 9a60c83..e863881 100644 --- a/dashscope/acli/__init__.py +++ b/dashscope/acli/__init__.py @@ -5,12 +5,7 @@ # Expose the lightweight programmatic SDK at the package root. try: - from dashscope.acli.sdk import ( - create_agent, - run_interactive, - run_once, - run_once_sync, - ) + from dashscope.acli.sdk import create_agent, run_interactive, run_once, run_once_sync except Exception: # pragma: no cover - sdk imports optional deps may fail create_agent = None # type: ignore run_interactive = None # type: ignore diff --git a/dashscope/acli/agent.py b/dashscope/acli/agent.py index 68d2ee3..6b6bbe7 100644 --- a/dashscope/acli/agent.py +++ b/dashscope/acli/agent.py @@ -524,9 +524,9 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: async for chunk in self.provider.chat_stream( normalize_for_model(messages_with_system, self.model_name), tools_schema, - response_format={"type": "json_object"} - if self.json_mode - else None, + response_format=( + {"type": "json_object"} if self.json_mode else None + ), ): if chunk.delta_content: full_content += chunk.delta_content diff --git a/dashscope/acli/agents/subagent.py b/dashscope/acli/agents/subagent.py index 24d7b5f..0e7363a 100644 --- a/dashscope/acli/agents/subagent.py +++ b/dashscope/acli/agents/subagent.py @@ -73,9 +73,7 @@ async def _subagent_invoke( "(missing parent agent reference)" ) - from dashscope.acli.agent import ( - Agent, - ) # local import to avoid module-load cycle + from dashscope.acli.agent import Agent # local import to avoid module-load cycle from dashscope.acli.memory.manager import MemoryManager # Look up per-agent config overrides (max_turns, model, temperature) diff --git a/dashscope/acli/agents/subagents.py b/dashscope/acli/agents/subagents.py index ad16d56..347cfc0 100644 --- a/dashscope/acli/agents/subagents.py +++ b/dashscope/acli/agents/subagents.py @@ -154,10 +154,7 @@ def _subagents_list(config: Config) -> None: def _subagents_reload(config: Config) -> None: # pylint: disable=unused-argument """Re-scan custom_extensions.toml and refresh subagent registry.""" - from dashscope.acli.cli import ( - PROVIDER_MODELS, - sync_extensions_into_catalog, - ) + from dashscope.acli.cli import PROVIDER_MODELS, sync_extensions_into_catalog from dashscope.acli.extensions import apply_extensions ext = apply_extensions(PROVIDER_MODELS) diff --git a/dashscope/acli/cli/__init__.py b/dashscope/acli/cli/__init__.py index d65b288..b178db7 100644 --- a/dashscope/acli/cli/__init__.py +++ b/dashscope/acli/cli/__init__.py @@ -24,10 +24,7 @@ from dashscope.acli.skills import load_skill_files # noqa: E402 load_skill_files() -from dashscope.acli.cli.completer import ( # noqa: F401,E402 - _get_arg_hint, - _is_dir_safe, -) +from dashscope.acli.cli.completer import _get_arg_hint, _is_dir_safe # noqa: F401,E402 # Import constants and multimodal handling from submodules from dashscope.acli.cli.constants import ( # noqa: F401,E402 @@ -52,25 +49,16 @@ _handle_slash_command, dispatch_async_command, ) -from dashscope.acli.cli.examples import ( # noqa: E402 - _handle_example_command, -) +from dashscope.acli.cli.examples import _handle_example_command # noqa: E402 from dashscope.acli.cli.handlers_capability import ( # noqa: F401,E402 _cap_enabled, sync_extensions_into_catalog, ) -from dashscope.acli.cli.handlers_misc import ( # noqa: F401,E402 - _handle_report_command, -) -from dashscope.acli.cli.handlers_setup import ( # noqa: F401,E402 - _handle_setup, -) +from dashscope.acli.cli.handlers_misc import _handle_report_command # noqa: F401,E402 +from dashscope.acli.cli.handlers_setup import _handle_setup # noqa: F401,E402 # Import MCP management from submodule -from dashscope.acli.cli.mcp import ( # noqa: F401,E402 - _connect_mcp, - _mcp_clients, -) +from dashscope.acli.cli.mcp import _connect_mcp, _mcp_clients # noqa: F401,E402 from dashscope.acli.cli.repl import _run_loop # noqa: E402 from dashscope.acli.cli.runners import ( # noqa: E402 _run_dry_run, @@ -81,10 +69,7 @@ _compose_system_prompt, _load_system_prompt, ) -from dashscope.acli.cli.streaming import ( # noqa: F401,E402 - _do_compress, - _do_summarize, -) +from dashscope.acli.cli.streaming import _do_compress, _do_summarize # noqa: F401,E402 # Cron scheduler _scheduler = None diff --git a/dashscope/acli/cli/completer.py b/dashscope/acli/cli/completer.py index a5b0855..b412c75 100644 --- a/dashscope/acli/cli/completer.py +++ b/dashscope/acli/cli/completer.py @@ -245,9 +245,7 @@ def _slot_candidates(self, tokens: list[str], arg_index: int) -> list[str]: return [] if cmd == "/subagents": - from dashscope.acli.agents.subagents import ( - SUBAGENT_CAPABILITY_KEYS, - ) + from dashscope.acli.agents.subagents import SUBAGENT_CAPABILITY_KEYS if arg_index == 1: return _SUBCOMMANDS["/subagents"] diff --git a/dashscope/acli/cli/constants.py b/dashscope/acli/cli/constants.py index 5ba5b7a..ba8b370 100644 --- a/dashscope/acli/cli/constants.py +++ b/dashscope/acli/cli/constants.py @@ -186,6 +186,7 @@ "/report", "/feedback", "/history", + "/undo", "/json", "/save", "/privacy", @@ -228,7 +229,7 @@ "/mcp": ["list", "add", "remove"], "/cron": ["add", "list", "remove", "pause", "resume"], "/feedback": ["good", "bad"], - "/history": ["stats", "list", "export", "clear"], + "/history": ["stats", "list", "search", "export", "clear"], "/json": ["on", "off"], "/privacy": ["on", "off", "status"], "/audit": ["recent", "query", "clear"], diff --git a/dashscope/acli/cli/dispatch.py b/dashscope/acli/cli/dispatch.py index 9f45c6a..68ce99f 100644 --- a/dashscope/acli/cli/dispatch.py +++ b/dashscope/acli/cli/dispatch.py @@ -353,9 +353,7 @@ def _handle_slash_command( console.print(render_help_text()) return True elif cmd.startswith("/provider"): - from dashscope.acli.cli.handlers_provider import ( - handle_provider_command, - ) + from dashscope.acli.cli.handlers_provider import handle_provider_command handle_provider_command(cmd, agent, config) return True @@ -403,6 +401,11 @@ def _handle_slash_command( elif cmd.startswith("/history"): _handle_history_command(cmd) return True + elif cmd == "/undo": + from dashscope.acli.tools.checkpoint import handle_undo_command + + handle_undo_command() + return True elif cmd.startswith("/privacy"): _handle_privacy_command(cmd, config) return True diff --git a/dashscope/acli/cli/handlers_capability.py b/dashscope/acli/cli/handlers_capability.py index 464870a..86233c2 100644 --- a/dashscope/acli/cli/handlers_capability.py +++ b/dashscope/acli/cli/handlers_capability.py @@ -7,10 +7,7 @@ from rich.console import Console -from dashscope.acli.cli.constants import ( - ALL_CAPABILITY_KEYS, - CAPABILITY_CATALOG, -) +from dashscope.acli.cli.constants import ALL_CAPABILITY_KEYS, CAPABILITY_CATALOG from dashscope.acli.config import PROVIDER_MODELS, Config console = Console() @@ -153,9 +150,7 @@ def _handle_capability_command(cmd: str, config: Config): # Already enabled — but extension caps may still lack credentials # (enabled without a token, or env var unset). Offer the prompt # again and re-register so the tools bind to fresh creds. - from dashscope.acli.cli.handlers_key import ( - _maybe_prompt_extension_token, - ) + from dashscope.acli.cli.handlers_key import _maybe_prompt_extension_token _maybe_prompt_extension_token(cap_key, config) from dashscope.acli.cli.mcp import _connect_mcp @@ -188,9 +183,7 @@ def _handle_capability_command(cmd: str, config: Config): return # Extension-capability bearer/apikey-header token - from dashscope.acli.cli.handlers_key import ( - _maybe_prompt_extension_token, - ) + from dashscope.acli.cli.handlers_key import _maybe_prompt_extension_token _maybe_prompt_extension_token(cap_key, config) @@ -241,9 +234,7 @@ def _handle_capability_command(cmd: str, config: Config): if sub in ("reload", "refresh"): from dashscope.acli.extensions import apply_extensions - from dashscope.acli.tools.platform import ( - refresh_extension_capability_tools, - ) + from dashscope.acli.tools.platform import refresh_extension_capability_tools ext = apply_extensions(PROVIDER_MODELS) sync_extensions_into_catalog(ext) diff --git a/dashscope/acli/cli/handlers_config.py b/dashscope/acli/cli/handlers_config.py index 868cb62..10e661f 100644 --- a/dashscope/acli/cli/handlers_config.py +++ b/dashscope/acli/cli/handlers_config.py @@ -53,9 +53,7 @@ def _print_status(): get_audit_logger().set_privacy_mode(True) # Enforce at the tool surface too (not just the slash-command gate): # drop every registered cloud capability tool + connected MCP tools. - from dashscope.acli.tools.platform import ( - unregister_cloud_capability_tools, - ) + from dashscope.acli.tools.platform import unregister_cloud_capability_tools from dashscope.acli.tools.registry import registry removed = unregister_cloud_capability_tools() @@ -607,9 +605,7 @@ def _handle_directives_command(cmd: str, config: Config) -> None: return if sub == "proposals": - from dashscope.acli.memory.directives_learning import ( - list_proposed_directives, - ) + from dashscope.acli.memory.directives_learning import list_proposed_directives proposals = list_proposed_directives("pending") if not proposals: diff --git a/dashscope/acli/cli/handlers_misc.py b/dashscope/acli/cli/handlers_misc.py index b54e62e..94ffe93 100644 --- a/dashscope/acli/cli/handlers_misc.py +++ b/dashscope/acli/cli/handlers_misc.py @@ -1,10 +1,14 @@ # -*- coding: utf-8 -*- """Miscellaneous command handlers (trust, history, report).""" # pylint: disable=protected-access,too-many-branches,too-many-statements +# pylint: disable=too-many-return-statements from __future__ import annotations +from typing import Any + from rich.console import Console +from rich.markup import escape from dashscope.acli.agent import Agent @@ -77,6 +81,86 @@ def _handle_trust_command(cmd: str, agent: Agent) -> None: ) +def _message_text(msg: dict[str, Any]) -> str: + """Flatten a chat message's content into one-line plain text.""" + content = msg.get("content", "") + if isinstance(content, list): + content = " ".join( + part.get("text", "") for part in content if isinstance(part, dict) + ) + if not isinstance(content, str): + return "" + return " ".join(content.split()) + + +def _search_snippet(text: str, idx: int, kw_len: int) -> str: + """Build a short one-line snippet centered on a match position.""" + start = max(0, idx - 20) + end = min(len(text), idx + kw_len + 40) + prefix = "..." if start > 0 else "" + suffix = "..." if end < len(text) else "" + return prefix + text[start:end] + suffix + + +def _highlight_keyword(text: str, keyword: str) -> str: + """Wrap keyword occurrences in rich markup (case-insensitive).""" + lower_kw = keyword.lower() + if not lower_kw: + return escape(text) + lower_text = text.lower() + out: list[str] = [] + pos = 0 + while True: + idx = lower_text.find(lower_kw, pos) + if idx < 0: + out.append(escape(text[pos:])) + break + out.append(escape(text[pos:idx])) + out.append("[bold yellow]") + out.append(escape(text[idx : idx + len(keyword)])) + out.append("[/bold yellow]") + pos = idx + len(keyword) + return "".join(out) + + +def _history_search_matches( + keyword: str, + limit: int = 20, +) -> list[dict[str, str]]: + """Case-insensitive substring search across all session history. + + Scans every stored session's messages and returns up to ``limit`` + matches, each carrying the session topic, a timestamp, the message + role, and a one-line snippet with match context. + """ + from dashscope.acli.session import get_session_manager + + needle = keyword.lower() + if not needle or limit <= 0: + return [] + mgr = get_session_manager() + matches: list[dict[str, str]] = [] + for meta in mgr.list_topics(): + if len(matches) >= limit: + break + for msg in mgr.load_messages(meta.topic): + text = _message_text(msg) + idx = text.lower().find(needle) if text else -1 + if idx < 0: + continue + matches.append( + { + "session": meta.topic, + "timestamp": meta.last_accessed or "", + "role": str(msg.get("role", "?")), + "snippet": _search_snippet(text, idx, len(needle)), + }, + ) + if len(matches) >= limit: + break + return matches + + def _handle_history_command(cmd: str) -> None: """Manage conversation history.""" from dashscope.acli.platforms.local.history import ( @@ -107,6 +191,8 @@ def _handle_history_command(cmd: str) -> None: "\n[dim]Usage:\n" " /history stats — show stats\n" " /history list [n] — list recent n\n" + " /history search [limit] — full-text " + "search\n" " /history export [--format json|md|html] — export\n" " /history clear — clear " "history[/dim]", @@ -147,6 +233,41 @@ def _handle_history_command(cmd: str) -> None: ) return + if sub == "search": + if len(parts) < 3: + console.print( + "[dim]Usage: /history search [limit][/dim]", + ) + return + keyword = parts[2] + limit = 20 + if len(parts) >= 4: + try: + limit = int(parts[3]) + except ValueError: + limit = 0 + if limit <= 0: + console.print( + "[red]limit must be a positive integer[/red]", + ) + return + matches = _history_search_matches(keyword, limit=limit) + if not matches: + console.print(f"[dim]No matches for '{keyword}'[/dim]") + return + header = f"[bold]{len(matches)} match(es) for '{keyword}':[/bold]" + console.print(header) + for i, m in enumerate(matches, 1): + ts = m["timestamp"][:16] + head = ( + f" {i}. [cyan]{m['session']}[/cyan] " + f"[dim]{ts}[/dim] [bold]{m['role']}[/bold]" + ) + console.print(head) + snippet = _highlight_keyword(m["snippet"], keyword) + console.print(f" {snippet}") + return + if sub == "export" and len(parts) >= 3: output_path = parts[2] fmt = "html" @@ -174,7 +295,8 @@ def _handle_history_command(cmd: str) -> None: console.print(f"[green]✓ Cleared {count} history records[/green]") return - console.print("[dim]Usage: /history [stats|list|export|clear][/dim]") + usage = "[dim]Usage: /history [stats|list|search|export|clear][/dim]" + console.print(usage) def _handle_report_command(agent: Agent) -> None: diff --git a/dashscope/acli/cli/mcp.py b/dashscope/acli/cli/mcp.py index b570665..6343786 100644 --- a/dashscope/acli/cli/mcp.py +++ b/dashscope/acli/cli/mcp.py @@ -7,6 +7,7 @@ from rich.status import Status from dashscope.acli.config import Config, MCPServerConfig +from dashscope.acli.mcp_stdio import StdioMCPClient from dashscope.acli.platforms.bailian import MCPClient, MCPError from dashscope.acli.skills import list_known_services from dashscope.acli.tools.registry import registry @@ -14,22 +15,34 @@ console = Console() # Active MCP clients - shared state -_mcp_clients: dict[str, MCPClient] = {} +_mcp_clients: dict[str, MCPClient | StdioMCPClient] = {} -async def _connect_mcp(service: str, config: Config, url: str = "") -> str: +async def _connect_mcp( + service: str, + config: Config, + url: str = "", + server: MCPServerConfig | None = None, +) -> str: """Connect to an MCP service and register its tools. Returns empty string on success, error message on failure.""" if service in _mcp_clients: return "" + use_stdio = bool(server) and ( + (server.transport or "").lower() == "stdio" or bool(server.command) + ) try: - client = MCPClient( - service=service, - api_key=config.tongyi_api_key, - url=url, - ) + client: MCPClient | StdioMCPClient + if use_stdio: + client = StdioMCPClient(server.command, server.args) + else: + client = MCPClient( + service=service, + api_key=config.tongyi_api_key, + url=url, + ) except MCPError as e: return str(e) @@ -143,7 +156,12 @@ async def _handle_mcp_command(cmd: str, config: Config): async def _init_mcp_servers(config: Config): """Connect to configured MCP servers on startup.""" for mcp_cfg in config.mcp_servers: - error = await _connect_mcp(mcp_cfg.service, config, url=mcp_cfg.url) + error = await _connect_mcp( + mcp_cfg.service, + config, + url=mcp_cfg.url, + server=mcp_cfg, + ) if not error: client = _mcp_clients[mcp_cfg.service] summary = f"{len(client.tools)} tools" diff --git a/dashscope/acli/cli/repl.py b/dashscope/acli/cli/repl.py index c749f33..a6327cd 100644 --- a/dashscope/acli/cli/repl.py +++ b/dashscope/acli/cli/repl.py @@ -15,11 +15,7 @@ from rich.panel import Panel from dashscope.acli.agent import Agent -from dashscope.acli.cli.completer import ( - AcliCompleter, - SafeFileHistory, - _HintProcessor, -) +from dashscope.acli.cli.completer import AcliCompleter, SafeFileHistory, _HintProcessor from dashscope.acli.cli.dispatch import ( _handle_skill_continue, _handle_slash_command, @@ -35,20 +31,13 @@ _init_mcp_servers, _mcp_clients, ) -from dashscope.acli.cli.multimodal import ( - _expand_at_references, - _to_multimodal_content, -) +from dashscope.acli.cli.multimodal import _expand_at_references, _to_multimodal_content from dashscope.acli.cli.startup import ( _compose_system_prompt, _load_system_prompt, _print_banner, ) -from dashscope.acli.cli.streaming import ( - _do_compress, - _do_summarize, - _stream_response, -) +from dashscope.acli.cli.streaming import _do_compress, _do_summarize, _stream_response from dashscope.acli.config import ( PROVIDER_MODELS, WORKSPACE_CONFIG_FILE, @@ -148,18 +137,10 @@ async def _run_loop(config: Config): # Pin parent agent ref for local.subagent / local.delegate BEFORE platform # tool registration so register_one_capability finds a parent to attach to. - from dashscope.acli.agents.delegate import ( - set_config as set_delegate_config, - ) - from dashscope.acli.agents.delegate import ( - set_parent_agent as set_delegate_parent, - ) - from dashscope.acli.agents.subagent import ( - set_config as set_subagent_config, - ) - from dashscope.acli.agents.subagent import ( - set_parent_agent as set_subagent_parent, - ) + from dashscope.acli.agents.delegate import set_config as set_delegate_config + from dashscope.acli.agents.delegate import set_parent_agent as set_delegate_parent + from dashscope.acli.agents.subagent import set_config as set_subagent_config + from dashscope.acli.agents.subagent import set_parent_agent as set_subagent_parent set_subagent_parent(agent) set_subagent_config(config) diff --git a/dashscope/acli/cli/runners.py b/dashscope/acli/cli/runners.py index 056b6a7..0dcb603 100644 --- a/dashscope/acli/cli/runners.py +++ b/dashscope/acli/cli/runners.py @@ -10,14 +10,8 @@ from dashscope.acli.agent import Agent from dashscope.acli.cli.handlers_key import ensure_provider_key -from dashscope.acli.cli.multimodal import ( - _expand_at_references, - _to_multimodal_content, -) -from dashscope.acli.cli.startup import ( - _compose_system_prompt, - _load_system_prompt, -) +from dashscope.acli.cli.multimodal import _expand_at_references, _to_multimodal_content +from dashscope.acli.cli.startup import _compose_system_prompt, _load_system_prompt from dashscope.acli.config import ( PROVIDER_MODELS, Config, @@ -79,18 +73,10 @@ async def _run_oneshot(config: Config, prompt: str): # Pin parent agent ref for local.subagent / local.delegate BEFORE platform # tool registration so register_one_capability finds a parent to attach to # (same ordering as cli/repl.py). - from dashscope.acli.agents.delegate import ( - set_config as set_delegate_config, - ) - from dashscope.acli.agents.delegate import ( - set_parent_agent as set_delegate_parent, - ) - from dashscope.acli.agents.subagent import ( - set_config as set_subagent_config, - ) - from dashscope.acli.agents.subagent import ( - set_parent_agent as set_subagent_parent, - ) + from dashscope.acli.agents.delegate import set_config as set_delegate_config + from dashscope.acli.agents.delegate import set_parent_agent as set_delegate_parent + from dashscope.acli.agents.subagent import set_config as set_subagent_config + from dashscope.acli.agents.subagent import set_parent_agent as set_subagent_parent set_subagent_parent(agent) set_subagent_config(config) @@ -269,9 +255,7 @@ def _run_tui_mode(config: Config): from dashscope.acli.extensions import apply_extensions _ext = apply_extensions(PROVIDER_MODELS) - from dashscope.acli.cli.handlers_capability import ( - sync_extensions_into_catalog, - ) + from dashscope.acli.cli.handlers_capability import sync_extensions_into_catalog sync_extensions_into_catalog(_ext) @@ -280,18 +264,10 @@ def _run_tui_mode(config: Config): provider = get_provider_chain(config) executor = Executor(auto_approve=config.auto_approve) - from dashscope.acli.agents.delegate import ( - set_config as set_delegate_config, - ) - from dashscope.acli.agents.delegate import ( - set_parent_agent as set_delegate_parent, - ) - from dashscope.acli.agents.subagent import ( - set_config as set_subagent_config, - ) - from dashscope.acli.agents.subagent import ( - set_parent_agent as set_subagent_parent, - ) + from dashscope.acli.agents.delegate import set_config as set_delegate_config + from dashscope.acli.agents.delegate import set_parent_agent as set_delegate_parent + from dashscope.acli.agents.subagent import set_config as set_subagent_config + from dashscope.acli.agents.subagent import set_parent_agent as set_subagent_parent from dashscope.acli.hooks import create_hook_bus from dashscope.acli.platforms import get_memory_provider from dashscope.acli.tools.platform import disabled_capabilities_hint diff --git a/dashscope/acli/cli/streaming.py b/dashscope/acli/cli/streaming.py index e77fa72..10b45db 100644 --- a/dashscope/acli/cli/streaming.py +++ b/dashscope/acli/cli/streaming.py @@ -13,15 +13,8 @@ from dashscope.acli.agent import Agent from dashscope.acli.config import Config -from dashscope.acli.deliverable import ( - collect_deliverables, - surface_deliverables, -) -from dashscope.acli.utils import ( - AsyncSpinner, - UserAbortedTurn, - message_text_for_compress, -) +from dashscope.acli.deliverable import collect_deliverables, surface_deliverables +from dashscope.acli.utils import AsyncSpinner, UserAbortedTurn, message_text_for_compress console = Console() diff --git a/dashscope/acli/commands.py b/dashscope/acli/commands.py index 0907661..4de0e86 100644 --- a/dashscope/acli/commands.py +++ b/dashscope/acli/commands.py @@ -38,7 +38,11 @@ "JSON output mode (replies forced to JSON when on)", ), ("/compress", "Compress context (LLM summary replaces history)"), - ("/history", "Conversation history (stats/list/export/clear)"), + ( + "/history", + "Conversation history (stats/list/search/export/clear)", + ), + ("/undo", "Undo the last file write/delete (checkpoint)"), ( "/feedback good|bad", "Rate task satisfaction (stored in experience memory)", diff --git a/dashscope/acli/config.py b/dashscope/acli/config.py index 99f2fca..52b6a70 100644 --- a/dashscope/acli/config.py +++ b/dashscope/acli/config.py @@ -8,11 +8,7 @@ from dashscope.acli.utils.crypto import decrypt_value, encrypt_value from dashscope.acli.utils.paths import atomic_write_text -from dashscope.acli.utils.toml import ( - load_toml, - parse_toml_inline_table, - toml_str, -) +from dashscope.acli.utils.toml import load_toml, parse_toml_inline_table, toml_str CONFIG_DIR = Path.home() / ".acli" CONFIG_FILE = CONFIG_DIR / "config.toml" @@ -165,6 +161,10 @@ def _add(m: str) -> None: class MCPServerConfig: service: str url: str = "" + # "sse" (remote, default) or "stdio" (local subprocess). + transport: str = "sse" + command: str = "" + args: list[str] = field(default_factory=list) @dataclass @@ -659,7 +659,18 @@ def _load_workspace_from(self, path: Path): if "mcp_servers" in data: for mcp_data in data["mcp_servers"]: if isinstance(mcp_data, dict) and "service" in mcp_data: - self.mcp_servers.append(MCPServerConfig(**mcp_data)) + kwargs = { + k: v + for k, v in mcp_data.items() + if k + in ("service", "url", "transport", "command", "args") + } + raw_args = kwargs.get("args") + if isinstance(raw_args, list): + kwargs["args"] = [str(a) for a in raw_args] + else: + kwargs.pop("args", None) + self.mcp_servers.append(MCPServerConfig(**kwargs)) if "examples_repo" in data: self.examples_repo = str(data["examples_repo"]) if "examples_branch" in data: @@ -846,4 +857,11 @@ def _workspace_lines(self) -> list[str]: lines.append(f"service = {toml_str(mcp.service)}") if mcp.url: lines.append(f"url = {toml_str(mcp.url)}") + if mcp.transport and mcp.transport != "sse": + lines.append(f"transport = {toml_str(mcp.transport)}") + if mcp.command: + lines.append(f"command = {toml_str(mcp.command)}") + if mcp.args: + arg_list = ", ".join(toml_str(a) for a in mcp.args) + lines.append(f"args = [{arg_list}]") return lines diff --git a/dashscope/acli/dev.py b/dashscope/acli/dev.py index ff4cd27..1f8ce85 100644 --- a/dashscope/acli/dev.py +++ b/dashscope/acli/dev.py @@ -453,9 +453,7 @@ def _hot_reload(config: Config | None = None) -> None: sync_extensions_into_catalog(ext) if config is not None: - from dashscope.acli.tools.platform import ( - refresh_extension_capability_tools, - ) + from dashscope.acli.tools.platform import refresh_extension_capability_tools refresh_extension_capability_tools(config) @@ -608,10 +606,7 @@ def _provider_remove(name: str) -> None: def _capability_add(config: Config) -> None: """Scaffold a [[capabilities]] block in toml the user then edits in their editor — tool definitions are too complex for a smooth one-shot prompt.""" - from dashscope.acli.extensions import ( - append_capability_scaffold, - load_extensions, - ) + from dashscope.acli.extensions import append_capability_scaffold, load_extensions console.print("\n[bold]Add Capability (HTTP tool group)[/bold]") console.print( @@ -683,11 +678,7 @@ def _capability_remove(key: str, config: Config) -> None: def _skill_add() -> None: - from dashscope.acli.extensions import ( - CustomSkill, - append_skill, - load_extensions, - ) + from dashscope.acli.extensions import CustomSkill, append_skill, load_extensions from dashscope.acli.skills.base import BUILTIN_SKILLS, Skill, register console.print("\n[bold]Add Skill (Prompt template)[/bold]") @@ -984,10 +975,7 @@ async def _test_provider(name: str, config: Config) -> None: import copy as _copy from dashscope.acli.extensions import find_provider - from dashscope.acli.providers import ( - _create_provider, - build_profiles_from_config, - ) + from dashscope.acli.providers import _create_provider, build_profiles_from_config console.print(f"[dim]Testing provider {name}...[/dim]") try: diff --git a/dashscope/acli/eval/__init__.py b/dashscope/acli/eval/__init__.py index 4af79d5..9d22d53 100644 --- a/dashscope/acli/eval/__init__.py +++ b/dashscope/acli/eval/__init__.py @@ -214,11 +214,13 @@ async def compare( / max(len(results_a), 1), "avg_duration_b": sum(r.duration for r in results_b) / max(len(results_b), 1), - "winner": label_a - if avg_a > avg_b - else label_b - if avg_b > avg_a - else "tie", + "winner": ( + label_a + if avg_a > avg_b + else label_b + if avg_b > avg_a + else "tie" + ), "results_a": [r.to_dict() for r in results_a], "results_b": [r.to_dict() for r in results_b], } diff --git a/dashscope/acli/extensions.py b/dashscope/acli/extensions.py index dda9461..155ea4c 100644 --- a/dashscope/acli/extensions.py +++ b/dashscope/acli/extensions.py @@ -1237,11 +1237,7 @@ def _register_custom_shell_tools(ext: CustomExtensions) -> None: registry.""" import subprocess as sp - from dashscope.acli.tools.registry import ( - PermissionLevel, - ToolDefinition, - registry, - ) + from dashscope.acli.tools.registry import PermissionLevel, ToolDefinition, registry for t in ext.shell_tools: perm = getattr( diff --git a/dashscope/acli/hooks.py b/dashscope/acli/hooks.py index bc35e1b..d0e58a5 100644 --- a/dashscope/acli/hooks.py +++ b/dashscope/acli/hooks.py @@ -99,9 +99,11 @@ def _build_variables(ctx: HookContext) -> dict[str, str]: "filename_stem": p.stem if p else "", "exit_code": "", "content": "", - "args": json.dumps(ctx.arguments, ensure_ascii=False) - if ctx.arguments - else "", + "args": ( + json.dumps(ctx.arguments, ensure_ascii=False) + if ctx.arguments + else "" + ), "result": (ctx.result or "")[:1000], "error": (ctx.result or "")[:1000] if ctx.success is False else "", } diff --git a/dashscope/acli/mcp_stdio.py b/dashscope/acli/mcp_stdio.py new file mode 100644 index 0000000..bd92e13 --- /dev/null +++ b/dashscope/acli/mcp_stdio.py @@ -0,0 +1,312 @@ +# -*- coding: utf-8 -*- +"""MCP stdio transport client. + +Speaks newline-delimited JSON-RPC 2.0 to a local MCP server subprocess +over stdin/stdout. Mirrors the SSE ``MCPClient`` interface (initialize / +list_tools / list_prompts / call_tool / close plus ``tools``, ``prompts`` +and ``last_error`` attributes) so ``cli/mcp.py`` can use either transport +interchangeably. +""" + +from __future__ import annotations + +import asyncio +import json + +from dashscope.acli.platforms.bailian.mcp import MCP_PROTOCOL_VERSION, MCPError + +__all__ = ["StdioMCPClient", "MCPError"] + +_CLIENT_INFO = {"name": "acli", "version": "0.1.0"} +_CLOSE_TIMEOUT = 3.0 +_STDERR_TAIL = 500 + + +class StdioMCPClient: + """MCP client that drives a local server over stdio. + + The server process is spawned with ``command`` + ``args``; requests + and responses are single-line JSON documents. A background reader + matches response ids to pending request futures; server notifications + (messages without an ``id``) are ignored. + """ + + def __init__( + self, + command: str, + args: list[str] | None = None, + timeout: float = 30.0, + ): + if not command: + raise MCPError("stdio MCP server requires a command") + self.command = command + self.args = list(args or []) + self.timeout = timeout + self.tools: list[dict] = [] + self.prompts: list[dict] = [] + self.last_error = "" + self._proc: asyncio.subprocess.Process | None = None + self._reader_task: asyncio.Task | None = None + self._pending: dict[int, asyncio.Future] = {} + self._request_id = 0 + self._stderr_tail = "" + + # ------------------------------------------------------------------ # + # Lifecycle + # ------------------------------------------------------------------ # + async def initialize(self) -> bool: + """Spawn the server, run the MCP initialize handshake.""" + try: + self._proc = await asyncio.create_subprocess_exec( + self.command, + *self.args, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except (OSError, ValueError) as exc: + self.last_error = f"Failed to start MCP server: {exc}" + return False + + self._reader_task = asyncio.create_task(self._read_loop()) + + params = { + "protocolVersion": MCP_PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": dict(_CLIENT_INFO), + } + try: + resp = await self._request("initialize", params) + except MCPError as exc: + self.last_error = str(exc) + await self.close() + return False + if "error" in resp: + error = resp["error"] + message = error.get("message", str(error)) + self.last_error = f"MCP initialize failed: {message}" + await self.close() + return False + try: + await self._notify("notifications/initialized") + except MCPError as exc: + self.last_error = str(exc) + await self.close() + return False + return True + + async def close(self) -> None: + """Stop the reader, close stdin, terminate the subprocess.""" + self._fail_all_pending("connection closed") + if self._reader_task and not self._reader_task.done(): + self._reader_task.cancel() + try: + await self._reader_task + except (asyncio.CancelledError, Exception): + pass + self._reader_task = None + + proc = self._proc + self._proc = None + if proc is None: + return + if proc.stdin and not proc.stdin.is_closing(): + try: + proc.stdin.close() + except (OSError, RuntimeError): + pass + if proc.returncode is None: + try: + await asyncio.wait_for(proc.wait(), timeout=_CLOSE_TIMEOUT) + except asyncio.TimeoutError: + try: + proc.terminate() + await asyncio.wait_for( + proc.wait(), + timeout=_CLOSE_TIMEOUT, + ) + except (asyncio.TimeoutError, ProcessLookupError, OSError): + try: + proc.kill() + except (ProcessLookupError, OSError): + pass + await self._drain_stderr(proc) + + # ------------------------------------------------------------------ # + # MCP operations + # ------------------------------------------------------------------ # + async def list_tools(self) -> list[dict]: + resp = await self._request("tools/list") + if "error" in resp: + error = resp["error"] + message = error.get("message", str(error)) + raise MCPError(f"tools/list failed: {message}") + result = resp.get("result", {}) + self.tools = result.get("tools", []) + return self.tools + + async def list_prompts(self) -> list[dict]: + """Discover prompts/skills; returns [] when unsupported.""" + try: + resp = await self._request("prompts/list") + except MCPError: + return [] + if "error" in resp: + return [] + result = resp.get("result", {}) + self.prompts = result.get("prompts", []) + return self.prompts + + async def call_tool(self, tool_name: str, arguments: dict) -> str: + params = {"name": tool_name, "arguments": arguments} + resp = await self._request("tools/call", params) + if "error" in resp: + error = resp["error"] + message = error.get("message", str(error)) + return f"MCP Error: {message}" + result = resp.get("result", {}) + content = result.get("content", []) + parts: list[str] = [] + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + parts.append(str(item.get("text", ""))) + else: + parts.append(json.dumps(item, ensure_ascii=False)) + return "\n".join(parts) if parts else str(result) + + # ------------------------------------------------------------------ # + # JSON-RPC plumbing + # ------------------------------------------------------------------ # + def _next_id(self) -> int: + self._request_id += 1 + return self._request_id + + async def _request( + self, + method: str, + params: dict | None = None, + ) -> dict: + """Send a request and await the matching response.""" + proc = self._proc + if proc is None or proc.stdin is None: + raise MCPError("stdio MCP client is not connected") + req_id = self._next_id() + payload = { + "jsonrpc": "2.0", + "method": method, + "params": params or {}, + "id": req_id, + } + future: asyncio.Future = asyncio.get_running_loop().create_future() + self._pending[req_id] = future + try: + await self._write(payload) + except MCPError: + self._pending.pop(req_id, None) + raise + try: + return await asyncio.wait_for(future, timeout=self.timeout) + except asyncio.TimeoutError: + self._pending.pop(req_id, None) + message = ( + f"MCP request '{method}' timed out " f"after {self.timeout:g}s" + ) + raise MCPError(message) from None + + async def _notify( + self, + method: str, + params: dict | None = None, + ) -> None: + """Send a notification (no id, no response expected).""" + if self._proc is None or self._proc.stdin is None: + raise MCPError("stdio MCP client is not connected") + payload = {"jsonrpc": "2.0", "method": method} + if params: + payload["params"] = params + await self._write(payload) + + async def _write(self, payload: dict) -> None: + proc = self._proc + if proc is None or proc.stdin is None: + raise MCPError("stdio MCP client is not connected") + line = json.dumps(payload, ensure_ascii=False) + "\n" + try: + proc.stdin.write(line.encode("utf-8")) + await proc.stdin.drain() + except ( + ConnectionResetError, + BrokenPipeError, + OSError, + RuntimeError, + ) as exc: + raise MCPError( + f"MCP server process is gone: {exc}", + ) from exc + + async def _read_loop(self) -> None: + """Read stdout lines and resolve pending futures by id.""" + proc = self._proc + if proc is None or proc.stdout is None: + return + try: + while True: + raw = await proc.stdout.readline() + if not raw: + self._fail_all_pending( + "MCP server closed stdout" + self._stderr_suffix(), + ) + return + line = raw.decode("utf-8", errors="replace").strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + # stdout is reserved for JSON-RPC messages; anything + # else is a protocol violation — fail in-flight + # requests instead of hanging until timeout. + self._stderr_tail = ( + self._stderr_tail + "non-JSON: " + line + "\n" + )[-_STDERR_TAIL:] + message = ( + "MCP server sent non-JSON output: " f"{line[:100]}" + ) + self._fail_all_pending(message) + return + if not isinstance(msg, dict): + continue + msg_id = msg.get("id") + if msg_id is None: + continue # server notification — ignore + future = self._pending.pop(msg_id, None) + if future is not None and not future.done(): + future.set_result(msg) + except asyncio.CancelledError: # pylint: disable=try-except-raise + raise + except Exception as exc: # stream broke unexpectedly + self._fail_all_pending(f"MCP stdio stream error: {exc}") + + def _fail_all_pending(self, reason: str) -> None: + pending, self._pending = self._pending, {} + for future in pending.values(): + if not future.done(): + future.set_exception(MCPError(reason)) + + async def _drain_stderr(self, proc: asyncio.subprocess.Process) -> None: + if proc.stderr is None: + return + try: + data = await asyncio.wait_for( + proc.stderr.read(_STDERR_TAIL), + timeout=1.0, + ) + if data: + text = data.decode("utf-8", errors="replace").strip() + self._stderr_tail = (self._stderr_tail + text)[-_STDERR_TAIL:] + except (asyncio.TimeoutError, OSError): + pass + + def _stderr_suffix(self) -> str: + tail = self._stderr_tail.strip() + return f" (stderr: {tail})" if tail else "" diff --git a/dashscope/acli/memory/experience.py b/dashscope/acli/memory/experience.py index 4bebefa..1085e60 100644 --- a/dashscope/acli/memory/experience.py +++ b/dashscope/acli/memory/experience.py @@ -10,10 +10,7 @@ from pathlib import Path from typing import Any -from dashscope.acli.utils.keywords import ( - expand_scoring_terms, - extract_keywords, -) +from dashscope.acli.utils.keywords import expand_scoring_terms, extract_keywords class ExperienceTracker: diff --git a/dashscope/acli/memory/tool_chains.py b/dashscope/acli/memory/tool_chains.py index 5fcfa39..1512314 100644 --- a/dashscope/acli/memory/tool_chains.py +++ b/dashscope/acli/memory/tool_chains.py @@ -8,10 +8,7 @@ from pathlib import Path -from dashscope.acli.utils.keywords import ( - expand_scoring_terms, - extract_keywords, -) +from dashscope.acli.utils.keywords import expand_scoring_terms, extract_keywords # Common tool chain patterns with examples TOOL_CHAINS = { diff --git a/dashscope/acli/memory/trace.py b/dashscope/acli/memory/trace.py index b3e41dc..ff41e20 100644 --- a/dashscope/acli/memory/trace.py +++ b/dashscope/acli/memory/trace.py @@ -189,9 +189,9 @@ def generate_report(trace_logger: TraceLogger | None) -> dict | None: return { "total_llm_calls": llm_calls, "total_tool_calls": tool_calls, - "tool_success_rate": (tool_successes / tool_calls) - if tool_calls - else 0.0, + "tool_success_rate": ( + (tool_successes / tool_calls) if tool_calls else 0.0 + ), "avg_response_time": avg_response_time, "top_tools": top_tools, } diff --git a/dashscope/acli/platforms/bailian/__init__.py b/dashscope/acli/platforms/bailian/__init__.py index 0f7f0f6..415432d 100644 --- a/dashscope/acli/platforms/bailian/__init__.py +++ b/dashscope/acli/platforms/bailian/__init__.py @@ -1,10 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import annotations -from dashscope.acli.platforms.bailian.cli import ( - BailianCLIClient, - BailianCLIError, -) +from dashscope.acli.platforms.bailian.cli import BailianCLIClient, BailianCLIError from dashscope.acli.platforms.bailian.mcp import MCPClient, MCPError __all__ = [ diff --git a/dashscope/acli/platforms/base.py b/dashscope/acli/platforms/base.py index e28d071..39b2e7f 100644 --- a/dashscope/acli/platforms/base.py +++ b/dashscope/acli/platforms/base.py @@ -206,7 +206,11 @@ def list_files( ) -> list[FileInfo]: ... - def delete_file(self, file_id: str, category_id: str = "default") -> bool: + def delete_file( + self, + file_id: str, + category_id: str = "default", + ) -> bool: ... def list_categories( diff --git a/dashscope/acli/providers/openai.py b/dashscope/acli/providers/openai.py index a956cf8..ad5c1b1 100644 --- a/dashscope/acli/providers/openai.py +++ b/dashscope/acli/providers/openai.py @@ -148,9 +148,11 @@ async def chat( "output_tokens": getattr(resp_usage, "completion_tokens", 0) or 0, "total_tokens": getattr(resp_usage, "total_tokens", 0) or 0, - "cached_tokens": (getattr(details, "cached_tokens", 0) or 0) - if details - else 0, + "cached_tokens": ( + (getattr(details, "cached_tokens", 0) or 0) + if details + else 0 + ), } return LLMResponse( @@ -208,10 +210,10 @@ async def chat_stream( or 0, "total_tokens": getattr(usage, "total_tokens", 0) or 0, "cached_tokens": ( - getattr(details, "cached_tokens", 0) or 0 - ) - if details - else 0, + (getattr(details, "cached_tokens", 0) or 0) + if details + else 0 + ), } continue diff --git a/dashscope/acli/providers/profile.py b/dashscope/acli/providers/profile.py index 43296c6..dd02fd1 100644 --- a/dashscope/acli/providers/profile.py +++ b/dashscope/acli/providers/profile.py @@ -13,10 +13,7 @@ from typing import AsyncIterator from dashscope.acli.providers.base import LLMChunk, LLMProvider, LLMResponse -from dashscope.acli.providers.hardening import ( - HardenedProvider, - is_retryable_error, -) +from dashscope.acli.providers.hardening import HardenedProvider, is_retryable_error _API_KEY_ENVS = { "tongyi": "DASHSCOPE_API_KEY", diff --git a/dashscope/acli/providers/tongyi.py b/dashscope/acli/providers/tongyi.py index 8bd34fb..3929bbb 100644 --- a/dashscope/acli/providers/tongyi.py +++ b/dashscope/acli/providers/tongyi.py @@ -134,9 +134,7 @@ async def chat( # If protocol is anthropic, convert input from Anthropic to # OpenAI format if self.protocol == "anthropic": - from dashscope.acli.providers.adapter import ( - anthropic_to_openai_request, - ) + from dashscope.acli.providers.adapter import anthropic_to_openai_request # Agent may send system as first message, extract it system_msg = None @@ -235,9 +233,7 @@ async def chat_stream( # If protocol is anthropic, convert input from Anthropic to # OpenAI format if self.protocol == "anthropic": - from dashscope.acli.providers.adapter import ( - anthropic_to_openai_request, - ) + from dashscope.acli.providers.adapter import anthropic_to_openai_request # Agent may send system as first message, extract it system_msg = None diff --git a/dashscope/acli/session.py b/dashscope/acli/session.py index 07e690c..36fee7c 100644 --- a/dashscope/acli/session.py +++ b/dashscope/acli/session.py @@ -13,6 +13,7 @@ from dataclasses import asdict, dataclass from datetime import datetime from pathlib import Path +from typing import Any from dashscope.acli.config import WORKSPACE_DIR @@ -254,6 +255,24 @@ def get_input_history_path(self, topic: str | None = None) -> Path: topic = topic or self.current_topic return self._input_history_file(topic) + def load_messages( + self, + topic: str | None = None, + ) -> list[dict[str, Any]]: + """Load stored chat messages for a topic (default: current). + + Returns an empty list when the history file is missing, + unreadable, or not a JSON list. + """ + path = self._history_file(topic or self.current_topic) + if not path.exists(): + return [] + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return [] + return data if isinstance(data, list) else [] + def update_message_count( self, count: int, diff --git a/dashscope/acli/tools/checkpoint.py b/dashscope/acli/tools/checkpoint.py new file mode 100644 index 0000000..e6f84af --- /dev/null +++ b/dashscope/acli/tools/checkpoint.py @@ -0,0 +1,167 @@ +# -*- coding: utf-8 -*- +"""File checkpoint/undo support for mutating file tools. + +Backups live under ``/.acli/checkpoints/`` next to a JSONL +index. ``snapshot()`` records the pre-mutation state of a file and +``undo()`` reverses the most recent recorded mutation. +""" + +from __future__ import annotations + +import json +import os +import shutil +import time +import uuid +from pathlib import Path + +from rich.console import Console + +console = Console() + +# Maximum number of checkpoint entries kept in the index. +_MAX_ENTRIES = 50 + +_INDEX_NAME = "index.jsonl" + +# Maps a checkpoint action to the tool name shown in undo messages. +_TOOL_BY_ACTION = { + "overwrite": "write_file", + "create": "write_file", + "delete": "delete_file", +} + + +def _checkpoint_dir() -> Path: + """Directory holding backup files and the JSONL index.""" + # Lazy import so loading this module never triggers config cycles. + from dashscope.acli.config import WORKSPACE_DIR + + return Path(WORKSPACE_DIR) / "checkpoints" + + +def _read_entries(cp_dir: Path) -> list[dict]: + """Load index entries, skipping blank or malformed lines.""" + index = cp_dir / _INDEX_NAME + if not index.is_file(): + return [] + entries: list[dict] = [] + with open(index, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + entries.append(json.loads(line)) + except json.JSONDecodeError: + continue + return entries + + +def _write_entries(cp_dir: Path, entries: list[dict]) -> None: + """Rewrite the index atomically (temp file + rename).""" + index = cp_dir / _INDEX_NAME + tmp = index.with_name(_INDEX_NAME + ".tmp") + with open(tmp, "w", encoding="utf-8") as f: + for entry in entries: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + os.replace(tmp, index) + + +def snapshot(path: str, action: str) -> None: + """Record a pre-mutation checkpoint for *path*. + + *action* is one of ``"overwrite"``, ``"create"`` or ``"delete"``. + Never raises: a checkpoint failure must not break the write tool. + """ + try: + _snapshot(path, action) + except Exception: + # Checkpointing is best-effort; skip silently on any error. + pass + + +def _snapshot(path: str, action: str) -> None: + """Internal snapshot implementation (may raise).""" + abs_path = os.path.abspath(path) + cp_dir = _checkpoint_dir() + cp_dir.mkdir(parents=True, exist_ok=True) + entry_id = uuid.uuid4().hex + backup = None + if os.path.isfile(abs_path): + backup = entry_id + ".bak" + shutil.copy2(abs_path, cp_dir / backup) + entry = { + "id": entry_id, + "path": abs_path, + "backup": backup, + "action": action, + "ts": time.time(), + } + entries = _read_entries(cp_dir) + entries.append(entry) + overflow = len(entries) - _MAX_ENTRIES + dropped = entries[:overflow] if overflow > 0 else [] + _write_entries(cp_dir, entries[-_MAX_ENTRIES:]) + for old in dropped: + name = old.get("backup") + if not name: + continue + try: + os.remove(cp_dir / name) + except OSError: + pass + + +def undo() -> str: + """Undo the most recent checkpointed file mutation. + + Returns a human-readable result string; never raises. + """ + try: + return _undo() + except Exception as e: + return f"Error: undo failed - {e}" + + +def _undo() -> str: + """Internal undo implementation (may raise).""" + cp_dir = _checkpoint_dir() + entries = _read_entries(cp_dir) + if not entries: + return "Nothing to undo" + entry = entries.pop() + _write_entries(cp_dir, entries) + + path = entry.get("path") or "" + action = entry.get("action") or "" + backup = entry.get("backup") + tool_name = _TOOL_BY_ACTION.get(action, action or "unknown") + + if backup: + src = cp_dir / backup + if not src.is_file(): + return f"Error: backup file missing for {path}" + parent = os.path.dirname(path) + if parent: + os.makedirs(parent, exist_ok=True) + shutil.copy2(src, path) + try: + os.remove(src) + except OSError: + pass + return f"Undid {tool_name}: restored {path}" + + if action == "create": + try: + os.remove(path) + except FileNotFoundError: + pass + return f"Undid {tool_name}: removed created {path}" + + return f"Error: no backup recorded for {path}" + + +def handle_undo_command() -> None: + """CLI-facing wrapper: undo the last change and print the result.""" + console.print(undo()) diff --git a/dashscope/acli/tools/filesystem.py b/dashscope/acli/tools/filesystem.py index 9dbf595..54985f1 100644 --- a/dashscope/acli/tools/filesystem.py +++ b/dashscope/acli/tools/filesystem.py @@ -91,6 +91,12 @@ def write_file(path: str, content: str) -> str: except (OSError, UnicodeDecodeError): existed = False # treat as new for diff purposes + # Checkpoint the current state so /undo can reverse this write. + # Lazy import to avoid tool-module import cycles. + from dashscope.acli.tools import checkpoint + + checkpoint.snapshot(path, "overwrite" if existed else "create") + with open(path, "w", encoding="utf-8") as f: f.write(content) @@ -187,6 +193,10 @@ def delete_file(path: str) -> str: return f"Error: {e}" if not os.path.isfile(path): return f"Error: file not found - {path}" + # Checkpoint so /undo can restore the deleted file. + from dashscope.acli.tools import checkpoint + + checkpoint.snapshot(path, "delete") os.remove(path) return f"Deleted file: {path}" diff --git a/dashscope/acli/tools/platform.py b/dashscope/acli/tools/platform.py index 2269065..f90d910 100644 --- a/dashscope/acli/tools/platform.py +++ b/dashscope/acli/tools/platform.py @@ -5,11 +5,7 @@ from dashscope.acli.config import Config from dashscope.acli.platforms import get_cli_provider -from dashscope.acli.tools.registry import ( - PermissionLevel, - ToolDefinition, - registry, -) +from dashscope.acli.tools.registry import PermissionLevel, ToolDefinition, registry # Track which tool names each capability registered, so /capability disable # can unregister them mid-session (the prior "takes effect on restart" @@ -131,10 +127,7 @@ def register_one_capability( if cli_client := get_cli_provider(config): _register_bailian_cli_tools(cli_client) elif cap_key == "local.subagent": - from dashscope.acli.agents.subagent import ( - _has_parent, - register_subagent_tool, - ) + from dashscope.acli.agents.subagent import _has_parent, register_subagent_tool if _has_parent(): register_subagent_tool() @@ -142,10 +135,7 @@ def register_one_capability( # was called too early — cli.py wires _set_parent_agent + a final # re-register pass after Agent construction. elif cap_key == "local.delegate": - from dashscope.acli.agents.delegate import ( - _has_parent, - register_delegate_tools, - ) + from dashscope.acli.agents.delegate import _has_parent, register_delegate_tools if _has_parent(): register_delegate_tools() diff --git a/dashscope/acli/tools/session.py b/dashscope/acli/tools/session.py index d6c855d..03bfbb5 100644 --- a/dashscope/acli/tools/session.py +++ b/dashscope/acli/tools/session.py @@ -8,11 +8,7 @@ from typing import Callable from dashscope.acli.config import PROVIDER_MODELS, Config, normalize_model_name -from dashscope.acli.tools.registry import ( - PermissionLevel, - ToolDefinition, - registry, -) +from dashscope.acli.tools.registry import PermissionLevel, ToolDefinition, registry def register_session_tools( diff --git a/dashscope/acli/ui/tui.py b/dashscope/acli/ui/tui.py index e3deafe..3041a16 100644 --- a/dashscope/acli/ui/tui.py +++ b/dashscope/acli/ui/tui.py @@ -58,18 +58,10 @@ from textual.screen import Screen # noqa: E402 from textual.selection import SelectEnd, Selection # noqa: E402 from textual.strip import Strip # noqa: E402 -from textual.widgets import ( # noqa: E402 - OptionList, - RichLog, - Static, - TextArea, -) +from textual.widgets import OptionList, RichLog, Static, TextArea # noqa: E402 from textual.widgets.option_list import Option # noqa: E402 -from dashscope.acli.commands import ( # noqa: E402 - handle_shell_escape, - render_help_text, -) +from dashscope.acli.commands import handle_shell_escape, render_help_text # noqa: E402 from dashscope.acli.utils import ( # noqa: E402 UserAbortedTurn, UserSupplement, diff --git a/dashscope/acli/utils/__init__.py b/dashscope/acli/utils/__init__.py index 03c2285..9b2f879 100644 --- a/dashscope/acli/utils/__init__.py +++ b/dashscope/acli/utils/__init__.py @@ -21,16 +21,9 @@ validate_path, validate_write_path, ) -from dashscope.acli.utils.sanitizer import ( - is_secret_field, - sanitize, - sanitize_text, -) +from dashscope.acli.utils.sanitizer import is_secret_field, sanitize, sanitize_text from dashscope.acli.utils.spinner import AsyncSpinner, StderrSpinner -from dashscope.acli.utils.template import ( - render_brace_template, - render_mustache_template, -) +from dashscope.acli.utils.template import render_brace_template, render_mustache_template from dashscope.acli.utils.text import ( mask_secret, strip_frontmatter, From 5c2d56cc1cee54bab9f0ac78e5e0549f64bcf2cf Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Tue, 18 Aug 2026 14:26:32 +0800 Subject: [PATCH 02/16] feat(acli): sync extended dangerous-command blocklist (P0-L1) Synced from agenticCLI cadee49: run_command's deny gate now also blocks rm -rf home variants, block-device writes, fork bombs, chown -R on system paths, shutdown/reboot-class commands, pipe-to-shell downloads, history wiping, and macOS disk erase/CSR-disable. UT green. --- dashscope/acli/tools/shell.py | 77 +++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/dashscope/acli/tools/shell.py b/dashscope/acli/tools/shell.py index 1fb6832..a4aaceb 100644 --- a/dashscope/acli/tools/shell.py +++ b/dashscope/acli/tools/shell.py @@ -21,6 +21,10 @@ "dd if=", "> /dev/sd", "chmod -R 777 /", + "shred", + "wipefs", + # rm with this flag always targets `/` — no benign use exists + "--no-preserve-root", # Windows "rd /s /q", "del /f /q /s", @@ -35,6 +39,73 @@ _RM_ROOT_RE = re.compile(r"\brm\s+-\w*[rf]\w*\s+/(?:\s*\*?\s*(?:$|[;&|]))") _FORMAT_CMD_RE = re.compile(r"(?:^|[;&|]\s*)format(?:\s|$)") +# Home / cwd wipes: `rm -rf ~`, `rm -rf .`, but NOT `rm -rf ./build`. +_RM_HOME_RE = re.compile( + r"\brm\s+(?:-\w+\s+)*-\w*[rf]\w*\s+(?:--\s+)?(?:~|\.\.?)/?" + r"(?=\s|$|[;&|])", +) +# Fork bombs: a function that pipes itself into itself in the background, +# e.g. `:(){ :|:& };:` or `bomb(){ bomb|bomb& };bomb`. +_FORK_BOMB_RE = re.compile( + r"(\S+)\s*\(\)\s*\{\s*\1\s*\|\s*\1\s*&\s*\}\s*;", +) +# Redirects writing to raw block devices (`> /dev/sda`, `>>/dev/nvme0n1`). +_DEV_WRITE_RE = re.compile( + r">\s*/dev/(?:sd|hd|vd|xvd|nvme|mmcblk|disk)\w*", +) +# Recursive chown of whole system trees (`chown -R u:g /etc`, `/`, ...). +# `/home` & co only match bare: `chown -R u /home/lzs` is everyday work. +_CHOWN_SYSTEM_RE = re.compile( + r"\bchown\s+(?:-\w+\s+)*-R\s+\S+\s+" + r"(?:/(?:etc|usr|bin|sbin|lib64|lib|boot|dev|sys)(?:[/\s;&|]|$)" + r"|/(?:home|var|opt|srv|root)?(?:[\s;&|]|$))", +) +# Power-state commands as actual command tokens (incl. `sudo reboot`). +_SYSTEM_STATE_RE = re.compile( + r"(?:^|[;&|]\s*)(?:sudo\s+)?" + r"(?:shutdown|reboot|halt|poweroff)(?:\s|$|[;&|])", +) +_INIT_HALT_RE = re.compile( + r"(?:^|[;&|]\s*)(?:sudo\s+)?init\s+[06](?:\s|$|[;&|])", +) +_SYSTEMCTL_HALT_RE = re.compile( + r"\bsystemctl\s+(?:-\S+\s+)*(?:poweroff|reboot|halt)(?:\s|$|[;&|])", +) +# Killing PID 1 drags the whole system down with it. +_KILL_PID1_RE = re.compile(r"\bkill\s+(?:-\S+\s+)*1(?:\s|$|[;&|])") +# Shell-history destruction (`history -c`, `history -cw`). +_HISTORY_CLEAR_RE = re.compile(r"\bhistory\s+-\w*c") +# Remote-exec pipes: `curl ... | sh`, `wget ... | bash`, with any flags +# between the downloader and the pipe. +_PIPE_TO_SHELL_RE = re.compile( + r"\b(?:curl|wget)\b[^\n]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh|dash|ksh)\b", +) +# Partition-table editors as actual command tokens. +_PARTITION_CMD_RE = re.compile( + r"(?:^|[;&|]\s*)(?:sudo\s+)?(?:fdisk|parted)(?:\s|$)", +) +# macOS: whole-disk erase and Secure Boot bypass. +_DISKUTIL_ERASE_RE = re.compile(r"\bdiskutil\s+eraseDisk\b") +_CSRUTIL_DISABLE_RE = re.compile(r"\bcsrutil\s+disable\b") + +# (label, regex) pairs checked by run_command after BLOCKED_PATTERNS; +# the label is quoted in the block error message. +_BLOCKED_REGEXES: list[tuple[str, re.Pattern[str]]] = [ + ("rm -rf ~", _RM_HOME_RE), + ("fork bomb", _FORK_BOMB_RE), + ("> /dev/", _DEV_WRITE_RE), + ("chown -R on system paths", _CHOWN_SYSTEM_RE), + ("shutdown/reboot/halt/poweroff", _SYSTEM_STATE_RE), + ("init 0/init 6", _INIT_HALT_RE), + ("systemctl poweroff/reboot/halt", _SYSTEMCTL_HALT_RE), + ("kill PID 1", _KILL_PID1_RE), + ("history -c", _HISTORY_CLEAR_RE), + ("curl/wget piped into a shell", _PIPE_TO_SHELL_RE), + ("fdisk/parted", _PARTITION_CMD_RE), + ("diskutil eraseDisk", _DISKUTIL_ERASE_RE), + ("csrutil disable", _CSRUTIL_DISABLE_RE), +] + # ----- Read-only command classifier --------------------------------------- # Used by the executor to auto-approve obvious inspection commands so the # user isn't asked to confirm every `grep`/`ls`/`git status`. Conservative @@ -439,6 +510,12 @@ async def run_command(command: str, timeout: int | None = None) -> str: return "Error: command blocked (contains dangerous pattern: rm -rf /)" if _FORMAT_CMD_RE.search(command): return "Error: command blocked (contains dangerous pattern: format)" + for name, regex in _BLOCKED_REGEXES: + if regex.search(command): + return ( + f"Error: command blocked " + f"(contains dangerous pattern: {name})" + ) # Belt-and-suspenders on top of utils.validation.coerce_types: if the model # still managed to slip something un-castable through (e.g. "auto"), From 08f400fd0af2ffb6db918669c8307f8da1cdae6f Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Tue, 18 Aug 2026 15:25:54 +0800 Subject: [PATCH 03/16] feat(acli): sync append-only session event log sidecar (P2-Phase2 step 1) Synced from agenticCLI 2307c7c: SessionEventLog (append-only JSONL per topic, schema-versioned, best-effort) + SessionManager.event_log() with topic/created, topic/switched, topic/renamed lifecycle events. The history.json read/write paths are untouched. UT green. --- dashscope/acli/session.py | 23 ++++++ dashscope/acli/session_events.py | 123 +++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 dashscope/acli/session_events.py diff --git a/dashscope/acli/session.py b/dashscope/acli/session.py index 36fee7c..afc74d8 100644 --- a/dashscope/acli/session.py +++ b/dashscope/acli/session.py @@ -5,6 +5,8 @@ - history.json: message history - input-history.txt: command input history - meta.json: metadata (created, last_accessed, message_count) + - events.jsonl: append-only event sidecar (lifecycle; see + session_events.SessionEventLog) """ from __future__ import annotations @@ -16,6 +18,7 @@ from typing import Any from dashscope.acli.config import WORKSPACE_DIR +from dashscope.acli.session_events import EVENTS_FILENAME, SessionEventLog DEFAULT_TOPIC = "default" @@ -103,6 +106,20 @@ def _meta_file(self, topic: str) -> Path: """Get the meta.json path for a topic.""" return self._topic_dir(topic) / "meta.json" + def _events_file(self, topic: str) -> Path: + """Get the events.jsonl sidecar path for a topic.""" + return self._topic_dir(topic) / EVENTS_FILENAME + + def event_log(self, topic: str | None = None) -> SessionEventLog: + """Return the append-only event log for a topic (default: current). + + The sidecar is advisory: callers may append lifecycle/turn events + without affecting the history.json read/write path. + """ + return SessionEventLog( + self._events_file(topic or self.current_topic), + ) + def list_topics(self) -> list[SessionMeta]: """List all available topics with metadata.""" topics = [] @@ -165,6 +182,7 @@ def set_current_topic(self, topic: str) -> bool: return False self.current_topic = topic self._update_last_accessed(topic) + self.event_log(topic).append("topic/switched", {"topic": topic}) return True def create_topic(self, topic: str) -> bool: @@ -180,6 +198,7 @@ def create_topic(self, topic: str) -> bool: ) self._save_meta(topic, meta) self.current_topic = topic + self.event_log(topic).append("topic/created", {"topic": topic}) return True def rename_topic(self, old_name: str, new_name: str) -> bool: @@ -222,6 +241,10 @@ def rename_topic(self, old_name: str, new_name: str) -> bool: if self.current_topic == old_name: self.current_topic = new_name + self.event_log(new_name).append( + "topic/renamed", + {"from": old_name, "to": new_name}, + ) return True except OSError: return False diff --git a/dashscope/acli/session_events.py b/dashscope/acli/session_events.py new file mode 100644 index 0000000..2f0cab5 --- /dev/null +++ b/dashscope/acli/session_events.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +"""Append-only session event log (roadmap P2-Phase2, first increment). + +An event sidecar that records session lifecycle (and, in later +increments, turn) events as an append-only JSONL stream, without +touching the existing ``history.json`` read/write path. This lays the +groundwork for future projection / fork / resume built on an immutable +event source. + +Storage layout (per topic):: + + .acli/session//events.jsonl + +Each line is a self-describing event:: + + {"v": 1, "seq": 3, "ts": "", "type": "topic/created", + "data": {...}} + +Writes are best-effort: an event-log failure never breaks the session, +mirroring the checkpoint/history philosophy. +""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any + +# Bump when the on-disk event schema changes in a breaking way. +SCHEMA_VERSION = 1 + +EVENTS_FILENAME = "events.jsonl" + + +class SessionEventLog: + """Append-only JSONL event log bound to one file. + + The log is strictly append-only: events are only ever added, never + rewritten or removed through this API. ``seq`` is a 1-based + monotonically increasing position derived from the current line + count, so it stays correct even if another process appends. + """ + + def __init__(self, events_file: Path): + self._file = Path(events_file) + + @property + def path(self) -> Path: + return self._file + + def append(self, event_type: str, data: dict | None = None) -> None: + """Append one event. Never raises (best-effort sidecar).""" + try: + self._append(event_type, data or {}) + except Exception: + # The event log is advisory; never break the caller. + pass + + def _append(self, event_type: str, data: dict) -> None: + self._file.parent.mkdir(parents=True, exist_ok=True) + entry = { + "v": SCHEMA_VERSION, + "seq": self._next_seq(), + "ts": datetime.now().isoformat(), + "type": event_type, + "data": data, + } + with open(self._file, "a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + + def _next_seq(self) -> int: + """1 + number of non-blank lines currently in the log.""" + if not self._file.exists(): + return 1 + count = 0 + with open(self._file, "r", encoding="utf-8") as f: + for line in f: + if line.strip(): + count += 1 + return count + 1 + + def read( + self, + event_type: str | None = None, + ) -> list[dict[str, Any]]: + """Return events in order, optionally filtered by type. + + Malformed or wrong-schema lines are skipped silently. + """ + if not self._file.exists(): + return [] + events: list[dict[str, Any]] = [] + with open(self._file, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(entry, dict): + continue + if entry.get("v") != SCHEMA_VERSION: + continue + if event_type is not None and entry.get("type") != event_type: + continue + events.append(entry) + return events + + def tail( + self, + n: int, + event_type: str | None = None, + ) -> list[dict[str, Any]]: + """Return the most recent ``n`` events (optionally by type).""" + if n <= 0: + return [] + return self.read(event_type)[-n:] + + def __len__(self) -> int: + return len(self.read()) From 20fe1575e86dc714375b74fa95c36a8f5449744f Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Wed, 19 Aug 2026 11:15:50 +0800 Subject: [PATCH 04/16] feat(acli): sync sandbox + evolution + scene memory from agenticCLI Synced from agenticCLI (935465b, 59024c3, plus per-topic scene memory b2037d2 and startup patchability fix eba051b): - Opt-in OS sandbox for shell commands (P0-L3): sandbox.py backend detection (macOS sandbox-exec / Linux bwrap), write-confinement to the workspace, graceful degradation; config `sandbox` flag; run_command uses the sandbox argv when enabled+available. - Skill distillation quality (P3): analyze_trajectory now requires the final tool result to have succeeded. - Per-topic scene memory and WORKSPACE_DIR-at-call-time startup fix. pre-commit (changed files): green. UT: 454 passed, 6 skipped. --- dashscope/acli/agent.py | 14 ++ dashscope/acli/cli/handlers_session.py | 71 ++++++--- dashscope/acli/cli/startup.py | 11 +- dashscope/acli/commands.py | 6 +- dashscope/acli/config.py | 8 + dashscope/acli/memory/skill_evolution.py | 15 ++ dashscope/acli/prompt_pipeline.py | 22 +++ dashscope/acli/sandbox.py | 181 +++++++++++++++++++++++ dashscope/acli/session.py | 57 +++++++ dashscope/acli/tools/shell.py | 27 +++- 10 files changed, 381 insertions(+), 31 deletions(-) create mode 100644 dashscope/acli/sandbox.py diff --git a/dashscope/acli/agent.py b/dashscope/acli/agent.py index 6b6bbe7..8a2acc6 100644 --- a/dashscope/acli/agent.py +++ b/dashscope/acli/agent.py @@ -326,11 +326,25 @@ def _system_prompt_for_turn(self, user_input_text: str) -> str: experience_tracker=self.experience_tracker, disabled_caps_provider=self.disabled_caps_provider, directives_provider=self.directives_provider, + scene_provider=self._scene_section, current_turn_tools=self._current_turn_tools, connected_mcp_services=self._connected_mcp_services, ) return self._prompt_pipeline.render(ctx) + def _scene_section(self) -> str: + """Scene memory of the current session topic (best-effort). + + Subagents and SDK callers may run without a session manager; + any failure simply yields no scene section. + """ + try: + from dashscope.acli.session import get_session_manager + + return get_session_manager().get_scene() + except Exception: + return "" + def _reflection_section(self) -> str: """Inject reflection hints when repeated failures detected.""" tracker = self.memory_manager.session.reflection diff --git a/dashscope/acli/cli/handlers_session.py b/dashscope/acli/cli/handlers_session.py index 76fcbfa..6ee91d4 100644 --- a/dashscope/acli/cli/handlers_session.py +++ b/dashscope/acli/cli/handlers_session.py @@ -8,6 +8,21 @@ console = Console() +_SESSION_USAGE = ( + "\n[dim]Usage:\n" + " /session — show current topic\n" + " /session new [name] — new session (default: default)\n" + " /session list — list all sessions\n" + " /session switch — switch to a topic\n" + " /session rename — rename\n" + " /session remove — remove session " + "(default cannot be removed)\n" + " /session scene — show scene memory of the current topic\n" + " /session scene — append a note to scene memory\n" + " /session scene set — replace scene memory\n" + " /session scene clear — clear scene memory[/dim]" +) + def _handle_session_command(cmd: str, config, agent) -> None: """Handle /session commands for multi-topic session management.""" @@ -20,16 +35,7 @@ def _handle_session_command(cmd: str, config, agent) -> None: # Show current topic current = session_mgr.get_current_topic() console.print(f"[bold]Current session[/bold]: {current}") - console.print( - "\n[dim]Usage:\n" - " /session — show current topic\n" - " /session new [name] — new session (default: default)\n" - " /session list — list all sessions\n" - " /session switch — switch to a topic\n" - " /session rename — rename\n" - " /session remove — remove session " - "(default cannot be removed)[/dim]", - ) + console.print(_SESSION_USAGE) return subcmd = parts[1] @@ -137,14 +143,39 @@ def _handle_session_command(cmd: str, config, agent) -> None: else: console.print(f"[red]Session '{topic}' does not exist[/red]") + elif subcmd == "scene": + topic = session_mgr.get_current_topic() + rest = parts[2].strip() if len(parts) > 2 else "" + if not rest: + text = session_mgr.get_scene() + if text: + console.print( + f"[bold]Scene memory[/bold] [dim]({topic})[/dim]:", + ) + console.print(text) + else: + console.print( + f"[dim]No scene memory for '{topic}' yet. Add one " + f"with: /session scene [/dim]", + ) + elif rest == "clear": + session_mgr.set_scene("") + console.print(f"[green]Scene memory cleared ({topic})[/green]") + elif rest.startswith("set "): + text = rest[len("set "):].strip() + if session_mgr.set_scene(text): + console.print( + f"[green]Scene memory replaced ({topic})[/green]", + ) + else: + console.print("[red]Failed to write scene memory[/red]") + else: + if session_mgr.append_scene(rest): + console.print( + f"[green]Scene note appended ({topic})[/green]", + ) + else: + console.print("[red]Failed to write scene memory[/red]") + else: - console.print( - "[dim]Usage:\n" - " /session — show current topic\n" - " /session new [name] — new session (default: default)\n" - " /session list — list all sessions\n" - " /session switch — switch to a topic\n" - " /session rename — rename\n" - " /session remove — remove session " - "(default cannot be removed)[/dim]", - ) + console.print(_SESSION_USAGE) diff --git a/dashscope/acli/cli/startup.py b/dashscope/acli/cli/startup.py index 0d02aa1..760bf29 100644 --- a/dashscope/acli/cli/startup.py +++ b/dashscope/acli/cli/startup.py @@ -7,7 +7,7 @@ from dashscope.acli import __version__ from dashscope.acli.cli.constants import ALL_CAPABILITY_KEYS -from dashscope.acli.config import WORKSPACE_DIR, Config +from dashscope.acli.config import Config from dashscope.acli.tools.registry import registry from dashscope.acli.utils import mask_secret @@ -21,7 +21,8 @@ def _load_system_prompt() -> str | None: are discovered separately by Agent.__init__ and passed to the prompt pipeline for proper stable/ephemeral separation. """ - from dashscope.acli.config import CONFIG_DIR + # Imported at call time so tests can patch acli.config.WORKSPACE_DIR. + from dashscope.acli.config import CONFIG_DIR, WORKSPACE_DIR base: str | None = None for d in (WORKSPACE_DIR, CONFIG_DIR): @@ -50,7 +51,7 @@ def _load_references() -> str | None: Unlike skills (invoked on demand), references are knowledge docs that must always be in the system prompt — e.g. generated SDK API indexes. """ - from dashscope.acli.config import CONFIG_DIR + from dashscope.acli.config import CONFIG_DIR, WORKSPACE_DIR parts: dict[str, str] = {} for d in (CONFIG_DIR, WORKSPACE_DIR): @@ -82,7 +83,9 @@ def _compose_system_prompt(base: str | None) -> str | None: return f"{base}\n\n---\n\n{section}" if base else section -def _print_banner(config: Config | None = None): +def _print_banner(config: Config | None = None) -> None: + from dashscope.acli.config import WORKSPACE_DIR + logo = ( " _ _ _ ____ _ ___\n" " / \\ __ _ ___ _ __ | |_(_) ___ / ___| | |_ _|\n" diff --git a/dashscope/acli/commands.py b/dashscope/acli/commands.py index 4de0e86..f8f1f26 100644 --- a/dashscope/acli/commands.py +++ b/dashscope/acli/commands.py @@ -110,7 +110,11 @@ [ ("/profile", "User profile (list/search/add/remove/clear)"), ("/memory", "Chat history (list/search/remove /clear)"), - ("/session", "Session management (new/list/switch/rename/remove)"), + ( + "/session", + "Session management " + "(new/list/switch/rename/remove/scene)", + ), ( "/summarize", "Summarize the current task; record key steps and lessons", diff --git a/dashscope/acli/config.py b/dashscope/acli/config.py index 52b6a70..5542794 100644 --- a/dashscope/acli/config.py +++ b/dashscope/acli/config.py @@ -282,6 +282,9 @@ class Config: debug: bool = ( False # When True, log final LLM prompts to .acli/logs/llm.log ) + sandbox: bool = ( + False # When True, run shell commands in an OS sandbox if available + ) skill_registry: str = ( "" # Optional registry index URL/path for /skill search/install ) @@ -550,6 +553,9 @@ def _load_workspace_from(self, path: Path): if "debug" in data: val = str(data["debug"]).lower() self.debug = val not in ("false", "0", "no") + if "sandbox" in data: + val = str(data["sandbox"]).lower() + self.sandbox = val not in ("false", "0", "no") if "voice_silence_duration" in data: try: self.voice_silence_duration = float( @@ -809,6 +815,8 @@ def _workspace_lines(self) -> list[str]: lines.append("privacy_mode = true") if self.debug: lines.append("debug = true") + if self.sandbox: + lines.append("sandbox = true") lines.append(f"tts_enabled = {str(self.tts_enabled).lower()}") if self.tts_model and self.tts_model != "cosyvoice-v2": lines.append(f"tts_model = {toml_str(self.tts_model)}") diff --git a/dashscope/acli/memory/skill_evolution.py b/dashscope/acli/memory/skill_evolution.py index ec8ece1..b9a3f32 100644 --- a/dashscope/acli/memory/skill_evolution.py +++ b/dashscope/acli/memory/skill_evolution.py @@ -74,6 +74,21 @@ def analyze_trajectory( if any(kw in last_assistant.lower() for kw in error_keywords): return None # Failed trajectory, not skill-worthy + # Tool-outcome check: the final tool call must have succeeded. Earlier + # tool errors are tolerated (a workflow that fails then recovers — e.g. + # edit-and-test — is still a valuable skill), but a trajectory whose + # last tool call errored did not complete cleanly, so distilling it + # would produce a low-quality skill. + for msg in reversed(messages): + if msg.get("role") != "tool": + continue + content = msg.get("content", "") + if isinstance(content, str) and content.startswith( + ("Error", "错误"), + ): + return None + break # only the final tool outcome matters + # Identify common patterns pattern_name = _identify_pattern(tool_sequence) if not pattern_name: diff --git a/dashscope/acli/prompt_pipeline.py b/dashscope/acli/prompt_pipeline.py index 2f98a12..40aa13f 100644 --- a/dashscope/acli/prompt_pipeline.py +++ b/dashscope/acli/prompt_pipeline.py @@ -33,6 +33,7 @@ class PromptContext: experience_tracker: Any = None disabled_caps_provider: Callable[[], str] | None = None directives_provider: Callable[[], list[str]] | None = None + scene_provider: Callable[[], str] | None = None current_turn_tools: list[str] = field(default_factory=list) connected_mcp_services: Callable[[], list[str]] | None = None @@ -124,6 +125,26 @@ def render(self, ctx: PromptContext) -> str: return "\n".join(lines) +class SceneSection: + """Persistent per-topic scene memory (see SessionManager.get_scene).""" + + name = "scene" + + def render(self, ctx: PromptContext) -> str: + if not ctx.scene_provider: + return "" + try: + text = (ctx.scene_provider() or "").strip() + except Exception: + return "" + if not text: + return "" + return ( + "\n\n## Scene memory (persistent notes for the current " + "session topic; treat as standing context)\n" + text + ) + + class PlanSection: name = "plan" @@ -273,6 +294,7 @@ def default_pipeline( .add_ephemeral(SkillPackagesSection(active_prompts_fn)) .add_ephemeral(DisabledCapsSection()) .add_ephemeral(DirectivesSection()) + .add_ephemeral(SceneSection()) .add_ephemeral(PlanSection()) .add_ephemeral(ExperienceSection()) .add_ephemeral(ToolChainsSection()) diff --git a/dashscope/acli/sandbox.py b/dashscope/acli/sandbox.py new file mode 100644 index 0000000..912cedc --- /dev/null +++ b/dashscope/acli/sandbox.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +"""Optional OS-level sandbox for shell command execution (roadmap P0-L3). + +Defense-in-depth layered on top of the permission engine and the +dangerous-command blocklist. When enabled *and* a sandbox backend is +present, ``run_command`` executes commands inside an OS sandbox that +confines filesystem writes to the current workspace. + +Design constraints (see the roadmap): + +* **Opt-in** — disabled by default (``sandbox = false`` in config). Local + users already get the permission prompts + blocklist; the sandbox is an + extra layer for ``auto_approve`` / untrusted-skill scenarios. +* **Graceful degradation** — if no backend is detected the command runs + normally under the existing permission layer. The sandbox never blocks + startup and never raises out of ``run_command``. +* **Best-effort boundary** — this raises the cost of a destructive or + runaway command; it is *not* a hardened security boundary. Treat it as + confinement, not isolation. + +Backends: + +* macOS: Seatbelt via ``sandbox-exec`` (built into macOS). The profile + keeps the default-allow policy but denies filesystem writes outside the + workspace and temp areas, so process execution, reads, and networking are + unaffected and normal dev commands keep working. +* Linux: bubblewrap (``bwrap``) when installed. bwrap confines by + remapping the filesystem, so it is more restrictive; commands that write + outside the workspace/temp will fail. +""" + +from __future__ import annotations + +import os +import shutil +import sys +from typing import Optional + +# Backend identifiers. +SEATBELT = "seatbelt" +BWRAP = "bwrap" + + +def detect_backend() -> Optional[str]: + """Return the available sandbox backend name, or ``None``. + + macOS prefers ``sandbox-exec`` (Seatbelt); Linux prefers ``bwrap``. + Windows has no supported backend. Detection only checks for the tool's + presence on PATH — it never starts a sandbox. + """ + if sys.platform == "darwin": + return SEATBELT if shutil.which("sandbox-exec") else None + if sys.platform.startswith("linux"): + return BWRAP if shutil.which("bwrap") else None + return None + + +def available() -> bool: + """True when a sandbox backend is detected on this machine.""" + return detect_backend() is not None + + +# Optional override (mainly for tests / explicit CLI control). ``None`` +# means "read the setting from config". +_enabled_override: Optional[bool] = None + + +def set_enabled(value: Optional[bool]) -> None: + """Override sandbox enablement; pass ``None`` to use the config value.""" + global _enabled_override + _enabled_override = value + + +def is_enabled() -> bool: + """Whether sandboxing is enabled (config-driven, overridable). + + Reads ``sandbox`` from the loaded config on each call so config + changes take effect; degrades to ``False`` on any error so the + sandbox can never break command execution. + """ + if _enabled_override is not None: + return bool(_enabled_override) + try: + from dashscope.acli.config import Config + + return bool(Config.load().sandbox) + except Exception: + return False + + +def seatbelt_profile(cwd: str) -> str: + """Build a macOS Seatbelt profile confining writes to the workspace. + + The base policy stays default-allow; only filesystem writes are + denied, then re-allowed for the workspace and standard temp/cache + locations. Reads, process execution, and networking are untouched so + ordinary development commands keep working. + """ + safe_cwd = cwd.replace('"', '\\"') + return "\n".join( + [ + "(version 1)", + "(allow default)", + "(deny file-write*)", + f'(allow file-write* (subpath "{safe_cwd}"))', + '(allow file-write* (subpath "/private/tmp"))', + '(allow file-write* (subpath "/tmp"))', + '(allow file-write* (subpath "/private/var/folders"))', + '(allow file-write* (literal "/dev/null"))', + ], + ) + + +def bwrap_argv(command: str, cwd: str) -> list[str]: + """Build a ``bwrap`` argv that runs *command* with a read-only root. + + The whole filesystem is bound read-only, then the workspace and a + fresh ``/tmp`` are made writable. Commands that need to write elsewhere + (e.g. package caches) will fail — this is the intended confinement. + """ + return [ + "bwrap", + "--ro-bind", + "/", + "/", + "--bind", + cwd, + cwd, + "--tmpfs", + "/tmp", + "--proc", + "/proc", + "--dev", + "/dev", + "--chdir", + cwd, + "/bin/sh", + "-c", + command, + ] + + +def build_argv( + command: str, + cwd: str, + backend: Optional[str] = None, +) -> Optional[list[str]]: + """Return an argv to run *command* inside the sandbox, or ``None``. + + ``None`` means "no sandbox available — run the command normally". + ``backend`` may be passed explicitly (mainly for tests); otherwise it + is detected. + """ + backend = backend or detect_backend() + if backend == SEATBELT: + return [ + "sandbox-exec", + "-p", + seatbelt_profile(cwd), + "/bin/sh", + "-c", + command, + ] + if backend == BWRAP: + return bwrap_argv(command, cwd) + return None + + +def is_sandboxed_path(path: str, cwd: str) -> bool: + """True when *path* is inside the writable workspace or temp areas. + + Informational helper (e.g. for messages); not a security check. + """ + try: + abs_path = os.path.abspath(path) + except (OSError, ValueError): + return False + for base in (cwd, "/tmp", "/private/tmp"): + if abs_path == base or abs_path.startswith(base + os.sep): + return True + return False diff --git a/dashscope/acli/session.py b/dashscope/acli/session.py index afc74d8..1ade7d0 100644 --- a/dashscope/acli/session.py +++ b/dashscope/acli/session.py @@ -5,6 +5,8 @@ - history.json: message history - input-history.txt: command input history - meta.json: metadata (created, last_accessed, message_count) + - scene.md: persistent scene memory (topic-scoped notes injected into + the system prompt every turn) - events.jsonl: append-only event sidecar (lifecycle; see session_events.SessionEventLog) """ @@ -21,6 +23,7 @@ from dashscope.acli.session_events import EVENTS_FILENAME, SessionEventLog DEFAULT_TOPIC = "default" +SCENE_FILENAME = "scene.md" @dataclass @@ -110,6 +113,60 @@ def _events_file(self, topic: str) -> Path: """Get the events.jsonl sidecar path for a topic.""" return self._topic_dir(topic) / EVENTS_FILENAME + def _scene_file(self, topic: str) -> Path: + """Get the scene.md path for a topic.""" + return self._topic_dir(topic) / SCENE_FILENAME + + def get_scene(self, topic: str | None = None) -> str: + """Return the scene memory text for a topic (default: current). + + Returns an empty string when the file is missing or unreadable. + """ + path = self._scene_file(topic or self.current_topic) + if not path.exists(): + return "" + try: + return path.read_text(encoding="utf-8").strip() + except OSError: + return "" + + def set_scene(self, text: str, topic: str | None = None) -> bool: + """Replace the scene memory for a topic (default: current). + + An empty/whitespace-only *text* clears the scene file. Returns + False when the topic name is unsafe or the write fails. + """ + topic = topic or self.current_topic + topic_dir = self._safe_topic_dir(topic) + if topic_dir is None: + return False + path = self._scene_file(topic) + content = text.strip() + try: + if not content: + if path.exists(): + path.unlink() + else: + topic_dir.mkdir(parents=True, exist_ok=True) + path.write_text(content + "\n", encoding="utf-8") + except OSError: + return False + self.event_log(topic).append( + "scene/updated", + {"topic": topic, "chars": len(content)}, + ) + return True + + def append_scene(self, text: str, topic: str | None = None) -> bool: + """Append a note line to the scene memory of a topic.""" + topic = topic or self.current_topic + note = text.strip() + if not note: + return False + existing = self.get_scene(topic) + merged = f"{existing}\n{note}" if existing else note + return self.set_scene(merged, topic) + def event_log(self, topic: str | None = None) -> SessionEventLog: """Return the append-only event log for a topic (default: current). diff --git a/dashscope/acli/tools/shell.py b/dashscope/acli/tools/shell.py index a4aaceb..f376b05 100644 --- a/dashscope/acli/tools/shell.py +++ b/dashscope/acli/tools/shell.py @@ -538,12 +538,27 @@ async def run_command(command: str, timeout: int | None = None) -> str: cwd=os.getcwd(), ) else: - proc = await asyncio.create_subprocess_shell( - command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=os.getcwd(), - ) + # Optional OS sandbox (opt-in; degrades to normal execution + # when disabled or when no backend is available). + from dashscope.acli import sandbox + + sandbox_argv = None + if sandbox.is_enabled(): + sandbox_argv = sandbox.build_argv(command, os.getcwd()) + if sandbox_argv is not None: + proc = await asyncio.create_subprocess_exec( + *sandbox_argv, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=os.getcwd(), + ) + else: + proc = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=os.getcwd(), + ) stdout, stderr = await asyncio.wait_for( proc.communicate(), timeout=timeout, From fe8072b89cf945f61000cad311547d8ae2444a5c Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Wed, 19 Aug 2026 13:49:32 +0800 Subject: [PATCH 05/16] feat(acli): sync directives decay + experience cautionary recall (P3) Synced from agenticCLI 3b96f4f: directive proposals use recency-weighted frequency (14-day half-life); experience recall boosts failure lessons. UT green. --- dashscope/acli/memory/directives_learning.py | 59 ++++++++++++++++---- dashscope/acli/memory/experience.py | 3 + 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/dashscope/acli/memory/directives_learning.py b/dashscope/acli/memory/directives_learning.py index 3dfe971..3595015 100644 --- a/dashscope/acli/memory/directives_learning.py +++ b/dashscope/acli/memory/directives_learning.py @@ -65,6 +65,33 @@ def _save_patterns(patterns: dict[str, Any]) -> None: _patterns_cache = None +# Recency decay: a pattern's influence fades over time so stale habits +# stop generating proposals. Weight is 1.0 now, 0.5 after the half-life. +_DECAY_HALF_LIFE_DAYS = 14.0 +# A pair is proposal-worthy when its recency-weighted frequency reaches this. +_MIN_WEIGHTED_FREQUENCY = 3.0 + + +def _recency_weight(timestamp_iso: str) -> float: + """Exponential-decay weight for a recorded sequence timestamp. + + Returns 1.0 for a just-recorded entry and halves every + ``_DECAY_HALF_LIFE_DAYS`` days. Missing or malformed timestamps + degrade to 1.0 (no decay) rather than discarding the data. + """ + try: + ts = datetime.fromisoformat(timestamp_iso) + except (ValueError, TypeError): + return 1.0 + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + age_days = max( + (datetime.now(timezone.utc) - ts).total_seconds() / 86400.0, + 0.0, + ) + return 0.5 ** (age_days / _DECAY_HALF_LIFE_DAYS) + + def record_tool_sequence(tools: list[str]) -> None: """Record a sequence of tools used in a successful turn.""" if len(tools) < 2: @@ -100,21 +127,29 @@ def analyze_patterns() -> list[dict[str, Any]]: if len(sequences) < 5: return [] # Not enough data - # Count tool sequence patterns - sequence_counter: Counter = Counter() + # Count tool sequence patterns. Two tallies are kept per adjacent + # pair: a raw occurrence count (reported as "frequency") and a + # recency-weighted score (recent repetitions count more, stale ones + # decay) used for the proposal threshold and confidence. + raw_counter: Counter = Counter() + weighted_counter: Counter = Counter() for seq in sequences: tools = tuple(seq.get("tools", [])) - if len(tools) >= 2: - # Look for adjacent pairs - for i in range(len(tools) - 1): - pair = (tools[i], tools[i + 1]) - sequence_counter[pair] += 1 - - # Find frequent patterns (>= 3 occurrences) + if len(tools) < 2: + continue + weight = _recency_weight(seq.get("timestamp", "")) + # Look for adjacent pairs + for i in range(len(tools) - 1): + pair = (tools[i], tools[i + 1]) + raw_counter[pair] += 1 + weighted_counter[pair] += weight + + # Find frequent patterns (weighted frequency >= threshold) proposals = [] - for (tool1, tool2), count in sequence_counter.most_common(10): - if count >= 3: - confidence = min(count / 10, 1.0) + for (tool1, tool2), weighted in weighted_counter.most_common(10): + if weighted >= _MIN_WEIGHTED_FREQUENCY: + confidence = min(weighted / 10, 1.0) + count = raw_counter[(tool1, tool2)] directive = _generate_directive(tool1, tool2, count) proposals.append( { diff --git a/dashscope/acli/memory/experience.py b/dashscope/acli/memory/experience.py index 1085e60..f6e2fba 100644 --- a/dashscope/acli/memory/experience.py +++ b/dashscope/acli/memory/experience.py @@ -112,6 +112,9 @@ def search_experiences( if score > 0: if exp.get("lesson"): score += 1 + # Cautionary lessons are high-value recall targets. + if exp.get("outcome") == "failure": + score += 1 scored.append((score, index, exp)) # Highest score first; ties favor the most recently recorded entry. From b96dc2d77df7cb764fbe3ca2617f7e858c879338 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Wed, 19 Aug 2026 14:09:13 +0800 Subject: [PATCH 06/16] feat(acli): sync turn/end event recording (P2-Phase2) Synced from agenticCLI 7221691: SessionManager.record_turn_event appends a per-turn turn/end event to the topic's events.jsonl; the agent records it best-effort at turn end (aligned with the scene-memory design). UT green. --- dashscope/acli/agent.py | 26 ++++++++++++++++++++++++++ dashscope/acli/session.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/dashscope/acli/agent.py b/dashscope/acli/agent.py index 8a2acc6..2166cfd 100644 --- a/dashscope/acli/agent.py +++ b/dashscope/acli/agent.py @@ -796,6 +796,32 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: # Store conversation history summary self._store_history() + # Record the completed turn as an event (session-as-event-log + # direction). Best-effort: subagents/SDK callers may run without a + # session manager, and event recording must never break the loop. + try: + from dashscope.acli.session import get_session_manager + + turn_topic = ( + self.session_path.parent.name if self.session_path else None + ) + get_session_manager().record_turn_event( + user_text=text_of(user_input), + assistant_text=last_content, + tools_used=self._current_turn_tools, + outcome=( + _classify_outcome( + self._turn_tool_successes, + self._turn_tool_failures, + ) + if self._current_turn_tools + else "" + ), + topic=turn_topic, + ) + except Exception: + pass + # Record experience for learning (only if tools were used) if self._current_turn_tools: task_summary = text_of(user_input)[:100] # Truncate for storage diff --git a/dashscope/acli/session.py b/dashscope/acli/session.py index 1ade7d0..7c28b13 100644 --- a/dashscope/acli/session.py +++ b/dashscope/acli/session.py @@ -177,6 +177,37 @@ def event_log(self, topic: str | None = None) -> SessionEventLog: self._events_file(topic or self.current_topic), ) + def record_turn_event( + self, + user_text: str, + assistant_text: str, + tools_used: list[str] | None = None, + outcome: str = "", + topic: str | None = None, + ) -> None: + """Append a ``turn/end`` event to the topic's event sidecar. + + Records a compact summary of one turn (truncated user/assistant + text, tools used, outcome). Best-effort: never raises, so event + recording can't break the agent loop. This is a step toward the + session-as-event-log direction (later: projection / fork / + resume built on the event stream). + """ + try: + topic = topic or self.current_topic + self.event_log(topic).append( + "turn/end", + { + "topic": topic, + "user": (user_text or "")[:200], + "assistant": (assistant_text or "")[:200], + "tools": list(tools_used or []), + "outcome": outcome, + }, + ) + except Exception: + pass + def list_topics(self) -> list[SessionMeta]: """List all available topics with metadata.""" topics = [] From 32214fbbbfd8a654505cca6a7be3aa8751a4e3f1 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 20 Aug 2026 12:03:09 +0800 Subject: [PATCH 07/16] =?UTF-8?q?feat(acli):=20event-log=20projection=20?= =?UTF-8?q?=E2=80=94=20snapshot/resume/fork?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync from agenticCLI: messages/snapshot dual-write at turn end, resume_from_events() rebuilds from latest snapshot, fork_topic() copies the event log, read_raw() for schema-tolerant projection. --- dashscope/acli/agent.py | 4 ++ dashscope/acli/session.py | 80 ++++++++++++++++++++++++++++++++ dashscope/acli/session_events.py | 23 +++++++++ 3 files changed, 107 insertions(+) diff --git a/dashscope/acli/agent.py b/dashscope/acli/agent.py index 2166cfd..d5f8f8b 100644 --- a/dashscope/acli/agent.py +++ b/dashscope/acli/agent.py @@ -819,6 +819,10 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: ), topic=turn_topic, ) + get_session_manager().record_messages_snapshot( + self.messages, + topic=turn_topic, + ) except Exception: pass diff --git a/dashscope/acli/session.py b/dashscope/acli/session.py index 7c28b13..4291eda 100644 --- a/dashscope/acli/session.py +++ b/dashscope/acli/session.py @@ -208,6 +208,86 @@ def record_turn_event( except Exception: pass + def record_messages_snapshot( + self, + messages: list[dict[str, Any]], + topic: str | None = None, + ) -> None: + """Append a full-fidelity ``messages/snapshot`` event. + + Stores the complete current message list so the session can later + be reconstructed (resume/fork) from the event log alone. Best- + effort: never raises. Storage grows per turn; snapshot pruning is + a follow-up. + """ + try: + topic = topic or self.current_topic + self.event_log(topic).append( + "messages/snapshot", + {"topic": topic, "messages": list(messages or [])}, + ) + except Exception: + pass + + def resume_from_events( + self, + topic: str | None = None, + ) -> list[dict[str, Any]]: + """Rebuild the message list from the latest snapshot event. + + Returns an empty list when the topic has no snapshot. This is the + projection side of the event-sourced session: combined with the + append-only log it enables resume / fork / crash recovery. + """ + try: + events = self.event_log(topic).read_raw() + except Exception: + return [] + for entry in reversed(events): + if entry.get("type") != "messages/snapshot": + continue + data = entry.get("data") or {} + msgs = data.get("messages") + if isinstance(msgs, list): + return msgs + return [] + + def fork_topic(self, src: str, dst: str) -> bool: + """Create topic *dst* seeded with a copy of *src*'s event log. + + The forked topic resumes from the same state as the source. Both + names must be safe and the destination must not already exist. + Returns False otherwise. + """ + src_dir = self._safe_topic_dir(src) + dst_dir = self._safe_topic_dir(dst) + if src_dir is None or dst_dir is None: + return False + if not src_dir.exists() or dst_dir.exists(): + return False + try: + dst_dir.mkdir(parents=True) + self._save_meta( + dst, + SessionMeta( + topic=dst, + created=datetime.now().isoformat(), + last_accessed=datetime.now().isoformat(), + ), + ) + src_events = self._events_file(src) + if src_events.exists(): + import shutil + + shutil.copy2(src_events, self._events_file(dst)) + except OSError: + return False + self.event_log(dst).append( + "topic/forked", + {"from": src, "to": dst}, + ) + return True + def list_topics(self) -> list[SessionMeta]: """List all available topics with metadata.""" topics = [] diff --git a/dashscope/acli/session_events.py b/dashscope/acli/session_events.py index 2f0cab5..3de9574 100644 --- a/dashscope/acli/session_events.py +++ b/dashscope/acli/session_events.py @@ -119,5 +119,28 @@ def tail( return [] return self.read(event_type)[-n:] + def read_raw(self) -> list[dict[str, Any]]: + """Return every well-formed entry, any schema version. + + Unlike :meth:`read` (which filters to the current schema), this + yields all parseable entries — used when projecting large events + such as ``messages/snapshot``. + """ + if not self._file.exists(): + return [] + entries: list[dict[str, Any]] = [] + with open(self._file, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(entry, dict): + entries.append(entry) + return entries + def __len__(self) -> int: return len(self.read()) From e69c79db156c572f7cdb52fd4bafde298933456d Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 20 Aug 2026 12:52:40 +0800 Subject: [PATCH 08/16] feat(acli): snapshot compaction for the event log Sync from agenticCLI: atomic compact_snapshots() (tmp + os.replace) keeps the audit trail and newest snapshots; SessionManager auto-compacts past 8 snapshots down to 2. --- dashscope/acli/session.py | 19 ++++++++--- dashscope/acli/session_events.py | 54 +++++++++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/dashscope/acli/session.py b/dashscope/acli/session.py index 4291eda..6a5ec21 100644 --- a/dashscope/acli/session.py +++ b/dashscope/acli/session.py @@ -25,6 +25,13 @@ DEFAULT_TOPIC = "default" SCENE_FILENAME = "scene.md" +# Snapshot pruning (event-log completeness Phase 2): snapshots carry a +# full message copy, so unbounded retention grows the log quadratically. +# Compact once the log holds more than _SNAPSHOT_COMPACT_AT snapshots, +# keeping the newest _SNAPSHOT_KEEP. +_SNAPSHOT_KEEP = 2 +_SNAPSHOT_COMPACT_AT = 8 + @dataclass class SessionMeta: @@ -216,16 +223,20 @@ def record_messages_snapshot( """Append a full-fidelity ``messages/snapshot`` event. Stores the complete current message list so the session can later - be reconstructed (resume/fork) from the event log alone. Best- - effort: never raises. Storage grows per turn; snapshot pruning is - a follow-up. + be reconstructed (resume/fork) from the event log alone. Old + snapshots are pruned once more than ``_SNAPSHOT_COMPACT_AT`` + accumulate, keeping the newest ``_SNAPSHOT_KEEP``. Best-effort: + never raises. """ try: topic = topic or self.current_topic - self.event_log(topic).append( + log = self.event_log(topic) + log.append( "messages/snapshot", {"topic": topic, "messages": list(messages or [])}, ) + if len(log.read("messages/snapshot")) > _SNAPSHOT_COMPACT_AT: + log.compact_snapshots(keep=_SNAPSHOT_KEEP) except Exception: pass diff --git a/dashscope/acli/session_events.py b/dashscope/acli/session_events.py index 3de9574..f931f73 100644 --- a/dashscope/acli/session_events.py +++ b/dashscope/acli/session_events.py @@ -23,6 +23,7 @@ from __future__ import annotations import json +import os from datetime import datetime from pathlib import Path from typing import Any @@ -36,10 +37,12 @@ class SessionEventLog: """Append-only JSONL event log bound to one file. - The log is strictly append-only: events are only ever added, never - rewritten or removed through this API. ``seq`` is a 1-based - monotonically increasing position derived from the current line - count, so it stays correct even if another process appends. + Events are appended, never edited in place — with one exception: + :meth:`compact_snapshots` atomically rewrites the file to drop + stale full-history snapshots (all other events are preserved). + ``seq`` is a 1-based monotonically increasing position derived + from the current line count, so it stays correct even if another + process appends. """ def __init__(self, events_file: Path): @@ -142,5 +145,48 @@ def read_raw(self) -> list[dict[str, Any]]: entries.append(entry) return entries + def compact_snapshots( + self, + keep: int = 2, + snapshot_type: str = "messages/snapshot", + ) -> bool: + """Drop all but the newest ``keep`` snapshot events, atomically. + + Snapshots carry a full copy of the message list, so retaining + every one grows the log quadratically in history length. + Compaction rewrites the file with all non-snapshot events plus + the newest ``keep`` snapshots, renumbering ``seq`` to stay + line-ordered. The rewrite goes through a tmp file and + ``os.replace``, so a crash mid-compaction leaves the original + log intact. + + Best-effort: returns True only when the file was rewritten. + """ + try: + entries = self.read_raw() + except OSError: + return False + snapshot_idx = [ + i for i, e in enumerate(entries) if e.get("type") == snapshot_type + ] + if len(snapshot_idx) <= keep: + return False + drop = set(snapshot_idx[: len(snapshot_idx) - keep]) + kept = [e for i, e in enumerate(entries) if i not in drop] + tmp = self._file.with_suffix(self._file.suffix + ".tmp") + try: + with open(tmp, "w", encoding="utf-8") as f: + for seq, entry in enumerate(kept, start=1): + entry["seq"] = seq + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + os.replace(tmp, self._file) + except OSError: + try: + tmp.unlink(missing_ok=True) + except OSError: + pass + return False + return True + def __len__(self) -> int: return len(self.read()) From 4456cf10f271e0d8d8dd0a1929096b2871e37ca0 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 20 Aug 2026 13:49:34 +0800 Subject: [PATCH 09/16] feat(acli): event stream becomes the source of truth Sync from agenticCLI: load_session() restores from the newest messages/snapshot (torn-line tolerant), history.json demoted to compat fallback; turn-end events written before history.json; shared latest_snapshot_messages() helper. --- dashscope/acli/agent.py | 72 ++++++++++++++++++++++---------- dashscope/acli/session.py | 15 +++---- dashscope/acli/session_events.py | 18 ++++++++ 3 files changed, 73 insertions(+), 32 deletions(-) diff --git a/dashscope/acli/agent.py b/dashscope/acli/agent.py index d5f8f8b..b76364a 100644 --- a/dashscope/acli/agent.py +++ b/dashscope/acli/agent.py @@ -235,9 +235,32 @@ def reset(self): self.messages = [] def load_session(self) -> int: - """Restore self.messages from session_path. Returns the number of - messages loaded (0 if no file, file empty, or parse failed).""" - if not self.session_path or not self.session_path.exists(): + """Restore self.messages. Returns the number of messages loaded + (0 if nothing could be restored). + + The per-topic event stream is the source of truth: the latest + ``messages/snapshot`` wins when present (crash recovery via + append-only replay; torn trailing lines are skipped). The + ``history.json`` file remains as the compat fallback for + sessions written before snapshots existed. + """ + if not self.session_path: + return 0 + try: + from dashscope.acli.session_events import ( + EVENTS_FILENAME, + SessionEventLog, + latest_snapshot_messages, + ) + + log = SessionEventLog(self.session_path.parent / EVENTS_FILENAME) + resumed = latest_snapshot_messages(log.read_raw()) + if resumed: + self.messages = resumed + return len(resumed) + except Exception: + pass + if not self.session_path.exists(): return 0 try: data = json.loads(self.session_path.read_text()) @@ -779,26 +802,12 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: ), ) - # Persist session before memory write so a memory exception can't - # cost us the conversation. - self.save_session() - - # Store memory after conversation ends (whether normal or max_turns). - # Strip images from the user side — memory backends expect text. - if last_content: - await self._store_memory( - [ - {"role": "user", "content": text_of(user_input)}, - {"role": "assistant", "content": last_content}, - ], - ) - - # Store conversation history summary - self._store_history() - - # Record the completed turn as an event (session-as-event-log - # direction). Best-effort: subagents/SDK callers may run without a - # session manager, and event recording must never break the loop. + # Record the completed turn as events (session-as-event-log + # direction) BEFORE persisting history.json: the event stream is + # the source of truth, so it must never be older than the + # fallback store if we crash between the two writes. Best-effort: + # subagents/SDK callers may run without a session manager, and + # event recording must never break the loop. try: from dashscope.acli.session import get_session_manager @@ -826,6 +835,23 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: except Exception: pass + # Persist session before memory write so a memory exception can't + # cost us the conversation. + self.save_session() + + # Store memory after conversation ends (whether normal or max_turns). + # Strip images from the user side — memory backends expect text. + if last_content: + await self._store_memory( + [ + {"role": "user", "content": text_of(user_input)}, + {"role": "assistant", "content": last_content}, + ], + ) + + # Store conversation history summary + self._store_history() + # Record experience for learning (only if tools were used) if self._current_turn_tools: task_summary = text_of(user_input)[:100] # Truncate for storage diff --git a/dashscope/acli/session.py b/dashscope/acli/session.py index 6a5ec21..98b42c4 100644 --- a/dashscope/acli/session.py +++ b/dashscope/acli/session.py @@ -20,7 +20,11 @@ from typing import Any from dashscope.acli.config import WORKSPACE_DIR -from dashscope.acli.session_events import EVENTS_FILENAME, SessionEventLog +from dashscope.acli.session_events import ( + EVENTS_FILENAME, + SessionEventLog, + latest_snapshot_messages, +) DEFAULT_TOPIC = "default" SCENE_FILENAME = "scene.md" @@ -254,14 +258,7 @@ def resume_from_events( events = self.event_log(topic).read_raw() except Exception: return [] - for entry in reversed(events): - if entry.get("type") != "messages/snapshot": - continue - data = entry.get("data") or {} - msgs = data.get("messages") - if isinstance(msgs, list): - return msgs - return [] + return latest_snapshot_messages(events) def fork_topic(self, src: str, dst: str) -> bool: """Create topic *dst* seeded with a copy of *src*'s event log. diff --git a/dashscope/acli/session_events.py b/dashscope/acli/session_events.py index f931f73..777845e 100644 --- a/dashscope/acli/session_events.py +++ b/dashscope/acli/session_events.py @@ -34,6 +34,24 @@ EVENTS_FILENAME = "events.jsonl" +def latest_snapshot_messages( + entries: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Return the message list from the newest ``messages/snapshot``. + + Scans *entries* (as produced by :meth:`SessionEventLog.read_raw`) + backwards; returns an empty list when no snapshot is present. + """ + for entry in reversed(entries): + if entry.get("type") != "messages/snapshot": + continue + data = entry.get("data") or {} + msgs = data.get("messages") + if isinstance(msgs, list): + return msgs + return [] + + class SessionEventLog: """Append-only JSONL event log bound to one file. From 912903ce9bc4226f9af70d11cef20e69313441e8 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 20 Aug 2026 15:02:57 +0800 Subject: [PATCH 10/16] chore(acli): sync v0.6.1 version bump from agenticCLI __version__ 0.6.0 -> 0.6.1; black wraps the rewritten dashscope.acli.sdk import (exceeds 79 cols after prefix rewrite). --- dashscope/acli/__init__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/dashscope/acli/__init__.py b/dashscope/acli/__init__.py index e863881..199c1b8 100644 --- a/dashscope/acli/__init__.py +++ b/dashscope/acli/__init__.py @@ -1,11 +1,16 @@ # -*- coding: utf-8 -*- from __future__ import annotations -__version__ = "0.6.0" +__version__ = "0.6.1" # Expose the lightweight programmatic SDK at the package root. try: - from dashscope.acli.sdk import create_agent, run_interactive, run_once, run_once_sync + from dashscope.acli.sdk import ( + create_agent, + run_interactive, + run_once, + run_once_sync, + ) except Exception: # pragma: no cover - sdk imports optional deps may fail create_agent = None # type: ignore run_interactive = None # type: ignore From e936c0d446a869326d11b83e3166f4677f0707be Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 20 Aug 2026 16:37:20 +0800 Subject: [PATCH 11/16] fix(acli): stop bottom-of-screen jitter during PyCharm trackpad scrolls JediTerm translates wheel notches into arrow-key bursts; decelerating gesture tails (20-350ms gaps) leaked past the 20ms burst classifier into history recall and completion popups, and the sticky-follow band yanked the view back down mid-gesture. - Absorb deferred arrows within 350ms of the last wheel-classified key into the scroll queue instead of history/popup routing - Suppress sticky follow for 0.5s after a wheel-up; wheel-down re-engages --- dashscope/acli/ui/tui.py | 53 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/dashscope/acli/ui/tui.py b/dashscope/acli/ui/tui.py index 3041a16..23a8f11 100644 --- a/dashscope/acli/ui/tui.py +++ b/dashscope/acli/ui/tui.py @@ -42,6 +42,13 @@ # Wheel batching window: full repaints are costly on JediTerm; trade # frame rate for stability _WHEEL_FLUSH_INTERVAL = 0.12 if _IS_JEDITERM else 0.03 +# Trackpad gestures decelerate: the tail of a wheel gesture arrives as +# isolated arrows 20-200ms after the last burst key, indistinguishable +# from real keypresses by timing alone. Within this window after the +# last wheel-classified key, deferred arrows are absorbed as scrolls +# instead of history/completion (which visibly raced the input box and +# popup while scrolling). +_WHEEL_TAIL_WINDOW = 0.35 from rich.cells import cell_len # noqa: E402 from rich.console import Console # noqa: E402 @@ -61,7 +68,10 @@ from textual.widgets import OptionList, RichLog, Static, TextArea # noqa: E402 from textual.widgets.option_list import Option # noqa: E402 -from dashscope.acli.commands import handle_shell_escape, render_help_text # noqa: E402 +from dashscope.acli.commands import ( # noqa: E402 + handle_shell_escape, + render_help_text, +) from dashscope.acli.utils import ( # noqa: E402 UserAbortedTurn, UserSupplement, @@ -171,6 +181,13 @@ class OutputLog(RichLog): widget-level select-all that copies nothing. """ + # Set by wheel-up gestures (mouse events or the arrow-key wheel queue); + # cleared by wheel-down. While fresh, sticky follow is suppressed. + _last_wheel_up_ts: float = 0.0 + + # How long a wheel-up suppresses sticky follow (seconds). + _FOLLOW_SUPPRESS_WINDOW = 0.5 + def write( self, content, @@ -181,8 +198,13 @@ def write( animate: bool = False, ): if scroll_end is None and self.auto_scroll: - # Sticky follow: only scroll to the end when already at the bottom. - scroll_end = self.scroll_offset.y >= max(self.max_scroll_y - 1, 0) + # Sticky follow: only scroll to the end when already at the + # bottom — unless the user just wheeled up (their scroll + # intent beats the 1-line sticky band, e.g. turn-end writes + # landing mid-gesture). + scroll_end = ( + self.scroll_offset.y >= max(self.max_scroll_y - 1, 0) + ) and not self._recent_wheel_up() return super().write( content, width=width, @@ -192,6 +214,12 @@ def write( animate=animate, ) + def _recent_wheel_up(self) -> bool: + ts = self._last_wheel_up_ts + return bool(ts) and ( + time.monotonic() - ts < self._FOLLOW_SUPPRESS_WINDOW + ) + def render_line(self, y: int) -> Strip: scroll_x, scroll_y = self.scroll_offset line_y = scroll_y + y @@ -274,10 +302,12 @@ def get_selection(self, selection: Selection) -> tuple[str, str] | None: def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None: super()._on_mouse_scroll_down(event) + self._last_wheel_up_ts = 0.0 self._extend_selection_after_wheel(event) def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None: super()._on_mouse_scroll_up(event) + self._last_wheel_up_ts = time.monotonic() self._extend_selection_after_wheel(event) def _extend_selection_after_wheel(self, event: events.MouseEvent) -> None: @@ -705,6 +735,7 @@ def __init__(self, history_path: Path | None = None, **kwargs): self._prev_arrow_ts: float = 0.0 self._wheel_pending: int = 0 self._wheel_flush_timer = None + self._last_wheel_ts: float = 0.0 self._arrow_pending_key: str = "" self._arrow_timer = None # Password masking: real chars live in _password_real, the widget @@ -901,6 +932,7 @@ def _on_key(self, event) -> None: def _queue_wheel_scroll(self, key: str) -> None: self._wheel_pending += 1 if key == "down" else -1 + self._last_wheel_ts = time.monotonic() if self._wheel_flush_timer is None: self._wheel_flush_timer = self.set_timer( _WHEEL_FLUSH_INTERVAL, @@ -914,10 +946,16 @@ def _flush_wheel_scroll(self) -> None: if not lines: return try: - output = self.app.query_one("#output") + output = self.app.query_one("#output", OutputLog) screen = self.app.screen except Exception: return + # Drive the same follow-suppression marker as the mouse path: + # wheel-up = user reading above (don't yank), wheel-down = follow. + if lines < 0: + output._last_wheel_up_ts = time.monotonic() + else: + output._last_wheel_up_ts = 0.0 output.scroll_to(y=output.scroll_offset.y + lines, animate=False) # Wheel scrolling mid-drag (PyCharm wheel = arrow keys) must also # keep the selection following the pointer, or the selection cannot @@ -936,6 +974,13 @@ def _apply_deferred_arrow(self) -> None: self._arrow_timer = None if not key or self.password_mode: return + # A decelerating trackpad gesture keeps emitting isolated arrows + # past the 20ms burst window; while a wheel gesture is (or was + # just) active, these stragglers are scrolls, not history/completion + # keys — routing them there visibly raced the input box and popup. + if time.monotonic() - self._last_wheel_ts < _WHEEL_TAIL_WINDOW: + self._queue_wheel_scroll(key) + return popup = None try: popup = self.app.query_one("#completion-popup", CompletionPopup) From 6d684c3d9b49dadee878b6ee7b24b634290cf797 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Fri, 21 Aug 2026 14:16:46 +0800 Subject: [PATCH 12/16] fix(acli): repair TUI drag-select edge auto-scroll and stale mouse capture Sync from agenticCLI (5ad5ce4): watchdog for leaked mouse capture, pointer re-anchor on new drag, edge auto-scroll armed on #output, ACLI_DEBUG_SELECT instrumentation. --- dashscope/acli/ui/tui.py | 245 ++++++++++++++++++++++++++++++++------- 1 file changed, 201 insertions(+), 44 deletions(-) diff --git a/dashscope/acli/ui/tui.py b/dashscope/acli/ui/tui.py index 23a8f11..5f769c5 100644 --- a/dashscope/acli/ui/tui.py +++ b/dashscope/acli/ui/tui.py @@ -35,6 +35,18 @@ os.environ.get("TERMINAL_EMULATOR", "").startswith("JetBrains") or "jediterm" in os.environ.get("TERM_PROGRAM", "").lower() ) + +# Temporary instrumentation for the iTerm2 drag auto-scroll investigation. +_ACLI_DEBUG_SELECT = bool(os.environ.get("ACLI_DEBUG_SELECT")) + + +def _select_debug(message: str) -> None: + if not _ACLI_DEBUG_SELECT: + return + with open("/tmp/acli_select_debug.log", "a", encoding="utf-8") as log_file: + log_file.write(f"{time.monotonic():.3f} {message}\n") + + # Stream flush: time window + line-count threshold (the threshold only # guards against over-frequent flushes on bursty bulk output) _STREAM_FLUSH_INTERVAL = 0.8 if _IS_JEDITERM else 0.3 @@ -181,12 +193,23 @@ class OutputLog(RichLog): widget-level select-all that copies nothing. """ - # Set by wheel-up gestures (mouse events or the arrow-key wheel queue); - # cleared by wheel-down. While fresh, sticky follow is suppressed. - _last_wheel_up_ts: float = 0.0 - - # How long a wheel-up suppresses sticky follow (seconds). - _FOLLOW_SUPPRESS_WINDOW = 0.5 + # Explicit follow state: writes keep the view pinned to the bottom only + # while following. An upward scroll (mouse wheel, PyCharm's arrow-key + # wheel queue, page keys) stops following; a downward scroll re-engages + # it once it lands back at the bottom; scroll_end() (command submit, + # confirmation prompts) always re-engages. A position band or a + # time-based suppression both misbehave at the bottom while streaming: + # the band yanks the view on fine 1-line adjustments and a timeout + # re-arms follow while the user is still reading. + _follow_output: bool = True + + # A read-only view must never take keyboard focus: on PyCharm the + # trackpad wheel arrives as arrow keys, and a focused OutputLog would + # route them to ScrollView's *animated* key-scroll bindings — bypassing + # CommandInput's burst classifier and tearing on JediTerm. Keyboard + # scrolling is handled globally by CommandInput (pageup/shift+arrows); + # mouse wheel and drag selection need no focus. + can_focus = False def write( self, @@ -198,27 +221,38 @@ def write( animate: bool = False, ): if scroll_end is None and self.auto_scroll: - # Sticky follow: only scroll to the end when already at the - # bottom — unless the user just wheeled up (their scroll - # intent beats the 1-line sticky band, e.g. turn-end writes - # landing mid-gesture). - scroll_end = ( - self.scroll_offset.y >= max(self.max_scroll_y - 1, 0) - ) and not self._recent_wheel_up() - return super().write( + scroll_end = self._follow_output + if not self._size_known: + return super().write( + content, + width=width, + expand=expand, + shrink=shrink, + scroll_end=scroll_end, + animate=animate, + ) + result = super().write( content, width=width, expand=expand, shrink=shrink, - scroll_end=scroll_end, + scroll_end=False, animate=animate, ) + if scroll_end: + # Follow with a fire-time check rather than RichLog's queued + # scroll_end: a wheel-up landing between the write and the + # refresh must win over the write's queued scroll. + self.call_after_refresh(self._scroll_to_follow) + return result - def _recent_wheel_up(self) -> bool: - ts = self._last_wheel_up_ts - return bool(ts) and ( - time.monotonic() - ts < self._FOLLOW_SUPPRESS_WINDOW - ) + def _scroll_to_follow(self) -> None: + if self._follow_output: + self.scroll_to(y=self.max_scroll_y, animate=False) + + def scroll_end(self, *args, **kwargs): + self._follow_output = True + return super().scroll_end(*args, **kwargs) def render_line(self, y: int) -> Strip: scroll_x, scroll_y = self.scroll_offset @@ -301,13 +335,29 @@ def get_selection(self, selection: Selection) -> tuple[str, str] | None: return None def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None: + _select_debug( + f"wheel down scroll_y={self.scroll_offset.y}/{self.max_scroll_y}", + ) + # The pump dispatches this event to every MRO class defining the + # handler; stop() doesn't suppress that, only prevent_default() + # does — without it each wheel notch scrolls twice. + event.prevent_default() super()._on_mouse_scroll_down(event) - self._last_wheel_up_ts = 0.0 + # Pointer scrolls apply synchronously (animate=False), so the + # landed position is readable here: re-engage follow only when the + # scroll actually reached the bottom. + if self.scroll_offset.y >= self.max_scroll_y: + self._follow_output = True self._extend_selection_after_wheel(event) def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None: + _select_debug( + f"wheel up scroll_y={self.scroll_offset.y}/{self.max_scroll_y}", + ) + event.prevent_default() super()._on_mouse_scroll_up(event) - self._last_wheel_up_ts = time.monotonic() + if self.scroll_offset.y < self.max_scroll_y: + self._follow_output = False self._extend_selection_after_wheel(event) def _extend_selection_after_wheel(self, event: events.MouseEvent) -> None: @@ -353,39 +403,121 @@ class AcliScreen(Screen): """ _auto_scroll_pointer: Offset | None = None + _auto_scroll_target: Any = None # widget our auto-scroll timer scrolls _select_state: Any # textual Screen internal + def _start_auto_scroll( + self, + widget, + direction, + speed: float = 1.0, + ) -> None: + # super() calls _stop_auto_scroll() first (clearing the target), so + # record the target afterwards. + super()._start_auto_scroll(widget, direction, speed) + self._auto_scroll_target = widget + _select_debug( + f"arm target={getattr(widget, 'id', widget)} " + f"direction={direction} speed={speed:.2f}", + ) + + def _stop_auto_scroll(self) -> None: + if self._auto_select_scroll_timer is not None: + target = self._auto_scroll_target + _select_debug( + f"stop (was target={getattr(target, 'id', target)})", + ) + self._auto_scroll_target = None + super()._stop_auto_scroll() + def _forward_event(self, event) -> None: + if ( + isinstance(event, events.MouseDown) + and self.app.mouse_captured is not None + ): + # A MouseUp that never reaches the captor (released outside the + # window, intercepted by a modal, ...) leaks the capture: every + # later MouseDown skips selection setup and routes to the stale + # captor, so a drag can never start a new selection and copy + # keeps serving the old one. A fresh MouseDown always begins a + # new gesture — drop the stale capture. + _select_debug( + f"down: dropping stale capture {self.app.mouse_captured}", + ) + self.app.capture_mouse(None) super()._forward_event(event) + if isinstance(event, events.MouseDown): + _select_debug( + f"down y={event.pointer_screen_y} " + f"selecting={self._selecting} " + f"state={self._select_state is not None}", + ) + # Anchor the pointer stash to the new drag: a wheel flush firing + # before this drag's first MouseMove must not extend the + # selection toward the previous drag's parked position. + self._auto_scroll_pointer = Offset( + int(event.pointer_screen_x), + int(event.pointer_screen_y), + ) + return + if isinstance(event, events.MouseUp): + _select_debug(f"up y={event.pointer_screen_y}") + self._auto_scroll_pointer = None + return if not (isinstance(event, events.MouseMove) and self._selecting): return self._auto_scroll_pointer = Offset( int(event.pointer_screen_x), int(event.pointer_screen_y), ) - # When dragging to the top/bottom screen edge, the pointer may land - # on a non-scrollable widget (the fixed input box below) or a no-hit - # area (hit-test on the output area's padding rows returns None) — - # Textual then stops the auto-scroll timer and the selection freezes - # within one screen (always reproducible in iTerm2 when a drag past - # the window edge is clamped to the first/last row). Fall back to - # scrolling #output directly here. - if self._auto_select_scroll_timer is not None: + # Edge auto-scroll must scroll the widget being selected, not the + # widget under the pointer. Below the output area (spinner, input + # box, hint rows) Textual's ancestor walk either finds no scrollable + # widget and stops the timer, or arms it for that widget — the + # CommandInput is a TextArea, so it is *always* scrollable from + # Textual's viewpoint even when the draft fits on one line, and the + # timer then ticks against an input whose scroll offset is clamped + # at 0 while the output never moves (this is exactly what a drag + # into the prompt area hits in iTerm2). Whenever the drag started + # in #output and the pointer sits in an edge zone, make sure the + # armed auto-scroll targets #output. + state = self._select_state + if state is None: return try: output = self.query_one("#output") except Exception: return + start_widget = state.start.content_widget or state.start.container + if start_widget is not output: + # Selecting inside another widget (e.g. the input draft) — + # leave Textual's native auto-scroll alone. + return lines = self.app.SELECT_AUTO_SCROLL_LINES y = event.pointer_screen_y - if y < lines and output.scroll_y > 0: - self._start_auto_scroll(output, -1, (lines - y) / lines) - elif ( - y >= self.size.height - lines - and output.scroll_y < output.max_scroll_y - ): - speed = (y - (self.size.height - lines) + 1) / lines - self._start_auto_scroll(output, +1, speed) + if y < output.region.y + lines: + direction = -1 + speed = min((output.region.y + lines - y) / lines, 1.0) + can_scroll = output.scroll_y > 0 + elif y >= output.region.bottom: + direction = +1 + speed = min((y - output.region.bottom + 1) / lines, 1.0) + can_scroll = output.scroll_y < output.max_scroll_y + else: + return + target = self._auto_scroll_target + _select_debug( + f"move y={y} dy={event.delta_y} " + f"zone={'up' if direction < 0 else 'down'} " + f"target={getattr(target, 'id', target)} " + f"scroll_y={output.scroll_y:.0f}/{output.max_scroll_y}", + ) + if self._auto_scroll_target is output: + # Already scrolling the right widget (armed here or natively). + return + self._stop_auto_scroll() + if can_scroll: + self._start_auto_scroll(output, direction, speed) def extend_selection_to(self, pointer: Offset) -> None: """Move an in-progress selection's end to the content under pointer.""" @@ -811,6 +943,20 @@ def _on_key(self, event) -> None: if output is not None: event.prevent_default() event.stop() + if isinstance(output, OutputLog): + # The scroll below is deferred, so decide follow from + # the computed landing position, not the live offset. + if event.key in ("pageup", "shift+up"): + output._follow_output = False + else: + delta = ( + output.scrollable_content_region.height + if event.key == "pagedown" + else 1 + ) + landed = output.scroll_offset.y + delta + if landed >= output.max_scroll_y: + output._follow_output = True if event.key == "pageup": output.scroll_page_up(animate=False) elif event.key == "pagedown": @@ -950,13 +1096,11 @@ def _flush_wheel_scroll(self) -> None: screen = self.app.screen except Exception: return - # Drive the same follow-suppression marker as the mouse path: - # wheel-up = user reading above (don't yank), wheel-down = follow. if lines < 0: - output._last_wheel_up_ts = time.monotonic() - else: - output._last_wheel_up_ts = 0.0 + output._follow_output = False output.scroll_to(y=output.scroll_offset.y + lines, animate=False) + if lines > 0 and output.scroll_offset.y >= output.max_scroll_y: + output._follow_output = True # Wheel scrolling mid-drag (PyCharm wheel = arrow keys) must also # keep the selection following the pointer, or the selection cannot # grow past one screen @@ -1619,6 +1763,17 @@ def on_text_selected(self, event: events.TextSelected) -> None: if not text or not text.strip(): return n_lines = len(text.splitlines()) + if _IS_JEDITERM: + # JediTerm handles Cmd+C itself and copies only the visible + # screen; the app never sees that keypress. Ctrl+C always + # reaches the app and copies the full (multi-screen) selection. + self.notify( + f"Selected {n_lines} lines: Ctrl+C copies all " + "(Cmd+C here copies only the visible screen); " + "Ctrl+Q to quote into input box", + timeout=4, + ) + return self.notify( f"Selected {n_lines} lines: Cmd+C to copy (Ctrl+C on " "PyCharm-style terminals); Ctrl+Q to quote into input box", @@ -2859,4 +3014,6 @@ def run_tui(config, agent, input_history_path: Path | None = None): # screen (including the input line) appears to move. Set tui_mouse = # false for terminal-native selection; bypass capture with Alt/Option # drag to copy. - app.run(mouse=getattr(config, "tui_mouse", True)) + mouse = getattr(config, "tui_mouse", True) + _select_debug(f"run_tui start mouse={mouse} pid={os.getpid()}") + app.run(mouse=mouse) From fd5af21993cd7c044a74711488746d01c67567ae Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Fri, 21 Aug 2026 15:41:37 +0800 Subject: [PATCH 13/16] fix(acli): clear selection before copy toast; hint Ctrl+C uniformly Sync from agenticCLI (35f6142). --- dashscope/acli/ui/tui.py | 35 ++++++++++++++--------------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/dashscope/acli/ui/tui.py b/dashscope/acli/ui/tui.py index 5f769c5..2516754 100644 --- a/dashscope/acli/ui/tui.py +++ b/dashscope/acli/ui/tui.py @@ -1508,6 +1508,13 @@ def action_copy_selection(self) -> bool: tool = copy_to_clipboard(text) n_lines = len(text.splitlines()) + # Clear the selection highlight BEFORE notify: a failing toast must + # never leave the highlight stuck (clearing also prevents the + # terminal from overwriting the clipboard with visible-screen + # content on Cmd+C). The explicit refresh forces the cleared frame + # out immediately instead of relying on the async selections watcher. + self.screen.clear_selection() + self.screen.refresh() if tool: self.notify(f"Copied {n_lines} lines to clipboard", timeout=2) else: @@ -1520,9 +1527,6 @@ def action_copy_selection(self) -> bool: "does not support it)", timeout=3, ) - # Clear the selection highlight: prevents the terminal from - # overwriting the clipboard with visible-screen content on Cmd+C - self.screen.clear_selection() return True def action_smart_quit(self) -> None: @@ -1756,27 +1760,16 @@ def _write_output(self, content) -> None: def on_text_selected(self, event: events.TextSelected) -> None: # Do not write the clipboard automatically on mouse-up — that would - # clobber the user's clipboard content. Only hint at Cmd+C for an - # explicit copy (the super+c binding writes the full selection, + # clobber the user's clipboard content. Only hint at Ctrl+C for an + # explicit copy (the smart_quit binding writes the full selection, # including the scrolled-off part, to the clipboard). text = self.screen.get_selected_text() if not text or not text.strip(): return n_lines = len(text.splitlines()) - if _IS_JEDITERM: - # JediTerm handles Cmd+C itself and copies only the visible - # screen; the app never sees that keypress. Ctrl+C always - # reaches the app and copies the full (multi-screen) selection. - self.notify( - f"Selected {n_lines} lines: Ctrl+C copies all " - "(Cmd+C here copies only the visible screen); " - "Ctrl+Q to quote into input box", - timeout=4, - ) - return self.notify( - f"Selected {n_lines} lines: Cmd+C to copy (Ctrl+C on " - "PyCharm-style terminals); Ctrl+Q to quote into input box", + f"Selected {n_lines} lines: Ctrl+C to copy; " + "Ctrl+Q to quote into input box", timeout=3, ) @@ -1985,9 +1978,9 @@ def _render_banner(self) -> None: "Ctrl+C cancel/quit [/dim]", ) info_lines.append( - "[dim]Output: wheel to scroll; drag to select, then Cmd+C " - "to copy (Ctrl+C on PyCharm-style terminals); Ctrl+Q to " - "quote the selection into the input box[/dim]", + "[dim]Output: wheel to scroll; drag to select, then Ctrl+C " + "to copy; Ctrl+Q to quote the selection into the input box" + "[/dim]", ) panel_border = ( From a79a3cf09781fc68a92ed30e46f93ea7693001f6 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Fri, 21 Aug 2026 16:01:20 +0800 Subject: [PATCH 14/16] =?UTF-8?q?release:=20v1.27.1=20=E2=80=94=20sync=20a?= =?UTF-8?q?cli=20v0.6.2=20TUI=20mouse=20selection=20&=20scroll=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dashscope __version__ 1.27.0 -> 1.27.1; acli 0.6.1 -> 0.6.2 (drag-select edge auto-scroll, stale capture watchdog, bottom jitter follow state, Ctrl+C copy hints, clear-before-toast). --- dashscope/acli/__init__.py | 2 +- dashscope/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dashscope/acli/__init__.py b/dashscope/acli/__init__.py index 199c1b8..4f5a0ac 100644 --- a/dashscope/acli/__init__.py +++ b/dashscope/acli/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import annotations -__version__ = "0.6.1" +__version__ = "0.6.2" # Expose the lightweight programmatic SDK at the package root. try: diff --git a/dashscope/version.py b/dashscope/version.py index 1925367..e8bac9f 100644 --- a/dashscope/version.py +++ b/dashscope/version.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -__version__ = "1.27.0" +__version__ = "1.27.1" From 444c91e767a185e85d9bb572d5f807fd666b171b Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Fri, 21 Aug 2026 16:23:21 +0800 Subject: [PATCH 15/16] style(acli): black-format synced tree; fix noqa placement and W1404 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI runs pre-commit --all-files while local commits only checked staged files, so the synced tree (rewritten acli. -> dashscope.acli., +10 cols per import) was never black-formatted: 23 files reformatted. The rewrite also pushed four noqa'd imports past 79 cols; black wrapped them and the trailing noqa no longer covered F401/E402 — fixed at the source with exploded first-line-noqa imports (agenticCLI). commands.py implicit string concat joined by black tripped pylint W1404 — single literal at the source. sync_acli.sh now runs the repo-pinned black on the synced tree so local output always satisfies CI. Also commit the two sync scripts. --- dashscope/acli/agents/subagent.py | 4 +- dashscope/acli/agents/subagents.py | 5 +- dashscope/acli/cli/__init__.py | 19 ++++- dashscope/acli/cli/completer.py | 4 +- dashscope/acli/cli/dispatch.py | 4 +- dashscope/acli/cli/handlers_capability.py | 17 +++- dashscope/acli/cli/handlers_config.py | 8 +- dashscope/acli/cli/handlers_session.py | 2 +- dashscope/acli/cli/repl.py | 33 ++++++-- dashscope/acli/cli/runners.py | 46 ++++++++--- dashscope/acli/cli/streaming.py | 11 ++- dashscope/acli/commands.py | 3 +- dashscope/acli/config.py | 6 +- dashscope/acli/dev.py | 20 ++++- dashscope/acli/extensions.py | 6 +- dashscope/acli/memory/experience.py | 5 +- dashscope/acli/memory/tool_chains.py | 5 +- dashscope/acli/platforms/bailian/__init__.py | 5 +- dashscope/acli/providers/profile.py | 5 +- dashscope/acli/providers/tongyi.py | 8 +- dashscope/acli/tools/platform.py | 16 +++- dashscope/acli/tools/session.py | 6 +- dashscope/acli/utils/__init__.py | 11 ++- scripts/sync_acli.sh | 81 ++++++++++++++++++++ scripts/sync_examples.sh | 32 ++++++++ 25 files changed, 307 insertions(+), 55 deletions(-) create mode 100755 scripts/sync_acli.sh create mode 100755 scripts/sync_examples.sh diff --git a/dashscope/acli/agents/subagent.py b/dashscope/acli/agents/subagent.py index 0e7363a..24d7b5f 100644 --- a/dashscope/acli/agents/subagent.py +++ b/dashscope/acli/agents/subagent.py @@ -73,7 +73,9 @@ async def _subagent_invoke( "(missing parent agent reference)" ) - from dashscope.acli.agent import Agent # local import to avoid module-load cycle + from dashscope.acli.agent import ( + Agent, + ) # local import to avoid module-load cycle from dashscope.acli.memory.manager import MemoryManager # Look up per-agent config overrides (max_turns, model, temperature) diff --git a/dashscope/acli/agents/subagents.py b/dashscope/acli/agents/subagents.py index 347cfc0..ad16d56 100644 --- a/dashscope/acli/agents/subagents.py +++ b/dashscope/acli/agents/subagents.py @@ -154,7 +154,10 @@ def _subagents_list(config: Config) -> None: def _subagents_reload(config: Config) -> None: # pylint: disable=unused-argument """Re-scan custom_extensions.toml and refresh subagent registry.""" - from dashscope.acli.cli import PROVIDER_MODELS, sync_extensions_into_catalog + from dashscope.acli.cli import ( + PROVIDER_MODELS, + sync_extensions_into_catalog, + ) from dashscope.acli.extensions import apply_extensions ext = apply_extensions(PROVIDER_MODELS) diff --git a/dashscope/acli/cli/__init__.py b/dashscope/acli/cli/__init__.py index b178db7..d2ace54 100644 --- a/dashscope/acli/cli/__init__.py +++ b/dashscope/acli/cli/__init__.py @@ -24,7 +24,10 @@ from dashscope.acli.skills import load_skill_files # noqa: E402 load_skill_files() -from dashscope.acli.cli.completer import _get_arg_hint, _is_dir_safe # noqa: F401,E402 +from dashscope.acli.cli.completer import ( # noqa: F401,E402 + _get_arg_hint, + _is_dir_safe, +) # Import constants and multimodal handling from submodules from dashscope.acli.cli.constants import ( # noqa: F401,E402 @@ -54,11 +57,16 @@ _cap_enabled, sync_extensions_into_catalog, ) -from dashscope.acli.cli.handlers_misc import _handle_report_command # noqa: F401,E402 +from dashscope.acli.cli.handlers_misc import ( # noqa: F401,E402 + _handle_report_command, +) from dashscope.acli.cli.handlers_setup import _handle_setup # noqa: F401,E402 # Import MCP management from submodule -from dashscope.acli.cli.mcp import _connect_mcp, _mcp_clients # noqa: F401,E402 +from dashscope.acli.cli.mcp import ( # noqa: F401,E402 + _connect_mcp, + _mcp_clients, +) from dashscope.acli.cli.repl import _run_loop # noqa: E402 from dashscope.acli.cli.runners import ( # noqa: E402 _run_dry_run, @@ -69,7 +77,10 @@ _compose_system_prompt, _load_system_prompt, ) -from dashscope.acli.cli.streaming import _do_compress, _do_summarize # noqa: F401,E402 +from dashscope.acli.cli.streaming import ( # noqa: F401,E402 + _do_compress, + _do_summarize, +) # Cron scheduler _scheduler = None diff --git a/dashscope/acli/cli/completer.py b/dashscope/acli/cli/completer.py index b412c75..a5b0855 100644 --- a/dashscope/acli/cli/completer.py +++ b/dashscope/acli/cli/completer.py @@ -245,7 +245,9 @@ def _slot_candidates(self, tokens: list[str], arg_index: int) -> list[str]: return [] if cmd == "/subagents": - from dashscope.acli.agents.subagents import SUBAGENT_CAPABILITY_KEYS + from dashscope.acli.agents.subagents import ( + SUBAGENT_CAPABILITY_KEYS, + ) if arg_index == 1: return _SUBCOMMANDS["/subagents"] diff --git a/dashscope/acli/cli/dispatch.py b/dashscope/acli/cli/dispatch.py index 68ce99f..d38c525 100644 --- a/dashscope/acli/cli/dispatch.py +++ b/dashscope/acli/cli/dispatch.py @@ -353,7 +353,9 @@ def _handle_slash_command( console.print(render_help_text()) return True elif cmd.startswith("/provider"): - from dashscope.acli.cli.handlers_provider import handle_provider_command + from dashscope.acli.cli.handlers_provider import ( + handle_provider_command, + ) handle_provider_command(cmd, agent, config) return True diff --git a/dashscope/acli/cli/handlers_capability.py b/dashscope/acli/cli/handlers_capability.py index 86233c2..464870a 100644 --- a/dashscope/acli/cli/handlers_capability.py +++ b/dashscope/acli/cli/handlers_capability.py @@ -7,7 +7,10 @@ from rich.console import Console -from dashscope.acli.cli.constants import ALL_CAPABILITY_KEYS, CAPABILITY_CATALOG +from dashscope.acli.cli.constants import ( + ALL_CAPABILITY_KEYS, + CAPABILITY_CATALOG, +) from dashscope.acli.config import PROVIDER_MODELS, Config console = Console() @@ -150,7 +153,9 @@ def _handle_capability_command(cmd: str, config: Config): # Already enabled — but extension caps may still lack credentials # (enabled without a token, or env var unset). Offer the prompt # again and re-register so the tools bind to fresh creds. - from dashscope.acli.cli.handlers_key import _maybe_prompt_extension_token + from dashscope.acli.cli.handlers_key import ( + _maybe_prompt_extension_token, + ) _maybe_prompt_extension_token(cap_key, config) from dashscope.acli.cli.mcp import _connect_mcp @@ -183,7 +188,9 @@ def _handle_capability_command(cmd: str, config: Config): return # Extension-capability bearer/apikey-header token - from dashscope.acli.cli.handlers_key import _maybe_prompt_extension_token + from dashscope.acli.cli.handlers_key import ( + _maybe_prompt_extension_token, + ) _maybe_prompt_extension_token(cap_key, config) @@ -234,7 +241,9 @@ def _handle_capability_command(cmd: str, config: Config): if sub in ("reload", "refresh"): from dashscope.acli.extensions import apply_extensions - from dashscope.acli.tools.platform import refresh_extension_capability_tools + from dashscope.acli.tools.platform import ( + refresh_extension_capability_tools, + ) ext = apply_extensions(PROVIDER_MODELS) sync_extensions_into_catalog(ext) diff --git a/dashscope/acli/cli/handlers_config.py b/dashscope/acli/cli/handlers_config.py index 10e661f..868cb62 100644 --- a/dashscope/acli/cli/handlers_config.py +++ b/dashscope/acli/cli/handlers_config.py @@ -53,7 +53,9 @@ def _print_status(): get_audit_logger().set_privacy_mode(True) # Enforce at the tool surface too (not just the slash-command gate): # drop every registered cloud capability tool + connected MCP tools. - from dashscope.acli.tools.platform import unregister_cloud_capability_tools + from dashscope.acli.tools.platform import ( + unregister_cloud_capability_tools, + ) from dashscope.acli.tools.registry import registry removed = unregister_cloud_capability_tools() @@ -605,7 +607,9 @@ def _handle_directives_command(cmd: str, config: Config) -> None: return if sub == "proposals": - from dashscope.acli.memory.directives_learning import list_proposed_directives + from dashscope.acli.memory.directives_learning import ( + list_proposed_directives, + ) proposals = list_proposed_directives("pending") if not proposals: diff --git a/dashscope/acli/cli/handlers_session.py b/dashscope/acli/cli/handlers_session.py index 6ee91d4..b4d0d7b 100644 --- a/dashscope/acli/cli/handlers_session.py +++ b/dashscope/acli/cli/handlers_session.py @@ -162,7 +162,7 @@ def _handle_session_command(cmd: str, config, agent) -> None: session_mgr.set_scene("") console.print(f"[green]Scene memory cleared ({topic})[/green]") elif rest.startswith("set "): - text = rest[len("set "):].strip() + text = rest[len("set ") :].strip() if session_mgr.set_scene(text): console.print( f"[green]Scene memory replaced ({topic})[/green]", diff --git a/dashscope/acli/cli/repl.py b/dashscope/acli/cli/repl.py index a6327cd..c749f33 100644 --- a/dashscope/acli/cli/repl.py +++ b/dashscope/acli/cli/repl.py @@ -15,7 +15,11 @@ from rich.panel import Panel from dashscope.acli.agent import Agent -from dashscope.acli.cli.completer import AcliCompleter, SafeFileHistory, _HintProcessor +from dashscope.acli.cli.completer import ( + AcliCompleter, + SafeFileHistory, + _HintProcessor, +) from dashscope.acli.cli.dispatch import ( _handle_skill_continue, _handle_slash_command, @@ -31,13 +35,20 @@ _init_mcp_servers, _mcp_clients, ) -from dashscope.acli.cli.multimodal import _expand_at_references, _to_multimodal_content +from dashscope.acli.cli.multimodal import ( + _expand_at_references, + _to_multimodal_content, +) from dashscope.acli.cli.startup import ( _compose_system_prompt, _load_system_prompt, _print_banner, ) -from dashscope.acli.cli.streaming import _do_compress, _do_summarize, _stream_response +from dashscope.acli.cli.streaming import ( + _do_compress, + _do_summarize, + _stream_response, +) from dashscope.acli.config import ( PROVIDER_MODELS, WORKSPACE_CONFIG_FILE, @@ -137,10 +148,18 @@ async def _run_loop(config: Config): # Pin parent agent ref for local.subagent / local.delegate BEFORE platform # tool registration so register_one_capability finds a parent to attach to. - from dashscope.acli.agents.delegate import set_config as set_delegate_config - from dashscope.acli.agents.delegate import set_parent_agent as set_delegate_parent - from dashscope.acli.agents.subagent import set_config as set_subagent_config - from dashscope.acli.agents.subagent import set_parent_agent as set_subagent_parent + from dashscope.acli.agents.delegate import ( + set_config as set_delegate_config, + ) + from dashscope.acli.agents.delegate import ( + set_parent_agent as set_delegate_parent, + ) + from dashscope.acli.agents.subagent import ( + set_config as set_subagent_config, + ) + from dashscope.acli.agents.subagent import ( + set_parent_agent as set_subagent_parent, + ) set_subagent_parent(agent) set_subagent_config(config) diff --git a/dashscope/acli/cli/runners.py b/dashscope/acli/cli/runners.py index 0dcb603..056b6a7 100644 --- a/dashscope/acli/cli/runners.py +++ b/dashscope/acli/cli/runners.py @@ -10,8 +10,14 @@ from dashscope.acli.agent import Agent from dashscope.acli.cli.handlers_key import ensure_provider_key -from dashscope.acli.cli.multimodal import _expand_at_references, _to_multimodal_content -from dashscope.acli.cli.startup import _compose_system_prompt, _load_system_prompt +from dashscope.acli.cli.multimodal import ( + _expand_at_references, + _to_multimodal_content, +) +from dashscope.acli.cli.startup import ( + _compose_system_prompt, + _load_system_prompt, +) from dashscope.acli.config import ( PROVIDER_MODELS, Config, @@ -73,10 +79,18 @@ async def _run_oneshot(config: Config, prompt: str): # Pin parent agent ref for local.subagent / local.delegate BEFORE platform # tool registration so register_one_capability finds a parent to attach to # (same ordering as cli/repl.py). - from dashscope.acli.agents.delegate import set_config as set_delegate_config - from dashscope.acli.agents.delegate import set_parent_agent as set_delegate_parent - from dashscope.acli.agents.subagent import set_config as set_subagent_config - from dashscope.acli.agents.subagent import set_parent_agent as set_subagent_parent + from dashscope.acli.agents.delegate import ( + set_config as set_delegate_config, + ) + from dashscope.acli.agents.delegate import ( + set_parent_agent as set_delegate_parent, + ) + from dashscope.acli.agents.subagent import ( + set_config as set_subagent_config, + ) + from dashscope.acli.agents.subagent import ( + set_parent_agent as set_subagent_parent, + ) set_subagent_parent(agent) set_subagent_config(config) @@ -255,7 +269,9 @@ def _run_tui_mode(config: Config): from dashscope.acli.extensions import apply_extensions _ext = apply_extensions(PROVIDER_MODELS) - from dashscope.acli.cli.handlers_capability import sync_extensions_into_catalog + from dashscope.acli.cli.handlers_capability import ( + sync_extensions_into_catalog, + ) sync_extensions_into_catalog(_ext) @@ -264,10 +280,18 @@ def _run_tui_mode(config: Config): provider = get_provider_chain(config) executor = Executor(auto_approve=config.auto_approve) - from dashscope.acli.agents.delegate import set_config as set_delegate_config - from dashscope.acli.agents.delegate import set_parent_agent as set_delegate_parent - from dashscope.acli.agents.subagent import set_config as set_subagent_config - from dashscope.acli.agents.subagent import set_parent_agent as set_subagent_parent + from dashscope.acli.agents.delegate import ( + set_config as set_delegate_config, + ) + from dashscope.acli.agents.delegate import ( + set_parent_agent as set_delegate_parent, + ) + from dashscope.acli.agents.subagent import ( + set_config as set_subagent_config, + ) + from dashscope.acli.agents.subagent import ( + set_parent_agent as set_subagent_parent, + ) from dashscope.acli.hooks import create_hook_bus from dashscope.acli.platforms import get_memory_provider from dashscope.acli.tools.platform import disabled_capabilities_hint diff --git a/dashscope/acli/cli/streaming.py b/dashscope/acli/cli/streaming.py index 10b45db..e77fa72 100644 --- a/dashscope/acli/cli/streaming.py +++ b/dashscope/acli/cli/streaming.py @@ -13,8 +13,15 @@ from dashscope.acli.agent import Agent from dashscope.acli.config import Config -from dashscope.acli.deliverable import collect_deliverables, surface_deliverables -from dashscope.acli.utils import AsyncSpinner, UserAbortedTurn, message_text_for_compress +from dashscope.acli.deliverable import ( + collect_deliverables, + surface_deliverables, +) +from dashscope.acli.utils import ( + AsyncSpinner, + UserAbortedTurn, + message_text_for_compress, +) console = Console() diff --git a/dashscope/acli/commands.py b/dashscope/acli/commands.py index f8f1f26..111bd1d 100644 --- a/dashscope/acli/commands.py +++ b/dashscope/acli/commands.py @@ -112,8 +112,7 @@ ("/memory", "Chat history (list/search/remove /clear)"), ( "/session", - "Session management " - "(new/list/switch/rename/remove/scene)", + "Session management (new/list/switch/rename/remove/scene)", ), ( "/summarize", diff --git a/dashscope/acli/config.py b/dashscope/acli/config.py index 5542794..3ff97c1 100644 --- a/dashscope/acli/config.py +++ b/dashscope/acli/config.py @@ -8,7 +8,11 @@ from dashscope.acli.utils.crypto import decrypt_value, encrypt_value from dashscope.acli.utils.paths import atomic_write_text -from dashscope.acli.utils.toml import load_toml, parse_toml_inline_table, toml_str +from dashscope.acli.utils.toml import ( + load_toml, + parse_toml_inline_table, + toml_str, +) CONFIG_DIR = Path.home() / ".acli" CONFIG_FILE = CONFIG_DIR / "config.toml" diff --git a/dashscope/acli/dev.py b/dashscope/acli/dev.py index 1f8ce85..ff4cd27 100644 --- a/dashscope/acli/dev.py +++ b/dashscope/acli/dev.py @@ -453,7 +453,9 @@ def _hot_reload(config: Config | None = None) -> None: sync_extensions_into_catalog(ext) if config is not None: - from dashscope.acli.tools.platform import refresh_extension_capability_tools + from dashscope.acli.tools.platform import ( + refresh_extension_capability_tools, + ) refresh_extension_capability_tools(config) @@ -606,7 +608,10 @@ def _provider_remove(name: str) -> None: def _capability_add(config: Config) -> None: """Scaffold a [[capabilities]] block in toml the user then edits in their editor — tool definitions are too complex for a smooth one-shot prompt.""" - from dashscope.acli.extensions import append_capability_scaffold, load_extensions + from dashscope.acli.extensions import ( + append_capability_scaffold, + load_extensions, + ) console.print("\n[bold]Add Capability (HTTP tool group)[/bold]") console.print( @@ -678,7 +683,11 @@ def _capability_remove(key: str, config: Config) -> None: def _skill_add() -> None: - from dashscope.acli.extensions import CustomSkill, append_skill, load_extensions + from dashscope.acli.extensions import ( + CustomSkill, + append_skill, + load_extensions, + ) from dashscope.acli.skills.base import BUILTIN_SKILLS, Skill, register console.print("\n[bold]Add Skill (Prompt template)[/bold]") @@ -975,7 +984,10 @@ async def _test_provider(name: str, config: Config) -> None: import copy as _copy from dashscope.acli.extensions import find_provider - from dashscope.acli.providers import _create_provider, build_profiles_from_config + from dashscope.acli.providers import ( + _create_provider, + build_profiles_from_config, + ) console.print(f"[dim]Testing provider {name}...[/dim]") try: diff --git a/dashscope/acli/extensions.py b/dashscope/acli/extensions.py index 155ea4c..dda9461 100644 --- a/dashscope/acli/extensions.py +++ b/dashscope/acli/extensions.py @@ -1237,7 +1237,11 @@ def _register_custom_shell_tools(ext: CustomExtensions) -> None: registry.""" import subprocess as sp - from dashscope.acli.tools.registry import PermissionLevel, ToolDefinition, registry + from dashscope.acli.tools.registry import ( + PermissionLevel, + ToolDefinition, + registry, + ) for t in ext.shell_tools: perm = getattr( diff --git a/dashscope/acli/memory/experience.py b/dashscope/acli/memory/experience.py index f6e2fba..781e42b 100644 --- a/dashscope/acli/memory/experience.py +++ b/dashscope/acli/memory/experience.py @@ -10,7 +10,10 @@ from pathlib import Path from typing import Any -from dashscope.acli.utils.keywords import expand_scoring_terms, extract_keywords +from dashscope.acli.utils.keywords import ( + expand_scoring_terms, + extract_keywords, +) class ExperienceTracker: diff --git a/dashscope/acli/memory/tool_chains.py b/dashscope/acli/memory/tool_chains.py index 1512314..5fcfa39 100644 --- a/dashscope/acli/memory/tool_chains.py +++ b/dashscope/acli/memory/tool_chains.py @@ -8,7 +8,10 @@ from pathlib import Path -from dashscope.acli.utils.keywords import expand_scoring_terms, extract_keywords +from dashscope.acli.utils.keywords import ( + expand_scoring_terms, + extract_keywords, +) # Common tool chain patterns with examples TOOL_CHAINS = { diff --git a/dashscope/acli/platforms/bailian/__init__.py b/dashscope/acli/platforms/bailian/__init__.py index 415432d..0f7f0f6 100644 --- a/dashscope/acli/platforms/bailian/__init__.py +++ b/dashscope/acli/platforms/bailian/__init__.py @@ -1,7 +1,10 @@ # -*- coding: utf-8 -*- from __future__ import annotations -from dashscope.acli.platforms.bailian.cli import BailianCLIClient, BailianCLIError +from dashscope.acli.platforms.bailian.cli import ( + BailianCLIClient, + BailianCLIError, +) from dashscope.acli.platforms.bailian.mcp import MCPClient, MCPError __all__ = [ diff --git a/dashscope/acli/providers/profile.py b/dashscope/acli/providers/profile.py index dd02fd1..43296c6 100644 --- a/dashscope/acli/providers/profile.py +++ b/dashscope/acli/providers/profile.py @@ -13,7 +13,10 @@ from typing import AsyncIterator from dashscope.acli.providers.base import LLMChunk, LLMProvider, LLMResponse -from dashscope.acli.providers.hardening import HardenedProvider, is_retryable_error +from dashscope.acli.providers.hardening import ( + HardenedProvider, + is_retryable_error, +) _API_KEY_ENVS = { "tongyi": "DASHSCOPE_API_KEY", diff --git a/dashscope/acli/providers/tongyi.py b/dashscope/acli/providers/tongyi.py index 3929bbb..8bd34fb 100644 --- a/dashscope/acli/providers/tongyi.py +++ b/dashscope/acli/providers/tongyi.py @@ -134,7 +134,9 @@ async def chat( # If protocol is anthropic, convert input from Anthropic to # OpenAI format if self.protocol == "anthropic": - from dashscope.acli.providers.adapter import anthropic_to_openai_request + from dashscope.acli.providers.adapter import ( + anthropic_to_openai_request, + ) # Agent may send system as first message, extract it system_msg = None @@ -233,7 +235,9 @@ async def chat_stream( # If protocol is anthropic, convert input from Anthropic to # OpenAI format if self.protocol == "anthropic": - from dashscope.acli.providers.adapter import anthropic_to_openai_request + from dashscope.acli.providers.adapter import ( + anthropic_to_openai_request, + ) # Agent may send system as first message, extract it system_msg = None diff --git a/dashscope/acli/tools/platform.py b/dashscope/acli/tools/platform.py index f90d910..2269065 100644 --- a/dashscope/acli/tools/platform.py +++ b/dashscope/acli/tools/platform.py @@ -5,7 +5,11 @@ from dashscope.acli.config import Config from dashscope.acli.platforms import get_cli_provider -from dashscope.acli.tools.registry import PermissionLevel, ToolDefinition, registry +from dashscope.acli.tools.registry import ( + PermissionLevel, + ToolDefinition, + registry, +) # Track which tool names each capability registered, so /capability disable # can unregister them mid-session (the prior "takes effect on restart" @@ -127,7 +131,10 @@ def register_one_capability( if cli_client := get_cli_provider(config): _register_bailian_cli_tools(cli_client) elif cap_key == "local.subagent": - from dashscope.acli.agents.subagent import _has_parent, register_subagent_tool + from dashscope.acli.agents.subagent import ( + _has_parent, + register_subagent_tool, + ) if _has_parent(): register_subagent_tool() @@ -135,7 +142,10 @@ def register_one_capability( # was called too early — cli.py wires _set_parent_agent + a final # re-register pass after Agent construction. elif cap_key == "local.delegate": - from dashscope.acli.agents.delegate import _has_parent, register_delegate_tools + from dashscope.acli.agents.delegate import ( + _has_parent, + register_delegate_tools, + ) if _has_parent(): register_delegate_tools() diff --git a/dashscope/acli/tools/session.py b/dashscope/acli/tools/session.py index 03bfbb5..d6c855d 100644 --- a/dashscope/acli/tools/session.py +++ b/dashscope/acli/tools/session.py @@ -8,7 +8,11 @@ from typing import Callable from dashscope.acli.config import PROVIDER_MODELS, Config, normalize_model_name -from dashscope.acli.tools.registry import PermissionLevel, ToolDefinition, registry +from dashscope.acli.tools.registry import ( + PermissionLevel, + ToolDefinition, + registry, +) def register_session_tools( diff --git a/dashscope/acli/utils/__init__.py b/dashscope/acli/utils/__init__.py index 9b2f879..03c2285 100644 --- a/dashscope/acli/utils/__init__.py +++ b/dashscope/acli/utils/__init__.py @@ -21,9 +21,16 @@ validate_path, validate_write_path, ) -from dashscope.acli.utils.sanitizer import is_secret_field, sanitize, sanitize_text +from dashscope.acli.utils.sanitizer import ( + is_secret_field, + sanitize, + sanitize_text, +) from dashscope.acli.utils.spinner import AsyncSpinner, StderrSpinner -from dashscope.acli.utils.template import render_brace_template, render_mustache_template +from dashscope.acli.utils.template import ( + render_brace_template, + render_mustache_template, +) from dashscope.acli.utils.text import ( mask_secret, strip_frontmatter, diff --git a/scripts/sync_acli.sh b/scripts/sync_acli.sh new file mode 100755 index 0000000..4ec777c --- /dev/null +++ b/scripts/sync_acli.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Sync agenticCLI src/acli into dashscope/acli with import path rewrite. +# Usage: scripts/sync_acli.sh [path-to-agenticCLI] +set -euo pipefail + +SRC_REPO="${1:-/Users/zhansheng.lzs/ali/pro/ptm/agenticCLI}" +SRC="$SRC_REPO/src/acli" +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DST="$REPO_ROOT/dashscope/acli" + +if [ ! -d "$SRC" ]; then + echo "error: source not found: $SRC" >&2 + exit 1 +fi + +echo "==> Sync $SRC -> $DST" +# Preserve local examples when the source repo no longer ships them. +KEPT_EXAMPLES="" +if [ ! -d "$SRC_REPO/examples" ] && [ -d "$DST/examples" ]; then + KEPT_EXAMPLES="$(mktemp -d)/examples" + mv "$DST/examples" "$KEPT_EXAMPLES" +fi +rm -rf "$DST" +rsync -a --exclude='__pycache__' --exclude='*.pyc' --exclude='.DS_Store' "$SRC/" "$DST/" + +if [ -d "$SRC_REPO/examples" ]; then + echo "==> Sync $SRC_REPO/examples -> $DST/examples" + rm -rf "$DST/examples" + rsync -a --exclude='__pycache__' --exclude='*.pyc' --exclude='.DS_Store' "$SRC_REPO/examples/" "$DST/examples/" +elif [ -n "$KEPT_EXAMPLES" ]; then + echo "==> Source has no examples/; keeping existing $DST/examples" + mv "$KEPT_EXAMPLES" "$DST/examples" +fi + +echo "==> Rewrite imports acli.* -> dashscope.acli.*" +find "$DST" -name '*.py' -print0 | xargs -0 sed -i '' -E ' + s/^([[:space:]]*)from acli\./\1from dashscope.acli./g; + s/^([[:space:]]*)from acli (import)/\1from dashscope.acli \2/g; + s/^([[:space:]]*)import acli\./\1import dashscope.acli./g; + s/^([[:space:]]*)import acli$/\1import dashscope.acli as acli/g; + s/"acli\.cli\."/"dashscope.acli.cli."/g; + s/'"'"'acli\.cli\.'"'"'/'"'"'dashscope.acli.cli.'"'"'/g; +' + +echo "==> Check for leftover bare acli imports" +if grep -rnE '^[[:space:]]*(from|import) acli(\.|[[:space:]]|$)' "$DST" --include='*.py'; then + echo "error: unrewritten imports remain (see above)" >&2 + exit 1 +fi + +# The acli. -> dashscope.acli. rewrite makes import lines 10 chars longer, +# so the synced tree must be re-formatted with the repo's pinned black +# (23.3.0 --line-length=79 via pre-commit) to match CI's --all-files run. +echo "==> Format synced tree (black via pre-commit, matching CI)" +cd "$REPO_ROOT" +if command -v pre-commit >/dev/null 2>&1; then + # shellcheck disable=SC2046 + pre-commit run black --files $(find dashscope/acli -name '*.py') || true +else + echo "warn: pre-commit not on PATH; synced tree not auto-formatted" >&2 +fi + +echo "==> Verify all modules import" +cd "$REPO_ROOT" +python - <<'EOF' +import pkgutil, importlib, sys +import dashscope.acli +failed = [] +for m in pkgutil.walk_packages(dashscope.acli.__path__, prefix="dashscope.acli."): + try: + importlib.import_module(m.name) + except Exception as e: + failed.append((m.name, e)) +if failed: + for name, e in failed: + print(f"FAIL {name}: {e}", file=sys.stderr) + sys.exit(1) +print(f"OK: all modules under dashscope.acli import cleanly") +EOF + +echo "==> Done" diff --git a/scripts/sync_examples.sh b/scripts/sync_examples.sh new file mode 100755 index 0000000..3a98f9c --- /dev/null +++ b/scripts/sync_examples.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Sync the standalone agenticCLI-examples repo into dashscope/acli/examples. +# Examples are maintained in agenticCLI-examples; after modifying them there, +# run this script to vendor the updates here. +# Usage: scripts/sync_examples.sh [path-to-agenticCLI-examples] +set -euo pipefail + +SRC_REPO="${1:-/Users/zhansheng.lzs/ali/pro/ptm/agenticCLI-examples}" +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DST="$REPO_ROOT/dashscope/acli/examples" + +if [ ! -d "$SRC_REPO" ]; then + echo "error: source not found: $SRC_REPO" >&2 + exit 1 +fi + +echo "==> Sync $SRC_REPO -> $DST" +rsync -a --delete \ + --exclude='.git' \ + --exclude='__pycache__' \ + --exclude='*.pyc' \ + --exclude='.DS_Store' \ + "$SRC_REPO/" "$DST/" + +echo "==> Check for internal references in synced examples" +if grep -rnE 'alibaba-inc|gitlab\.alibaba|code\.alibaba' "$DST"; then + echo "error: internal references found in examples (see above);" >&2 + echo " fix them in agenticCLI-examples before syncing" >&2 + exit 1 +fi + +echo "==> Done" From 4eb84a0e57e0c5f29c3451135f4b7dbe4e5a4058 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Fri, 21 Aug 2026 16:43:14 +0800 Subject: [PATCH 16/16] chore: keep sync scripts local-only, out of the repo --- scripts/sync_acli.sh | 81 ---------------------------------------- scripts/sync_examples.sh | 32 ---------------- 2 files changed, 113 deletions(-) delete mode 100755 scripts/sync_acli.sh delete mode 100755 scripts/sync_examples.sh diff --git a/scripts/sync_acli.sh b/scripts/sync_acli.sh deleted file mode 100755 index 4ec777c..0000000 --- a/scripts/sync_acli.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env bash -# Sync agenticCLI src/acli into dashscope/acli with import path rewrite. -# Usage: scripts/sync_acli.sh [path-to-agenticCLI] -set -euo pipefail - -SRC_REPO="${1:-/Users/zhansheng.lzs/ali/pro/ptm/agenticCLI}" -SRC="$SRC_REPO/src/acli" -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -DST="$REPO_ROOT/dashscope/acli" - -if [ ! -d "$SRC" ]; then - echo "error: source not found: $SRC" >&2 - exit 1 -fi - -echo "==> Sync $SRC -> $DST" -# Preserve local examples when the source repo no longer ships them. -KEPT_EXAMPLES="" -if [ ! -d "$SRC_REPO/examples" ] && [ -d "$DST/examples" ]; then - KEPT_EXAMPLES="$(mktemp -d)/examples" - mv "$DST/examples" "$KEPT_EXAMPLES" -fi -rm -rf "$DST" -rsync -a --exclude='__pycache__' --exclude='*.pyc' --exclude='.DS_Store' "$SRC/" "$DST/" - -if [ -d "$SRC_REPO/examples" ]; then - echo "==> Sync $SRC_REPO/examples -> $DST/examples" - rm -rf "$DST/examples" - rsync -a --exclude='__pycache__' --exclude='*.pyc' --exclude='.DS_Store' "$SRC_REPO/examples/" "$DST/examples/" -elif [ -n "$KEPT_EXAMPLES" ]; then - echo "==> Source has no examples/; keeping existing $DST/examples" - mv "$KEPT_EXAMPLES" "$DST/examples" -fi - -echo "==> Rewrite imports acli.* -> dashscope.acli.*" -find "$DST" -name '*.py' -print0 | xargs -0 sed -i '' -E ' - s/^([[:space:]]*)from acli\./\1from dashscope.acli./g; - s/^([[:space:]]*)from acli (import)/\1from dashscope.acli \2/g; - s/^([[:space:]]*)import acli\./\1import dashscope.acli./g; - s/^([[:space:]]*)import acli$/\1import dashscope.acli as acli/g; - s/"acli\.cli\."/"dashscope.acli.cli."/g; - s/'"'"'acli\.cli\.'"'"'/'"'"'dashscope.acli.cli.'"'"'/g; -' - -echo "==> Check for leftover bare acli imports" -if grep -rnE '^[[:space:]]*(from|import) acli(\.|[[:space:]]|$)' "$DST" --include='*.py'; then - echo "error: unrewritten imports remain (see above)" >&2 - exit 1 -fi - -# The acli. -> dashscope.acli. rewrite makes import lines 10 chars longer, -# so the synced tree must be re-formatted with the repo's pinned black -# (23.3.0 --line-length=79 via pre-commit) to match CI's --all-files run. -echo "==> Format synced tree (black via pre-commit, matching CI)" -cd "$REPO_ROOT" -if command -v pre-commit >/dev/null 2>&1; then - # shellcheck disable=SC2046 - pre-commit run black --files $(find dashscope/acli -name '*.py') || true -else - echo "warn: pre-commit not on PATH; synced tree not auto-formatted" >&2 -fi - -echo "==> Verify all modules import" -cd "$REPO_ROOT" -python - <<'EOF' -import pkgutil, importlib, sys -import dashscope.acli -failed = [] -for m in pkgutil.walk_packages(dashscope.acli.__path__, prefix="dashscope.acli."): - try: - importlib.import_module(m.name) - except Exception as e: - failed.append((m.name, e)) -if failed: - for name, e in failed: - print(f"FAIL {name}: {e}", file=sys.stderr) - sys.exit(1) -print(f"OK: all modules under dashscope.acli import cleanly") -EOF - -echo "==> Done" diff --git a/scripts/sync_examples.sh b/scripts/sync_examples.sh deleted file mode 100755 index 3a98f9c..0000000 --- a/scripts/sync_examples.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env bash -# Sync the standalone agenticCLI-examples repo into dashscope/acli/examples. -# Examples are maintained in agenticCLI-examples; after modifying them there, -# run this script to vendor the updates here. -# Usage: scripts/sync_examples.sh [path-to-agenticCLI-examples] -set -euo pipefail - -SRC_REPO="${1:-/Users/zhansheng.lzs/ali/pro/ptm/agenticCLI-examples}" -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -DST="$REPO_ROOT/dashscope/acli/examples" - -if [ ! -d "$SRC_REPO" ]; then - echo "error: source not found: $SRC_REPO" >&2 - exit 1 -fi - -echo "==> Sync $SRC_REPO -> $DST" -rsync -a --delete \ - --exclude='.git' \ - --exclude='__pycache__' \ - --exclude='*.pyc' \ - --exclude='.DS_Store' \ - "$SRC_REPO/" "$DST/" - -echo "==> Check for internal references in synced examples" -if grep -rnE 'alibaba-inc|gitlab\.alibaba|code\.alibaba' "$DST"; then - echo "error: internal references found in examples (see above);" >&2 - echo " fix them in agenticCLI-examples before syncing" >&2 - exit 1 -fi - -echo "==> Done"