From 4255a3cbd2781ac209c7b7ee1ee5ad9b9577a10d Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Wed, 11 Nov 2026 15:27:24 +0800 Subject: [PATCH 1/5] refactor(cli): migrate agent runtime to dashscope-agenticx Replace the vendored acli runtime with the published AgenticX dependency while preserving both CLI entry points, one-shot prompts, interactive routing, and default Expert configuration. --- .gitignore | 2 +- MANIFEST.in | 1 - README.md | 8 +- README_zh.md | 8 +- dashscope/acli/__init__.py | 24 - dashscope/acli/__main__.py | 7 - dashscope/acli/acceptance.py | 355 -- dashscope/acli/agent.py | 1931 ---------- dashscope/acli/agents/__init__.py | 2 - dashscope/acli/agents/delegate.py | 379 -- dashscope/acli/agents/subagent.py | 317 -- dashscope/acli/agents/subagents.py | 357 -- dashscope/acli/audit.py | 252 -- dashscope/acli/capabilities/__init__.py | 102 - dashscope/acli/capabilities/local_memory.py | 92 - dashscope/acli/cli/__init__.py | 250 -- dashscope/acli/cli/completer.py | 316 -- dashscope/acli/cli/constants.py | 320 -- dashscope/acli/cli/dispatch.py | 560 --- dashscope/acli/cli/examples.py | 510 --- dashscope/acli/cli/handlers_capability.py | 306 -- dashscope/acli/cli/handlers_config.py | 850 ----- dashscope/acli/cli/handlers_device.py | 432 --- dashscope/acli/cli/handlers_key.py | 318 -- dashscope/acli/cli/handlers_misc.py | 328 -- dashscope/acli/cli/handlers_profile.py | 272 -- dashscope/acli/cli/handlers_provider.py | 357 -- dashscope/acli/cli/handlers_session.py | 213 -- dashscope/acli/cli/handlers_setup.py | 311 -- dashscope/acli/cli/handlers_skill.py | 373 -- dashscope/acli/cli/mcp.py | 179 - dashscope/acli/cli/multimodal.py | 279 -- dashscope/acli/cli/repl.py | 617 ---- dashscope/acli/cli/runners.py | 567 --- dashscope/acli/cli/startup.py | 197 - dashscope/acli/cli/streaming.py | 360 -- dashscope/acli/commands.py | 237 -- dashscope/acli/compression.py | 364 -- dashscope/acli/config.py | 906 ----- dashscope/acli/debuglog.py | 29 - dashscope/acli/deliverable.py | 171 - dashscope/acli/dev.py | 1294 ------- dashscope/acli/eval/__init__.py | 248 -- .../examples/basic-chat/.acli/config.toml | 4 - .../basic-chat/.acli/custom-extensions.toml | 201 -- .../acli/examples/basic-chat/.acli/hooks.toml | 101 - .../basic-chat/.acli/skills/explain-code.md | 17 - .../basic-chat/.acli/skills/research-topic.md | 19 - .../basic-chat/.acli/skills/translate.md | 15 - .../basic-chat/.acli/skills/write-poem.md | 11 - .../basic-chat/.acli/system-prompt.md | 26 - dashscope/acli/examples/basic-chat/.gitignore | 13 - dashscope/acli/examples/basic-chat/README.md | 112 - .../dashscope-sdk-expert/.acli/config.toml | 5 - .../.acli/custom-extensions.toml | 23 - .../dashscope-sdk-expert/.acli/hooks.toml | 25 - .../.acli/skills/agent.md | 161 - .../.acli/skills/api-doc.md | 19 - .../dashscope-sdk-expert/.acli/skills/cli.md | 102 - .../.acli/skills/diagnose.md | 15 - .../.acli/skills/error-code.md | 77 - .../.acli/skills/explain-code.md | 9 - .../.acli/skills/fine-tuning.md | 166 - .../.acli/skills/multimodal.md | 223 -- .../.acli/skills/retrieval.md | 243 -- .../.acli/skills/sdk-example.md | 20 - .../.acli/skills/speech.md | 201 -- .../.acli/skills/text-generation.md | 209 -- .../.acli/skills/translate.md | 9 - .../.acli/system-prompt.md | 116 - .../examples/dashscope-sdk-expert/.gitignore | 14 - .../examples/dashscope-sdk-expert/README.md | 117 - dashscope/acli/executor.py | 674 ---- dashscope/acli/expert.py | 143 - dashscope/acli/extensions.py | 1304 ------- dashscope/acli/hooks.py | 426 --- dashscope/acli/mcp_server.py | 169 - dashscope/acli/mcp_stdio.py | 313 -- dashscope/acli/memory/__init__.py | 2 - dashscope/acli/memory/directives_learning.py | 268 -- dashscope/acli/memory/experience.py | 175 - dashscope/acli/memory/manager.py | 172 - dashscope/acli/memory/plan.py | 111 - dashscope/acli/memory/reflection.py | 839 ----- dashscope/acli/memory/skill_evolution.py | 250 -- dashscope/acli/memory/tool_chains.py | 198 - dashscope/acli/memory/trace.py | 208 -- dashscope/acli/permission.py | 141 - dashscope/acli/platforms/__init__.py | 74 - dashscope/acli/platforms/bailian/__init__.py | 15 - dashscope/acli/platforms/bailian/cli.py | 178 - dashscope/acli/platforms/bailian/mcp.py | 303 -- dashscope/acli/platforms/base.py | 309 -- dashscope/acli/platforms/local/__init__.py | 6 - dashscope/acli/platforms/local/history.py | 319 -- dashscope/acli/platforms/local/memory.py | 235 -- dashscope/acli/prompt.py | 202 -- dashscope/acli/prompt_pipeline.py | 316 -- dashscope/acli/providers/__init__.py | 156 - dashscope/acli/providers/adapter.py | 331 -- dashscope/acli/providers/anthropic.py | 397 -- dashscope/acli/providers/base.py | 49 - dashscope/acli/providers/hardening.py | 302 -- dashscope/acli/providers/openai.py | 282 -- dashscope/acli/providers/profile.py | 307 -- dashscope/acli/providers/tongyi.py | 823 ----- dashscope/acli/runtime.py | 115 - dashscope/acli/sandbox.py | 181 - dashscope/acli/scheduler.py | 643 ---- dashscope/acli/sdk.py | 217 -- dashscope/acli/session.py | 566 --- dashscope/acli/session_events.py | 261 -- dashscope/acli/skills/__init__.py | 35 - dashscope/acli/skills/base.py | 271 -- dashscope/acli/skills/manager.py | 175 - dashscope/acli/skills/package.py | 253 -- dashscope/acli/skills/store.py | 589 --- dashscope/acli/tools/__init__.py | 6 - dashscope/acli/tools/browser.py | 494 --- dashscope/acli/tools/camera.py | 39 - dashscope/acli/tools/checkpoint.py | 220 -- dashscope/acli/tools/evolution.py | 83 - dashscope/acli/tools/filesystem.py | 437 --- dashscope/acli/tools/platform.py | 555 --- dashscope/acli/tools/registry.py | 497 --- dashscope/acli/tools/session.py | 412 --- dashscope/acli/tools/shell.py | 950 ----- dashscope/acli/tools/skills.py | 80 - dashscope/acli/tools/vision.py | 674 ---- dashscope/acli/tools/web_search.py | 193 - dashscope/acli/ui/__init__.py | 2 - dashscope/acli/ui/camera.py | 208 -- dashscope/acli/ui/embedded.py | 131 - dashscope/acli/ui/tts.py | 662 ---- dashscope/acli/ui/tui.py | 3182 ----------------- dashscope/acli/ui/voice.py | 483 --- dashscope/acli/utils/__init__.py | 98 - dashscope/acli/utils/clipboard.py | 43 - dashscope/acli/utils/crypto.py | 67 - dashscope/acli/utils/exceptions.py | 19 - dashscope/acli/utils/ids.py | 37 - dashscope/acli/utils/images.py | 31 - dashscope/acli/utils/keywords.py | 52 - dashscope/acli/utils/messages.py | 200 -- dashscope/acli/utils/paths.py | 142 - dashscope/acli/utils/sanitizer.py | 104 - dashscope/acli/utils/spinner.py | 108 - dashscope/acli/utils/template.py | 31 - dashscope/acli/utils/text.py | 140 - dashscope/acli/utils/toml.py | 111 - dashscope/acli/utils/validation.py | 116 - dashscope/cli/app.py | 4 +- dashscope/cli/main.py | 10 +- dashscope/expert.py | 98 + dashscope/legacy_1x/cli/__init__.py | 6 +- .../mcp_server_demo/README.md | 3 +- .../mcp_server_demo/server.py | 5 +- examples/README.md | 2 +- examples/README_zh.md | 2 +- requirements-test.txt | 5 +- setup.py | 14 +- tests/cli/test_dispatch.py | 4 +- tests/legacy_1x/unit/conftest.py | 18 - tests/legacy_1x/unit/test_acli_acceptance.py | 645 ---- .../unit/test_acli_command_timeout.py | 270 -- .../legacy_1x/unit/test_acli_command_trust.py | 297 -- .../unit/test_acli_provider_retries.py | 240 -- tests/legacy_1x/unit/test_acli_reflection.py | 775 ---- .../legacy_1x/unit/test_acli_sdk_contract.py | 376 -- .../legacy_1x/unit/test_acli_skill_render.py | 143 - .../legacy_1x/unit/test_acli_usage_report.py | 277 -- tests/legacy_1x/unit/test_acli_vision_tool.py | 235 -- .../unit/test_agenticx_sdk_contract.py | 95 + tests/legacy_1x/unit/test_banner_guide.py | 59 - tests/legacy_1x/unit/test_cli_agent_route.py | 211 +- .../legacy_1x/unit/test_handlers_key_docs.py | 71 - tests/legacy_1x/unit/test_sdk_headers.py | 75 - tests/legacy_1x/unit/test_tongyi_native.py | 836 ----- tests/protocols/test_anthropic_conformance.py | 191 +- tests/protocols/test_openai_conformance.py | 143 +- tests/test_layer_decoupling.py | 4 - 181 files changed, 428 insertions(+), 45870 deletions(-) delete mode 100644 dashscope/acli/__init__.py delete mode 100644 dashscope/acli/__main__.py delete mode 100644 dashscope/acli/acceptance.py delete mode 100644 dashscope/acli/agent.py delete mode 100644 dashscope/acli/agents/__init__.py delete mode 100644 dashscope/acli/agents/delegate.py delete mode 100644 dashscope/acli/agents/subagent.py delete mode 100644 dashscope/acli/agents/subagents.py delete mode 100644 dashscope/acli/audit.py delete mode 100644 dashscope/acli/capabilities/__init__.py delete mode 100644 dashscope/acli/capabilities/local_memory.py delete mode 100644 dashscope/acli/cli/__init__.py delete mode 100644 dashscope/acli/cli/completer.py delete mode 100644 dashscope/acli/cli/constants.py delete mode 100644 dashscope/acli/cli/dispatch.py delete mode 100644 dashscope/acli/cli/examples.py delete mode 100644 dashscope/acli/cli/handlers_capability.py delete mode 100644 dashscope/acli/cli/handlers_config.py delete mode 100644 dashscope/acli/cli/handlers_device.py delete mode 100644 dashscope/acli/cli/handlers_key.py delete mode 100644 dashscope/acli/cli/handlers_misc.py delete mode 100644 dashscope/acli/cli/handlers_profile.py delete mode 100644 dashscope/acli/cli/handlers_provider.py delete mode 100644 dashscope/acli/cli/handlers_session.py delete mode 100644 dashscope/acli/cli/handlers_setup.py delete mode 100644 dashscope/acli/cli/handlers_skill.py delete mode 100644 dashscope/acli/cli/mcp.py delete mode 100644 dashscope/acli/cli/multimodal.py delete mode 100644 dashscope/acli/cli/repl.py delete mode 100644 dashscope/acli/cli/runners.py delete mode 100644 dashscope/acli/cli/startup.py delete mode 100644 dashscope/acli/cli/streaming.py delete mode 100644 dashscope/acli/commands.py delete mode 100644 dashscope/acli/compression.py delete mode 100644 dashscope/acli/config.py delete mode 100644 dashscope/acli/debuglog.py delete mode 100644 dashscope/acli/deliverable.py delete mode 100644 dashscope/acli/dev.py delete mode 100644 dashscope/acli/eval/__init__.py delete mode 100644 dashscope/acli/examples/basic-chat/.acli/config.toml delete mode 100644 dashscope/acli/examples/basic-chat/.acli/custom-extensions.toml delete mode 100644 dashscope/acli/examples/basic-chat/.acli/hooks.toml delete mode 100644 dashscope/acli/examples/basic-chat/.acli/skills/explain-code.md delete mode 100644 dashscope/acli/examples/basic-chat/.acli/skills/research-topic.md delete mode 100644 dashscope/acli/examples/basic-chat/.acli/skills/translate.md delete mode 100644 dashscope/acli/examples/basic-chat/.acli/skills/write-poem.md delete mode 100644 dashscope/acli/examples/basic-chat/.acli/system-prompt.md delete mode 100644 dashscope/acli/examples/basic-chat/.gitignore delete mode 100644 dashscope/acli/examples/basic-chat/README.md delete mode 100644 dashscope/acli/examples/dashscope-sdk-expert/.acli/config.toml delete mode 100644 dashscope/acli/examples/dashscope-sdk-expert/.acli/custom-extensions.toml delete mode 100644 dashscope/acli/examples/dashscope-sdk-expert/.acli/hooks.toml delete mode 100644 dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/agent.md delete mode 100644 dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/api-doc.md delete mode 100644 dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/cli.md delete mode 100644 dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/diagnose.md delete mode 100644 dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/error-code.md delete mode 100644 dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/explain-code.md delete mode 100644 dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/fine-tuning.md delete mode 100644 dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/multimodal.md delete mode 100644 dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/retrieval.md delete mode 100644 dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/sdk-example.md delete mode 100644 dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/speech.md delete mode 100644 dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/text-generation.md delete mode 100644 dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/translate.md delete mode 100644 dashscope/acli/examples/dashscope-sdk-expert/.acli/system-prompt.md delete mode 100644 dashscope/acli/examples/dashscope-sdk-expert/.gitignore delete mode 100644 dashscope/acli/examples/dashscope-sdk-expert/README.md delete mode 100644 dashscope/acli/executor.py delete mode 100644 dashscope/acli/expert.py delete mode 100644 dashscope/acli/extensions.py delete mode 100644 dashscope/acli/hooks.py delete mode 100644 dashscope/acli/mcp_server.py delete mode 100644 dashscope/acli/mcp_stdio.py delete mode 100644 dashscope/acli/memory/__init__.py delete mode 100644 dashscope/acli/memory/directives_learning.py delete mode 100644 dashscope/acli/memory/experience.py delete mode 100644 dashscope/acli/memory/manager.py delete mode 100644 dashscope/acli/memory/plan.py delete mode 100644 dashscope/acli/memory/reflection.py delete mode 100644 dashscope/acli/memory/skill_evolution.py delete mode 100644 dashscope/acli/memory/tool_chains.py delete mode 100644 dashscope/acli/memory/trace.py delete mode 100644 dashscope/acli/permission.py delete mode 100644 dashscope/acli/platforms/__init__.py delete mode 100644 dashscope/acli/platforms/bailian/__init__.py delete mode 100644 dashscope/acli/platforms/bailian/cli.py delete mode 100644 dashscope/acli/platforms/bailian/mcp.py delete mode 100644 dashscope/acli/platforms/base.py delete mode 100644 dashscope/acli/platforms/local/__init__.py delete mode 100644 dashscope/acli/platforms/local/history.py delete mode 100644 dashscope/acli/platforms/local/memory.py delete mode 100644 dashscope/acli/prompt.py delete mode 100644 dashscope/acli/prompt_pipeline.py delete mode 100644 dashscope/acli/providers/__init__.py delete mode 100644 dashscope/acli/providers/adapter.py delete mode 100644 dashscope/acli/providers/anthropic.py delete mode 100644 dashscope/acli/providers/base.py delete mode 100644 dashscope/acli/providers/hardening.py delete mode 100644 dashscope/acli/providers/openai.py delete mode 100644 dashscope/acli/providers/profile.py delete mode 100644 dashscope/acli/providers/tongyi.py delete mode 100644 dashscope/acli/runtime.py delete mode 100644 dashscope/acli/sandbox.py delete mode 100644 dashscope/acli/scheduler.py delete mode 100644 dashscope/acli/sdk.py delete mode 100644 dashscope/acli/session.py delete mode 100644 dashscope/acli/session_events.py delete mode 100644 dashscope/acli/skills/__init__.py delete mode 100644 dashscope/acli/skills/base.py delete mode 100644 dashscope/acli/skills/manager.py delete mode 100644 dashscope/acli/skills/package.py delete mode 100644 dashscope/acli/skills/store.py delete mode 100644 dashscope/acli/tools/__init__.py delete mode 100644 dashscope/acli/tools/browser.py delete mode 100644 dashscope/acli/tools/camera.py delete mode 100644 dashscope/acli/tools/checkpoint.py delete mode 100644 dashscope/acli/tools/evolution.py delete mode 100644 dashscope/acli/tools/filesystem.py delete mode 100644 dashscope/acli/tools/platform.py delete mode 100644 dashscope/acli/tools/registry.py delete mode 100644 dashscope/acli/tools/session.py delete mode 100644 dashscope/acli/tools/shell.py delete mode 100644 dashscope/acli/tools/skills.py delete mode 100644 dashscope/acli/tools/vision.py delete mode 100644 dashscope/acli/tools/web_search.py delete mode 100644 dashscope/acli/ui/__init__.py delete mode 100644 dashscope/acli/ui/camera.py delete mode 100644 dashscope/acli/ui/embedded.py delete mode 100644 dashscope/acli/ui/tts.py delete mode 100644 dashscope/acli/ui/tui.py delete mode 100644 dashscope/acli/ui/voice.py delete mode 100644 dashscope/acli/utils/__init__.py delete mode 100644 dashscope/acli/utils/clipboard.py delete mode 100644 dashscope/acli/utils/crypto.py delete mode 100644 dashscope/acli/utils/exceptions.py delete mode 100644 dashscope/acli/utils/ids.py delete mode 100644 dashscope/acli/utils/images.py delete mode 100644 dashscope/acli/utils/keywords.py delete mode 100644 dashscope/acli/utils/messages.py delete mode 100644 dashscope/acli/utils/paths.py delete mode 100644 dashscope/acli/utils/sanitizer.py delete mode 100644 dashscope/acli/utils/spinner.py delete mode 100644 dashscope/acli/utils/template.py delete mode 100644 dashscope/acli/utils/text.py delete mode 100644 dashscope/acli/utils/toml.py delete mode 100644 dashscope/acli/utils/validation.py create mode 100644 dashscope/expert.py delete mode 100644 tests/legacy_1x/unit/test_acli_acceptance.py delete mode 100644 tests/legacy_1x/unit/test_acli_command_timeout.py delete mode 100644 tests/legacy_1x/unit/test_acli_command_trust.py delete mode 100644 tests/legacy_1x/unit/test_acli_provider_retries.py delete mode 100644 tests/legacy_1x/unit/test_acli_reflection.py delete mode 100644 tests/legacy_1x/unit/test_acli_sdk_contract.py delete mode 100644 tests/legacy_1x/unit/test_acli_skill_render.py delete mode 100644 tests/legacy_1x/unit/test_acli_usage_report.py delete mode 100644 tests/legacy_1x/unit/test_acli_vision_tool.py create mode 100644 tests/legacy_1x/unit/test_agenticx_sdk_contract.py delete mode 100644 tests/legacy_1x/unit/test_banner_guide.py delete mode 100644 tests/legacy_1x/unit/test_handlers_key_docs.py delete mode 100644 tests/legacy_1x/unit/test_tongyi_native.py diff --git a/.gitignore b/.gitignore index 3c201c86..d819b762 100644 --- a/.gitignore +++ b/.gitignore @@ -137,7 +137,7 @@ cython_debug/ # PyPI configuration file .pypirc -/.acli/ +/.agenticx/ .qoder/ diff --git a/MANIFEST.in b/MANIFEST.in index 6712512a..3d855be6 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,6 +3,5 @@ include dashscope/py.typed include dashscope/__init__.pyi include NOTICE include LICENSE.certifi -recursive-include dashscope/acli/examples *.md *.toml *.py recursive-include examples *.md *.py include requirements.txt diff --git a/README.md b/README.md index df7b550a..6d50ebcf 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ The DashScope Python SDK is the official client for [Alibaba Cloud Model Studio pip install -U dashscope ``` -The base install covers SDK API calls, the `dashscope` CLI command, Agentic RL fine-tuning and the local tokenizer. Python 3.9+ is required. Optional feature groups all belong to the bundled AI assistant: +The base install covers SDK API calls, the `dashscope` CLI command, Agentic RL fine-tuning and the local tokenizer. Python 3.9+ is required. Optional feature groups configure the AI assistant provided by `dashscope-agenticx`; the assistant requires Python 3.10+: | Extra | Provides | Install | |-------|----------|---------| @@ -511,11 +511,11 @@ For the latest model list, visit the [Model Studio model plaza](https://bailian. ## AI Assistant: DashScope SDK Expert -The SDK ships with an interactive AI assistant, **DashScope SDK Expert**, built on the bundled Agentic CLI (`dashscope/acli`). It answers SDK/API questions, generates runnable examples, shows CLI usage, and diagnoses errors right in your terminal. +The SDK integrates an interactive AI assistant, **DashScope SDK Expert**, powered by the optional `dashscope-agenticx` dependency. It answers SDK/API questions, generates runnable examples, shows CLI usage, and diagnoses errors right in your terminal. -- Run `dashscope` with no arguments to start the assistant. On first run it offers to install the knowledge pack (per-domain quick-reference skills: text, multimodal, speech, retrieval, fine-tuning, agent, cli), so guidance comes from the SDK's public interfaces — parameters, outputs, error codes — without reading the source. +- Run `dashscope` with no arguments to start the assistant. On first run it offers to download the Expert profile and per-domain skills into `./.agenticx/`; the same profile can be installed later with `agenticx expert download dashscope-sdk-expert`. - Ask it instead of reading docs — e.g. `dashscope expert "how do I stream chat output"` (asks once and exits). Type `/help` inside the assistant for available commands; `dashscope expert --help` shows all forms. -- Install with the `acli` extra: `pip install "dashscope[acli]"`. Add `acli-voice` / `acli-camera` for voice and camera input, or `acli-all` for everything — see the [extras table](#installation). +- With Python 3.10+, install the `acli` extra: `pip install "dashscope[acli]"`. Add `acli-voice` / `acli-camera` for voice and camera input, or `acli-all` for everything — see the [extras table](#installation). - Full walkthrough: [DashScope SDK Expert guide](https://help.aliyun.com/en/model-studio/dashscope-sdk-expert). ## Command line diff --git a/README_zh.md b/README_zh.md index 6635b5c7..3f68189f 100644 --- a/README_zh.md +++ b/README_zh.md @@ -26,7 +26,7 @@ DashScope Python SDK 是[阿里云百炼(Model Studio)](https://help.aliyun. pip install -U dashscope ``` -基础安装包含 SDK API 调用、`dashscope` CLI 命令、Agentic RL 微调和本地 tokenizer,要求 Python 3.9+。可选功能组都属于内置的 AI 助手: +基础安装包含 SDK API 调用、`dashscope` CLI 命令、Agentic RL 微调和本地 tokenizer,要求 Python 3.9+。可选功能组用于配置由 `dashscope-agenticx` 提供的 AI 助手;助手要求 Python 3.10+: | Extra | 提供的能力 | 安装命令 | |-------|----------|---------| @@ -511,11 +511,11 @@ connection.close() ## AI 助手:DashScope SDK Expert -SDK 内置了交互式 AI 助手 **DashScope SDK Expert**,基于随包提供的 Agentic CLI(`dashscope/acli`)框架构建。它能直接在终端中解答 SDK/API 问题、生成可运行示例、展示 CLI 用法、诊断错误。 +SDK 集成了交互式 AI 助手 **DashScope SDK Expert**,由可选依赖 `dashscope-agenticx` 提供。它能直接在终端中解答 SDK/API 问题、生成可运行示例、展示 CLI 用法、诊断错误。 -- 直接运行 `dashscope`(不带参数)即可启动助手。首次运行时会提示安装知识包(按领域划分的速查技能:文本、多模态、语音、检索、微调、Agent、CLI),使助手的指导来自 SDK 的公开接口 —— 参数、输出、错误码 —— 而无需阅读源码。 +- 直接运行 `dashscope`(不带参数)即可启动助手。首次运行时会提示将 Expert 配置和按领域划分的 Skills 下载到 `./.agenticx/`;之后也可以运行 `agenticx expert download dashscope-sdk-expert` 安装。 - 直接提问代替翻文档 —— 如 `dashscope expert "如何流式输出对话结果"`(一次性提问后退出)。在助手内输入 `/help` 查看可用命令;`dashscope expert --help` 查看全部形式。 -- 通过 `acli` extra 安装:`pip install "dashscope[acli]"`。需要语音/摄像头输入可加装 `acli-voice` / `acli-camera`,或直接用 `acli-all` 装齐 —— 详见[安装](#安装)一节的 extra 表格。 +- 使用 Python 3.10+ 时,通过 `acli` extra 安装:`pip install "dashscope[acli]"`。需要语音/摄像头输入可加装 `acli-voice` / `acli-camera`,或直接用 `acli-all` 装齐 —— 详见[安装](#安装)一节的 extra 表格。 - 完整使用指南:[DashScope SDK Expert 文档](https://help.aliyun.com/zh/model-studio/dashscope-sdk-expert)。 ## 命令行 diff --git a/dashscope/acli/__init__.py b/dashscope/acli/__init__.py deleted file mode 100644 index 9fe87651..00000000 --- a/dashscope/acli/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import annotations - -import uuid - -__version__ = "0.7.2" - -# Per-process identifier sent as x-dashscope-sdk-session-id so the -# backend can group multi-turn requests from one CLI run. -SDK_SESSION_ID = uuid.uuid4().hex - -# Expose the lightweight programmatic SDK at the package root. -try: - 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 - run_once = None # type: ignore - run_once_sync = None # type: ignore diff --git a/dashscope/acli/__main__.py b/dashscope/acli/__main__.py deleted file mode 100644 index e9340e14..00000000 --- a/dashscope/acli/__main__.py +++ /dev/null @@ -1,7 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import annotations - -from dashscope.acli.cli import main - -if __name__ == "__main__": - main() diff --git a/dashscope/acli/acceptance.py b/dashscope/acli/acceptance.py deleted file mode 100644 index fac3d8a4..00000000 --- a/dashscope/acli/acceptance.py +++ /dev/null @@ -1,355 +0,0 @@ -# -*- coding: utf-8 -*- -"""Hard acceptance gate for autonomous (oneshot) runs. - -The system prompt already asks the model to verify its work before reporting -completion, and the model already ignores that. Aider's ``--auto-test`` and -Agentless' three-stage filter make verification a property of the loop rather -than a request: a non-zero exit means the answer is not accepted yet. -OpenHands shipping the advice without the gate is the counterexample. - -Only oneshot runs are gated. Interactively the user *is* the acceptance -criterion and is reading the output, so spending minutes on a test suite -before every reply costs more than the near-miss it prevents. -""" - -from __future__ import annotations - -import asyncio -import os -import shlex -import sys -from dataclasses import dataclass -from pathlib import Path - -from dashscope.acli.utils.text import markdown_fence, truncate_head_tail - -# Quoted verbatim into the next prompt, so: large enough to contain the -# failing test id and its assertion, small enough to read. Tail-biased, -# because that is where pytest and cargo put the verdict. -_OUTPUT_CHARS = 3000 - -DEFAULT_RETRIES = 1 -DEFAULT_TIMEOUT = 600 - -EXPLICIT = "explicit" -DISCOVERED = "discovered" - -# Exit statuses meaning the check never reached the code under test, so its -# output describes the environment rather than the change. 127 is the shell's -# command-not-found; 2/3/4/5 are pytest's interrupted (also what a collection -# error such as a missing dependency reports), internal-error, usage-error and -# nothing-collected. pytest's 1 is deliberately absent: tests ran and one -# failed, which is the verdict this whole module exists to act on. -_NOT_FOUND = 127 -_PYTEST_NEVER_RAN = frozenset({2, 3, 4, 5}) - - -def _never_ran(run: AcceptanceRun) -> bool: - """True when ``run``'s exit status shows the check could not execute.""" - if run.exit_code == _NOT_FOUND: - return True - return "pytest" in run.command and run.exit_code in _PYTEST_NEVER_RAN - - -def _env_int(name: str, default: int) -> int: - try: - return int(os.environ.get(name, "") or default) - except ValueError: - return default - - -def _env_flag(name: str, default: bool) -> bool: - raw = os.environ.get(name) - if raw is None or not raw.strip(): - return default - return raw.strip().lower() not in ("0", "false", "no", "off") - - -def _read_head(path: Path, limit: int = 65536) -> str: - try: - with path.open("r", encoding="utf-8", errors="replace") as fh: - return fh.read(limit) - except OSError: - return "" - - -def _is_pytest_project(root: Path) -> bool: - if (root / "pytest.ini").is_file(): - return True - for name, marker in ( - ("pyproject.toml", "[tool.pytest"), - ("setup.cfg", "[tool:pytest]"), - ("tox.ini", "[pytest]"), - ): - path = root / name - if path.is_file() and marker in _read_head(path): - return True - # Most Python repos never declare pytest config at all. A tests/ directory - # beside a project file is pytest's default layout, and is what bare - # `pytest` collects. - return (root / "tests").is_dir() and (root / "pyproject.toml").is_file() - - -def discover_acceptance_command( - cwd: str | os.PathLike[str] | None = None, -) -> str | None: - """The conventional test entry point for the project at ``cwd``, if any. - - The allowlist is deliberately short. Every entry runs code from the - repository — ``conftest.py`` at collection time, ``build.rs``, ``TestMain`` - — so "conservative" here means "a standard test entry point for an - ecosystem whose manifest is sitting right here", not "harmless". - ``npm test`` and ``make test`` are excluded because their bodies are - arbitrary user scripts with no conventional meaning; declaring them via - ``ACLI_ACCEPTANCE_CMD`` is how you ask for those. - """ - root = Path(cwd or os.getcwd()) - if not root.is_dir(): - return None - if _is_pytest_project(root): - # This interpreter, not a bare `pytest`: the gate must run against the - # same environment the agent's own run_command calls use. - return f"{shlex.quote(sys.executable)} -m pytest -q" - if (root / "Cargo.toml").is_file(): - return "cargo test --quiet" - if (root / "go.mod").is_file(): - return "go test ./..." - return None - - -@dataclass(frozen=True) -class AcceptanceRun: - """One execution of the acceptance check.""" - - command: str - source: str - ok: bool - exit_code: int | None - output: str - - -@dataclass(frozen=True) -class GateVerdict: - """What the loop should do about a check that did not pass. - - At most one of ``rejection`` and ``note`` is non-empty. ``rejection`` - withholds the answer and sends the model back with the raw output; - ``note`` is appended to the final answer when sending it back would not - help. - """ - - run: AcceptanceRun - rejection: str = "" - note: str = "" - - -class AcceptanceGate: - """Runs the acceptance check and decides whether the answer stands. - - Two runs per turn at most: a baseline captured before the first mutation, - and the check at the loop's exit. The baseline is what turns "the suite is - red" into "you made it red" — Agentless reproduces the issue on the - unpatched repository for the same reason, and AlphaCodium's test anchors - are the same obligation stated positively. - """ - - def __init__( - self, - command: str = "", - source: str = EXPLICIT, - retries: int = DEFAULT_RETRIES, - timeout: int = DEFAULT_TIMEOUT, - ): - self.command = command - self.source = source - self.retries = retries - self.timeout = timeout - self.baseline: AcceptanceRun | None = None - self.attempts = 0 - self._baseline_task: asyncio.Task | None = None - - @classmethod - def from_env(cls, oneshot: bool) -> AcceptanceGate: - """Build the gate from the ``ACLI_ACCEPTANCE*`` environment variables. - - An explicit command wins over discovery. Setting ``ACLI_ACCEPTANCE=0`` - turns the gate off, which is what a benchmark measuring raw model - output wants. - """ - if not (oneshot and _env_flag("ACLI_ACCEPTANCE", True)): - return cls() - command = os.environ.get("ACLI_ACCEPTANCE_CMD", "").strip() - source = EXPLICIT - if not command: - command = discover_acceptance_command() or "" - source = DISCOVERED - return cls( - command=command, - source=source, - retries=_env_int("ACLI_ACCEPTANCE_RETRIES", DEFAULT_RETRIES), - timeout=_env_int("ACLI_ACCEPTANCE_TIMEOUT", DEFAULT_TIMEOUT), - ) - - @property - def active(self) -> bool: - """An empty command is the off switch.""" - return bool(self.command) - - def reset(self) -> None: - """Drop the previous turn's anchor and retry budget. - - The baseline has to come from the workspace as *this* turn found it, - and the retry budget is per-turn so one slow turn cannot spend the - next one's. An in-flight capture is cancelled for the same reason: - left running, it would write the previous turn's verdict into this - turn's ``baseline``. - """ - if self._baseline_task is not None and not self._baseline_task.done(): - self._baseline_task.cancel() - self._baseline_task = None - self.baseline = None - self.attempts = 0 - - async def ensure_baseline(self) -> None: - """Capture the pre-change verdict, once per turn. - - Called before the first mutating tool call rather than at turn start: - a read-only or pure-answer turn never pays for a test suite, and the - baseline still comes from a workspace the agent has not touched. - - Concurrent callers share one task rather than checking a flag. The - flag form lets the second mutating call in a parallel batch proceed - while the baseline is still running, so the "pre-change" verdict would - be captured against a workspace that had already changed. - """ - if not self.active: - return - if self._baseline_task is None: - self._baseline_task = asyncio.ensure_future(self._capture()) - await self._baseline_task - - async def _capture(self) -> None: - self.baseline = await self._run() - - async def check(self) -> GateVerdict | None: - """Run the check at the loop's exit and rule on the answer. - - ``None`` means "not applicable" — gate off, no command found, or - nothing was mutated this turn, in which case there is no work to - accept. - """ - if not self.active or self.baseline is None: - return None - run = await self._run() - self.attempts += 1 - if run.ok: - return GateVerdict(run) - if _never_ran(run) and self._baseline_never_ran(): - return GateVerdict(run, note=self._unrunnable_note(run)) - if self._unchanged_since_baseline(run): - return GateVerdict(run, note=self._insensitive_note(run)) - if self.attempts > self.retries: - return GateVerdict(run, note=self._unverified_note(run)) - return GateVerdict(run, rejection=self._rejection(run)) - - def _baseline_never_ran(self) -> bool: - """True when the pre-change check could not execute either. - - This is what separates a broken environment from a broken change. The - baseline is captured before the agent touches anything, so a check that - already could not run then was not made unrunnable by the change. - Requiring it also keeps the sharp edge: a *green* baseline proves the - check does run here, so a collection error the agent introduced stays - on the rejection path instead of being excused. - """ - base = self.baseline - return bool(base and not base.ok and _never_ran(base)) - - def _unchanged_since_baseline(self, run: AcceptanceRun) -> bool: - """True when the check came out byte-identical to a failing baseline. - - The catch-all behind ``_baseline_never_ran``, for a check whose exit - statuses this module does not know — cargo, go, an immediate timeout — - where reproducing the baseline exactly is the only evidence available - that the break is environmental. Sending the model back to fix that - burns the turn on a problem it neither created nor can solve, so the - gate reports it instead of rejecting. - - Byte-identical is the point: a suite that genuinely fails prints - timings, so real failures differ between runs and still get rejected. - Only a stable, environment-level break reproduces exactly. - """ - base = self.baseline - return bool( - base - and not base.ok - and base.exit_code == run.exit_code - and base.output == run.output, - ) - - async def _run(self) -> AcceptanceRun: - # run_command rather than a raw subprocess: it carries the - # blocked-pattern checks, the optional OS sandbox, the output caps, and - # the exit-code trailer that the rest of the loop already classifies. - from dashscope.acli.audit import get_audit_logger - from dashscope.acli.executor import classify_tool_text - from dashscope.acli.tools.shell import run_command - - get_audit_logger().log_tool_call( - "acceptance_gate", - {"command": self.command}, - decision="allowed", - reason=f"source: {self.source}", - ) - text = await run_command(command=self.command, timeout=self.timeout) - outcome = classify_tool_text(text, {"command": self.command}) - return AcceptanceRun( - command=self.command, - source=self.source, - ok=outcome.ok, - exit_code=outcome.exit_code, - output=outcome.text, - ) - - def _rejection(self, run: AcceptanceRun) -> str: - quoted = truncate_head_tail(run.output, _OUTPUT_CHARS, ratio=0.25) - fence = markdown_fence(quoted) - if self.baseline and self.baseline.ok: - verdict = ( - "a REGRESSION: it passed before anything in this turn " - "changed, so the change broke it" - ) - else: - verdict = "still failing" - return ( - "\n## ⛔ Not accepted: the verification check is " - f"{verdict}\n" - f"`{run.command}` exited {run.exit_code}.\n" - f"{fence}\n{quoted}\n{fence}\n" - "Your answer is being withheld, not discarded: fix what this " - "output shows and the turn ends by itself. Work from the output " - "above — do not restate your plan, and do not declare the check " - "wrong unless this output says so.\n" - ) - - def _unverified_note(self, run: AcceptanceRun) -> str: - return ( - f"\n[Unverified: `{run.command}` exited {run.exit_code} and the " - f"retry budget ({self.retries}) is spent, so the answer below is " - "reported as-is rather than withheld.]\n" - ) - - def _unrunnable_note(self, run: AcceptanceRun) -> str: - return ( - f"\n[Unverified: `{run.command}` exited {run.exit_code} before " - "and after these changes, a status that means it never reached " - "the tests. The check does not run in this environment, so it " - "says nothing about the answer below.]\n" - ) - - def _insensitive_note(self, run: AcceptanceRun) -> str: - return ( - f"\n[Unverified: `{run.command}` failed identically before and " - "after these changes, so it says nothing about them — most likely " - "the check itself does not run in this environment.]\n" - ) diff --git a/dashscope/acli/agent.py b/dashscope/acli/agent.py deleted file mode 100644 index 5bcd8171..00000000 --- a/dashscope/acli/agent.py +++ /dev/null @@ -1,1931 +0,0 @@ -# -*- coding: utf-8 -*- -# pylint: disable=too-many-branches,too-many-statements,protected-access -from __future__ import annotations - -import asyncio -import json -import os -import time -from pathlib import Path -from typing import AsyncIterator - -from rich.console import Console - -from dashscope.acli.acceptance import AcceptanceGate -from dashscope.acli.compression import ( - HARD_THRESHOLD_RATIO, - estimate_message_tokens, - estimate_tokens, - restore_note, - safety_compress_if_needed, - shrink_old_tool_messages, -) -from dashscope.acli.config import WORKSPACE_DIR, context_window_for_model -from dashscope.acli.executor import Executor -from dashscope.acli.hooks import HookBus, HookContext, create_hook_bus -from dashscope.acli.memory.manager import MemoryManager -from dashscope.acli.memory.reflection import ( - compression_hint, - convergence_hint, - is_readonly_tool_call, - time_budget_hint, -) -from dashscope.acli.platforms.base import MemoryProvider -from dashscope.acli.prompt_pipeline import PromptContext, default_pipeline -from dashscope.acli.providers.base import LLMProvider, LLMResponse, ToolCall -from dashscope.acli.providers.hardening import ( - RetriesExhausted, - is_retryable_error, -) -from dashscope.acli.skills import get_skill_manager, skills_summary_for_llm -from dashscope.acli.tools.registry import PermissionLevel, registry -from dashscope.acli.utils import ( - UserAbortedTurn, - UserSupplement, - normalize_for_model, - sanitize, - sanitize_text, - text_of, - tool_result_for_display, - tool_result_for_history, - truncate, -) - -console = Console() - - -def _classify_outcome(successes: int, failures: int) -> str: - """Classify a turn's outcome from its tool execution counters. - - User cancellation is neutral (counted neither way), so a turn where - everything was cancelled lands on "partial" rather than "success". - """ - if failures == 0 and successes > 0: - return "success" - if failures > 0 and successes == 0: - return "failure" - return "partial" - - -def _tool_message(tool_call: ToolCall, result: str) -> dict: - """Build the history entry answering ``tool_call``. - - Redaction happens here, ahead of truncation. ``save_session`` already ran - ``sanitize`` over the on-disk copy, so before this a key read out of the - environment was scrubbed from the session file and still handed to the - model in full — the persistence path was protecting the wrong reader. - Scrubbing after truncation would not work either: ``truncate_head_tail`` - cuts on a character budget and can split a token in half, leaving a - fragment that matches no pattern. - - The restore note must be attached here for the same reason: later - shrinking skips a result that already carries an omission marker, so the - largest results would end up with no way to be read back. - """ - return { - "role": "tool", - "content": tool_result_for_history( - sanitize_text(result), - note=restore_note(tool_call.name, tool_call.arguments), - ), - "name": tool_call.name, - "tool_use_id": tool_call.id, - "tool_call_id": tool_call.id, - } - - -def _budget_burned_slowly(error: BaseException) -> bool: - """True when the provider stack already spent real wall clock on this turn. - - ``RetriesExhausted`` on its own is not the signal it looks like. Raised - after a stalled endpoint it means ``max_retries x request_timeout`` went - into attempts that each timed out (268s measured), and re-attempting here - multiplies that — the 819s of silent wall clock the turn retry was - removed for. Raised after a refused connection it means only the ~28s of - backoff was spent, and re-attempting is cheap and is exactly what rides - out a transient blip. Matching on the type alone gives up in both cases; - the elapsed seconds the exception carries tell them apart. - """ - return isinstance(error, RetriesExhausted) and error.exhausted_slowly - - -SYSTEM_PROMPT = """You are acli, an intelligent command-line assistant. -The user states what they need and you execute it — never make the user -run commands themselves. - -Core principle: **the user speaks, you act.** You have full tool access to -fulfill user requests; call tools directly instead of suggesting slash -commands. - -Rules: -1. Only use tools that exist in the tool list; never guess or invent tool names -2. Only use file paths inside the current working directory, paths explicitly - given by the user, or paths relative to the CWD. Never invent, guess, or - reuse paths seen in training data (e.g. /Users/xxx/...) -3. For clear-intent requests (git commit, read file, search), call tools - directly without stating a plan first. For code changes, go straight to - the edit — but you must verify it afterwards (rule 17). Only for - irreversible operations (delete, overwrite, force push) or complex - multi-step tasks, briefly explain and wait for user confirmation -4. When a tool call fails, do not retry the same call; report the error to - the user with a suggestion -5. Tools prefixed [MCP:xxx] come from Bailian MCP services; call them directly -6. If the "user profile" contains info, personalize answers with it (e.g. - recommend Python solutions for a Python user) -7. When a request matches an "available Skill template" below, prefer that - flow — if the Skill's MCP is connected, call its tools directly; if not, - connect first with mcp_connect, then call -8. When a question may involve personal preferences, tech stack, or work - environment, proactively search the profile with memory_search for context -9. If no suitable tool can complete the task, answer from your knowledge; - never call a nonexistent tool -10. To switch model/Provider → call switch_model or switch_provider directly -11. To use cloud services (time, code execution, doc parsing, etc.) → call - mcp_connect directly -12. To enable/disable capabilities → call capability_enable / - capability_disable directly -13. Never reply "please use the /xxx command" — if you have a tool that can - do it, just do it -14. **Do not use run_command with python3 -c inline scripts to read/write - files** — it is inefficient. Read with read_file, write with write_file; - shell tools like sed/grep are fine to use -15. **If a question can be answered directly (no files, commands, or external - info needed), answer from your knowledge without calling tools** — e.g. - self-introduction, explaining concepts, translating text, small talk -16. **Multiple independent subtasks (e.g. "look at these 2 files", several - unrelated tasks at once) → call delegate_parallel to fan out subagents**, - or send multiple delegate calls in the same turn; a single independent - but lengthy subtask (whole-file review, multi-file scan) → call - subagent_invoke for isolated execution and take back only the conclusion. - Do not grind through them serially yourself -17. **Verify code changes before reporting done.** Run the tests that cover - what you touched; if nothing covers it, add a focused test for the new - behaviour and run that. Report the command and its pass/fail result — - never claim a change works without having executed it. When the output - transforms or joins data (ETL, CSV/JSON, migrations), also assert every - cross-reference resolves — schema-valid output with dangling IDs still - fails. When the task names a ground-truth tool or command for a check, - run the final verification with that exact tool and its stated - parameters — a calibrated substitute proves nothing about the grade -18. **Snapshot a fragile artifact before inspecting it.** A tool meant to - read can still mutate what it opens — SQLite deletes an invalid WAL - when the connection closes, and destroyed evidence cannot be - re-downloaded. Copy the file to scratch space first (cp to /tmp), or - open it immutably (e.g. sqlite3 'file:...?immutable=1') -19. **Read images with the vision model, not by proxy.** When a task - hinges on perceiving an image — text or digits in a picture, a chart, - a rendered layout — call view_image on the file itself; reading it - back through terminal-resolution ASCII art or hand-rolled pixel - scripts throws away exactly the detail the answer depends on. If the - artifact is data rather than an image (geometry, toolpaths, a - waveform), render it to a PNG first and then look at it with - view_image - -Reply style: -- **Concise**. No filler like "let me see / let me help you / I'll analyze - it"; just act or give the answer. Announcing that you are about to verify - is filler; running the check and reporting its result is not -- **One shot**. Read files with read_file (use offset/limit for large spans); - never use python3 -c inline scripts for file I/O -- **Batch in parallel**. Issue multiple independent tool calls for the same - purpose in one turn; do not call them one by one serially -- **No re-confirmation**. Do not re-read facts already fetched; if a tool - fails once, report the error to the user instead of retrying a rephrased - version of the same action -- Do not recap the diff or restate what you just did unless asked — but do - report verification results (command + pass/fail), which the user cannot - see for themselves -- User input may come from voice transcription (/v command); just understand - the intent and do not comment on the voice/recording feature itself""" - - -class Agent: - def __init__( - self, - provider: LLMProvider, - executor: Executor, - max_turns: int = 50, - memory: MemoryProvider | None = None, - user_name: str = "", - provider_name: str = "", - model_name: str = "", - session_path: Path | None = None, - disabled_caps_provider=None, - directives_provider=None, - system_prompt: str | None = None, - allow_delegate: bool = True, - allowed_tools: list[str] | None = None, - hook_bus: HookBus | None = None, - memory_manager: MemoryManager | None = None, - json_mode: bool = False, - oneshot: bool = False, - ): - self.provider = provider - self.executor = executor - self.max_turns = max_turns - self.system_prompt = system_prompt - self.allow_delegate = allow_delegate - self.allowed_tools = allowed_tools - self.hook_bus = hook_bus or create_hook_bus() - self.json_mode = json_mode - self.oneshot = oneshot - # Hard stop for read-only stalls in oneshot (benchmark/script) mode. - # Env override: ACLI_READONLY_HARD_CAP (0 disables). - try: - self.readonly_hard_cap = int( - os.environ.get("ACLI_READONLY_HARD_CAP", "40"), - ) - except ValueError: - self.readonly_hard_cap = 40 - # Turn-level retry: re-run the current turn (only when nothing was - # streamed yet, to avoid duplicate output) up to turn_retry_max times - # with capped exponential backoff. Covers providers that carry no - # retry layer of their own; a HardenedProvider that exhausted its own - # budget slowly (stalled endpoint, not a refused connection) is not - # re-paid here — see _budget_burned_slowly. - # Env override: ACLI_TURN_RETRY_MAX (0 disables). - try: - self.turn_retry_max = int( - os.environ.get("ACLI_TURN_RETRY_MAX", "2"), - ) - except ValueError: - self.turn_retry_max = 2 - self.turn_retry_delay = 5.0 - self.turn_retry_backoff_cap = 30.0 - # Empty-turn backstop (oneshot only): a turn that ends with no tool - # calls and no content is a provider failure shape, not "done". An - # autonomous run must not exit on it while budget remains — nudge - # and continue, bounded so a persistently empty endpoint still - # terminates. Interactive keeps the historical break: there the - # user sees the notice and simply speaks again. - self._empty_turns = 0 - self._empty_turn_max = 2 - # Budget-aware convergence nudge (oneshot only): as the turn budget is - # consumed, tell the agent to switch approach if plateaued, lock in an - # already-passing result, then finalize. Covers productive-but-stuck - # iteration that ReflectionTracker (failures) and StagnationTracker - # (read-only stalls) both miss. Env: ACLI_CONVERGE_SOFT/_HARD (ratios; - # soft >= 1.0 disables). - try: - self.converge_soft_ratio = float( - os.environ.get("ACLI_CONVERGE_SOFT", "0.6"), - ) - except ValueError: - self.converge_soft_ratio = 0.6 - try: - self.converge_hard_ratio = float( - os.environ.get("ACLI_CONVERGE_HARD", "0.85"), - ) - except ValueError: - self.converge_hard_ratio = 0.85 - # Wall-clock budget nudge (oneshot only): the turn-count convergence - # hint never fires when a run is few-but-huge generations, yet the - # harness kills the run on wall time all the same — and the hard-set - # tasks that died so (dna-assembly, write-compressor) all died with - # their deliverable never written. The bench adapter injects the - # in-container `timeout` value as ACLI_TIME_BUDGET_SEC; unset/<=0 - # disables. Ratios: ACLI_TIME_SOFT / ACLI_TIME_HARD. - try: - self.time_budget_sec = float( - os.environ.get("ACLI_TIME_BUDGET_SEC", "0"), - ) - except ValueError: - self.time_budget_sec = 0.0 - try: - self.time_soft_ratio = float( - os.environ.get("ACLI_TIME_SOFT", "0.5"), - ) - except ValueError: - self.time_soft_ratio = 0.5 - try: - self.time_hard_ratio = float( - os.environ.get("ACLI_TIME_HARD", "0.8"), - ) - except ValueError: - self.time_hard_ratio = 0.8 - # Monotonic anchor for the budget above, set on the first turn: a - # wall clock would credit host-suspend time the process never had. - self._run_started: float | None = None - # Silence nudge: one reminder per turn after N tool calls have gone by - # without any prose reaching the user. Mode-independent on purpose — - # the shape this exists for is an interactive turn of *distinct* - # run_command calls, which every existing backstop misses: - # StagnationTracker's read-only classifier counts run_command as - # mutating and so resets its streak, its repeat counters need - # byte-identical calls, and _convergence_section is oneshot-only. - # Env: ACLI_SILENT_TOOL_NUDGE (0 disables). - try: - self.silent_tool_nudge = int( - os.environ.get("ACLI_SILENT_TOOL_NUDGE", "6"), - ) - except ValueError: - self.silent_tool_nudge = 6 - # Hard acceptance gate (oneshot only): run the project's test command - # before accepting a final answer, and send the raw output back when - # it fails. Built here, ahead of the policy text below, so the prompt - # can promise the gate only when one is actually armed. - # Env: ACLI_ACCEPTANCE (0 disables), ACLI_ACCEPTANCE_CMD (overrides - # discovery), ACLI_ACCEPTANCE_RETRIES, ACLI_ACCEPTANCE_TIMEOUT. - self._acceptance_gate = AcceptanceGate.from_env(self.oneshot) - # Load custom system prompt: workspace .acli/system-prompt.md first, - # then global ~/.acli/system-prompt.md, then built-in default. - # runners.py pre-populates system_prompt via _load_system_prompt(); - # this fallback ensures direct Agent construction (SDK, tests, - # subagents) also respects the global file. - if self.system_prompt is None: - from dashscope.acli.cli.startup import ( - _compose_system_prompt, - _load_system_prompt, - ) - - self.system_prompt = _compose_system_prompt(_load_system_prompt()) - if self.oneshot: - self.system_prompt = (self.system_prompt or SYSTEM_PROMPT) + ( - "\n\nTask execution policy (autonomous mode):\n" - "- Work from a written plan, not from memory: early in the " - "run, write a short plan file (goal, acceptance check, " - "ordered steps); after each step, record in it what is done " - "and what remains; before reporting completion, re-read it " - "and verify each item. Sketch the deliverable itself first, " - "then iterate on it. Never spend a whole turn only " - "thinking — every turn must land something on disk: a file, " - "a measurement, or an updated plan.\n" - "- If the request has a measurable acceptance criterion " - "(numeric threshold, size/time limit, test suite, " - "similarity/accuracy target), iterate until it is met: " - "implement -> measure against the criterion -> refine. Do " - "not stop at the first version that merely runs; optimize " - "toward the target — but once the criterion is met, lock it " - "in and finish rather than risk regressing a passing result. " - "Heed any budget-check hint: it tells you when to converge " - "or switch to a different approach. A wall-clock hint means " - "the run is killed when time runs out: get the required " - "deliverable written early, then keep improving it.\n" - "- Before reporting completion, turn every stated " - "requirement into its own executable check and run them " - "all — the measurable criterion, the format rules, and " - "any constraint that mentions components the deliverable " - "must contain. Where a check depends on domain facts, " - "state the fact explicitly first (a wrong assumption is " - "worse than a flagged unknown). Fix what fails instead " - "of handing back a near miss.\n" - "- When a check fails close to the target, treat it as " - "actionable feedback: change the approach based on what " - "the measurement shows, then re-measure.\n" - "- Do not stake a large share of the remaining budget on " - "one long unverified command: before an install, compile " - "or download that could run for minutes, spend seconds " - "probing that the path can succeed at all (package " - "available, toolchain present); give long commands an " - "explicit timeout so a stuck one fails fast and frees " - "the rest of the budget." - ) - if self._acceptance_gate.active: - # An armed gate implies oneshot — from_env hands back an empty - # command otherwise — so this belongs inside the block above. - # Naming the command beats a generic promise: the model can - # run it itself first instead of meeting the gate at the exit. - check = self._acceptance_gate.command - self.system_prompt += ( - f"\n- Verification gate: `{check}` runs before your " - "final answer is accepted. If it fails, the answer is " - "withheld and the raw output comes back to you as the " - "next requirement. Make it pass by fixing the code — " - "never by editing, skipping, or weakening the check " - "itself." - ) - # Discover project instructions from CWD (rules.jsonl, - # .cursorrules, etc.) - from dashscope.acli.prompt import discover_project_instructions - - project_instructions = discover_project_instructions() - self._prompt_pipeline = default_pipeline( - base_prompt=self.system_prompt or SYSTEM_PROMPT, - project_instructions=project_instructions, - skills_summary_fn=skills_summary_for_llm, - active_prompts_fn=lambda text: get_skill_manager().active_prompts( - text, - ), - ) - self.messages: list[dict] = [] - self.last_output: str = "" - self.memory = memory - self.provider_name = provider_name - self.model_name = model_name - self.user_name = user_name - self.session_path = session_path - # Callable[[], str] returning a system-prompt fragment describing - # currently-disabled capabilities. Called fresh each turn so - # /capability toggles take effect on the next user input without - # re-instantiating the agent. - self.disabled_caps_provider = disabled_caps_provider - # Callable[[], list[str]] returning user-declared operational rules - # to be injected unconditionally each turn (see _directives_section). - self.directives_provider = directives_provider - - # Sub-agents reuse the parent's memory_manager; top-level agents - # create their own instance. - if memory_manager is not None: - self.memory_manager = memory_manager - else: - workspace_dir = WORKSPACE_DIR - self.memory_manager = MemoryManager(workspace_dir) - - # Resolved once: the compression token budget tracks the model's real - # context window instead of a fixed 128k. - self._context_window = context_window_for_model(model_name or "") - - # Convenience references for backward compatibility - self.trace_logger = self.memory_manager.trace - self.experience_tracker = self.memory_manager.persistent.experience - - # Track tools used in current turn for experience recording - self._current_turn_tools: list[str] = [] - self._turn_tool_successes = 0 - self._turn_tool_failures = 0 - self._reflection_lesson_recorded = False - self._reflection_hint_injected = False - # Per-turn counters for the TUI status line - self.turn_tool_calls = 0 - self.turn_subagents = 0 - self.turn_mcp_calls = 0 - self.turn_skills = 0 - # Consecutive tool calls that streamed no prose, and whether the - # silence nudge has already been spent this turn. - self._silent_tool_calls = 0 - self._silent_nudge_injected = False - # Compression observability toward the model: the event is set by - # either compression path and spent once by _compression_section; - # the ratio is refreshed by the mid-turn guard's estimate. - self._compression_event: tuple[int, int] | None = None - self._context_ratio = 0.0 - # Explicit /skill invocations to count on the next turn - self._pending_skill_names: list[str] = [] - - def note_skill_use(self, name: str) -> None: - """Queue an explicit /skill invocation for turn/session stats.""" - self._pending_skill_names.append(name) - - def reset(self): - """Clear in-memory messages without deleting the persisted history - file.""" - self.messages = [] - - def load_session(self) -> int: - """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()) - if isinstance(data, list): - self.messages = data - return len(data) - except (json.JSONDecodeError, OSError): - pass - return 0 - - def save_session(self) -> None: - """Persist self.messages atomically. No-op when session_path is - unset.""" - if not self.session_path: - return - try: - from dashscope.acli.utils.paths import atomic_write_text - - self.session_path.parent.mkdir(parents=True, exist_ok=True) - atomic_write_text( - self.session_path, - json.dumps( - sanitize(self.messages), - ensure_ascii=False, - indent=2, - ), - ) - except OSError: - pass - # Update session meta message count - try: - from dashscope.acli.session import get_session_manager - - topic = self.session_path.parent.name - get_session_manager().update_message_count( - len(self.messages), - topic, - ) - except Exception: - pass - - def _connected_mcp_services(self) -> set[str]: - """Infer connected MCP services from registry tool names - (mcp__*).""" - services: set[str] = set() - for t in registry.list_tools(): - if t.name.startswith("mcp_"): - rest = t.name[4:] - if "_" in rest: - services.add(rest.split("_", 1)[0]) - return services - - def _filter_tools_schema(self, tools_schema: list[dict]) -> list[dict]: - """Apply per-agent tool restrictions. - - - Child agents (allow_delegate=False) do not see delegate/subagent - tools (no nested spawning). - - If allowed_tools is set, only those tool names are exposed. - """ - names = {t.get("name") for t in tools_schema} - - delegate_names = {"delegate", "delegate_parallel", "subagent_invoke"} - if not self.allow_delegate: - names -= delegate_names - - if self.allowed_tools is not None: - allowed = set(self.allowed_tools) - names = names & allowed - - return [t for t in tools_schema if t.get("name") in names] - - def _system_prompt_for_turn(self, user_input_text: str) -> str: - """Assemble the full system prompt via the pluggable PromptPipeline. - - The pipeline owns the stable/ephemeral split and the section order; - adding a section is now a matter of registering it on the pipeline - rather than editing this method. - """ - ctx = PromptContext( - user_input=user_input_text, - user_name=self.user_name, - provider_name=self.provider_name, - model_name=self.model_name, - memory_manager=self.memory_manager, - 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 an evidence-anchored hint on repeated tool failures. - - The lesson is deliberately *not* recorded here. This runs before the - model has answered, so all it could capture was the tracker's own - summary of a count. The diagnosis the model writes in response to the - anchored hint is what makes a lesson recallable on a similar task - later, so ``_record_reflection_lesson`` picks it up after the stream. - """ - tracker = self.memory_manager.session.reflection - if not tracker.needs_reflection(): - return "" - hint = tracker.get_reflection_hint() - if hint and self._current_turn_tools: - self._reflection_hint_injected = True - return hint - - def _record_reflection_lesson(self, diagnosis: str) -> None: - """Persist the model's own failure diagnosis, once per turn. - - Called with the response to a turn that carried a reflection hint. An - empty ``diagnosis`` — no content, or a stream cut short — falls back - to the tracker's evidence summary, so a turn that hit the failure - threshold still leaves something recallable behind. - """ - if not self._reflection_hint_injected: - return - if self._reflection_lesson_recorded: - return - tracker = self.memory_manager.session.reflection - lesson = tracker.get_failure_lesson(diagnosis=diagnosis) - if not lesson: - return - self._reflection_lesson_recorded = True - self._reflection_hint_injected = False - self.memory_manager.record_experience( - task_summary="repeated tool failures", - tools_used=self._current_turn_tools, - outcome="failure", - lesson=lesson, - ) - - def _stagnation_section(self) -> str: - """Inject a nudge on read-only stalls or repeated identical calls. - - ``get_stagnation_hint`` returns "" until one of those fires, so the - tracker — not this method — decides whether the turn gets a section. - """ - tracker = self.memory_manager.session.stagnation - cap = self.readonly_hard_cap if self.oneshot else None - return tracker.get_stagnation_hint(hard_cap=cap) - - def _convergence_section(self, loop_index: int) -> str: - """Inject a budget-aware converge/switch nudge in oneshot runs. - - Fires on the fraction of the turn budget consumed, covering the - productive-but-plateaued loop that failure/read-only trackers miss. - """ - if not self.oneshot: - return "" - return convergence_hint( - loop_index, - self.max_turns, - self.converge_soft_ratio, - self.converge_hard_ratio, - ) - - def _time_budget_section(self) -> str: - """Wall-clock budget nudge; see time_budget_hint for the rationale.""" - if not self.oneshot or self._run_started is None: - return "" - return time_budget_hint( - time.monotonic() - self._run_started, - self.time_budget_sec, - self.time_soft_ratio, - self.time_hard_ratio, - ) - - def _compression_section(self) -> str: - """Tell the model about compression it would otherwise never see. - - The compression event is spent once — after a compression the ratio - drops below the warn band anyway, so the state-based pre-hint alone - cannot double-fire. The ratio is refreshed by the mid-turn guard, so - the pre-hint can only fire from the second loop iteration on; the - first iteration of a turn is covered by the event path instead - (``_auto_compress`` runs just before the loop). - """ - event = self._compression_event - self._compression_event = None - return compression_hint(event, self._context_ratio) - - # Seconds of wall-clock budget a single run_command may never consume: - # the remainder minus a reserve for writing/finalizing the deliverable. - # Run 2026-10-12__reqenum-0612 lost 720s of an 1800s budget to two - # run_command timeouts (a 600s pip compile that could never succeed - # offline plus a 120s follow-up) and died with the deliverable never - # written; the hint alone can only ask, this enforces. - _CMD_TIMEOUT_RESERVE_SEC = 180 - _CMD_TIMEOUT_MIN_SEC = 30 - - def _command_timeout_ceiling(self) -> int | None: - """Max seconds one run_command may take, or None when unbudgeted.""" - if ( - not self.oneshot - or self.time_budget_sec <= 0 - or self._run_started is None - ): - return None - remaining = self.time_budget_sec - ( - time.monotonic() - self._run_started - ) - return max( - self._CMD_TIMEOUT_MIN_SEC, - int(remaining - self._CMD_TIMEOUT_RESERVE_SEC), - ) - - def _clamp_run_command_timeout(self, tool_call) -> str: - """Clamp run_command's timeout to the budget ceiling. - - Mutates tool_call.arguments and returns a note for the tool - result when it clamped, "" otherwise. The note must ride the - result: the assistant's own tool_call entry in history keeps the - model's original arguments, so without it the model cannot see - why its 600s command died at 120s. - """ - if tool_call.name != "run_command": - return "" - ceiling = self._command_timeout_ceiling() - if ceiling is None or self._run_started is None: - return "" - from dashscope.acli.tools.shell import default_timeout - - raw = tool_call.arguments.get("timeout") - try: - current = ( - int(raw) if raw not in (None, "", 0) else default_timeout() - ) - except (TypeError, ValueError): - current = default_timeout() - if current <= ceiling: - return "" - tool_call.arguments["timeout"] = ceiling - remaining = self.time_budget_sec - ( - time.monotonic() - self._run_started - ) - return ( - f"[timeout clamped from {current}s to {ceiling}s: " - f"{int(remaining)}s of wall-clock budget remain, " - f"{self._CMD_TIMEOUT_RESERVE_SEC}s reserved for finalizing. " - "Break the work into commands that fit, or pick a path that " - "does not need the long step at all.]\n" - ) - - def _silence_section(self) -> str: - """Inject one reminder when tool calls stop producing prose. - - Spent at most once per turn. Repeating it every iteration teaches the - model to skim past it, which is the failure mode prose instructions - already have — the point of a structural backstop is that it fires on - a count rather than on the model's willingness to re-read advice. - - Deliberately not gated on ``oneshot``: the user watching a spinner for - five minutes with no text is the interactive case, and the oneshot - runs already have the convergence and read-only-cap sections. - """ - if self._silent_nudge_injected or not self.silent_tool_nudge: - return "" - if self._silent_tool_calls < self.silent_tool_nudge: - return "" - self._silent_nudge_injected = True - return ( - "\n## ⏸ You have gone quiet\n" - f"{self._silent_tool_calls} tool calls in a row and not one word " - "of it reached the user.\n" - "Before the next call, say in one or two sentences what you are " - "doing and what you have found so far. If the calls were " - "collecting information to answer a question you can already " - "answer, answer it instead of calling again.\n" - ) - - async def _recall_memory(self, user_input) -> str: - """Search for relevant profile info and format as context. - - Recall is purely score-based (weighted keyword match + min_score - threshold) — no keyword gate on the query, so stored profile facts - surface whenever they are actually relevant. - """ - if not self.memory: - return "" - query = text_of(user_input) - if not query.strip(): - return "" - - try: - nodes = await self.memory.search(query, top_k=3, min_score=0.3) - if not nodes: - return "" - parts = [f"- {n.content}" for n in nodes] - return "\n\nUser profile:\n" + "\n".join(parts) - except Exception: - return "" - - async def _store_memory(self, messages: list[dict[str, str]]): - """Store conversation turn as memory (fire-and-forget).""" - if not self.memory: - return - try: - nodes = await self.memory.add(messages) - if nodes: - names = ", ".join(n.content[:20] for n in nodes) - console.print(f"\n[dim](memory: +{len(nodes)} {names})[/dim]") - except Exception as e: - console.print(f"\n[dim red](memory error: {e})[/dim red]") - - async def run_stream(self, user_input) -> AsyncIterator[str]: - # A failed/interrupted turn can leave its user message as the last - # history entry; resubmitting the same text must not stack a second - # copy on top of it — the orphan is reused as this turn's input. - if not ( - self.messages - and self.messages[-1].get("role") == "user" - and self.messages[-1].get("content") == user_input - ): - self.messages.append({"role": "user", "content": user_input}) - - from dashscope.acli.audit import get_audit_logger - - get_audit_logger().log_user_input(text_of(user_input)) - - # Track partial assistant content so Ctrl-C doesn't lose it. - _partial: list[str] = [] - try: - async for chunk in self._run_stream_body(user_input): - _partial.append(chunk) - yield chunk - except BaseException as e: - # On cancellation / error, save whatever was received so the - # user message and partial reply survive the next session load. - # A partial that is pure markdown scaffolding ("**", "```") - # carries no information and is dropped. - partial_text = "".join(_partial).strip() - if partial_text and any(ch.isalnum() for ch in partial_text): - self.messages.append( - { - "role": "assistant", - "content": partial_text + " [interrupted]", - }, - ) - elif not isinstance( - e, - ( - UserAbortedTurn, - KeyboardInterrupt, - SystemExit, - GeneratorExit, - asyncio.CancelledError, - ), - ): - # A provider/turn failure with zero output otherwise leaves - # only the user message behind: the history then shows N - # stacked user prompts with no trace of why each attempt - # failed, and the model cannot learn the cause on resubmit. - self.messages.append( - { - "role": "assistant", - "content": ( - "[Turn failed before any response: " - f"{sanitize_text(str(e))[:500]}]" - ), - }, - ) - self.save_session() - raise - finally: - # Turn-scoped trust grants reset here so the next user prompt - # starts fresh (Ctrl-C aborts also fall through this branch). - self.executor.clear_session_trust() - - async def _run_stream_body(self, user_input) -> AsyncIterator[str]: - user_input_text = text_of(user_input) - - # User-message hook - await self.hook_bus.dispatch_async( - HookContext( - event="on_message", - tool_name="", - arguments={"input": user_input_text}, - ), - ) - - # Clear tool tracking for new turn - self._current_turn_tools = [] - if self._run_started is None: - self._run_started = time.monotonic() - self.turn_tool_calls = 0 - self.turn_subagents = 0 - self.turn_mcp_calls = 0 - self._silent_tool_calls = 0 - self._silent_nudge_injected = False - try: - active = get_skill_manager().active_packages(user_input_text) - names = [p.name for p in active] + self._pending_skill_names - self._pending_skill_names = [] - self.turn_skills = len(names) - self.executor.record_skills(names) - except Exception: - self.turn_skills = 0 - self._turn_tool_successes = 0 - self._turn_tool_failures = 0 - self._reflection_lesson_recorded = False - # Cleared with the tracker below: a hint from the previous turn must - # not license recording a lesson from this turn's first response. - self._reflection_hint_injected = False - - # Reset reflection tracker for new turn - self.memory_manager.session.reflection.reset() - self.memory_manager.session.stagnation.reset() - self._acceptance_gate.reset() - - # Recall relevant memories - memory_context = await self._recall_memory(user_input_text) - system_prompt = self._system_prompt_for_turn(user_input_text) - if memory_context: - system_prompt += memory_context - - tools_schema = self._filter_tools_schema( - registry.to_schema_list(user_input_text), - ) - registry_version = registry.version - - # Compress with the full request overhead in view: the system prompt - # and tools schema ride on every API call, so they count against the - # model's window too. Runs after both are built, before the request - # list is assembled. - overhead_tokens = estimate_tokens(system_prompt) + estimate_tokens( - json.dumps(tools_schema, ensure_ascii=False), - ) - await self._auto_compress(overhead_tokens) - await self._ensure_fits_context(overhead_tokens) - - messages_with_system = [ - {"role": "system", "content": system_prompt}, - *self.messages, - ] - - last_content = "" - for _loop_i in range(self.max_turns): - # Connecting MCP services or enabling capabilities can mutate the - # global registry during a tool call. A schema frozen at turn start - # makes the newly registered tools unusable until the user sends a - # second message, so refresh both the schema and the prompt's - # ephemeral capability/MCP sections before the next model call. - if registry.version != registry_version: - system_prompt = self._system_prompt_for_turn(user_input_text) - if memory_context: - system_prompt += memory_context - tools_schema = self._filter_tools_schema( - registry.to_schema_list(user_input_text), - ) - overhead_tokens = estimate_tokens( - system_prompt, - ) + estimate_tokens( - json.dumps(tools_schema, ensure_ascii=False), - ) - await self._ensure_fits_context(overhead_tokens) - messages_with_system = [ - {"role": "system", "content": system_prompt}, - *self.messages, - ] - registry_version = registry.version - - # Cheap in-place shrink of stale tool results (no LLM call): - # keeps long tool loops from re-sending huge old outputs. - if _loop_i > 0: - shrink_old_tool_messages(self.messages) - # Mid-turn guard: a long tool loop can overflow the window within - # a single user turn. Estimate-only check; compress and rebuild - # the request list only past the hard threshold. The estimate is - # kept as the context-fill ratio for the compression hint below. - if _loop_i > 0: - used_tokens = ( - estimate_message_tokens(self.messages) + overhead_tokens - ) - self._context_ratio = ( - used_tokens / self._context_window - if self._context_window > 0 - else 0.0 - ) - if _loop_i > 0 and ( - used_tokens >= int(self._context_window * HARD_THRESHOLD_RATIO) - ): - compressed = await safety_compress_if_needed( - self.messages, - self.provider.chat, - context_window=self._context_window, - extra_tokens=overhead_tokens, - ) - if compressed is not None: - old_count = len(self.messages) - self.messages = compressed - self._compression_event = (old_count, len(self.messages)) - messages_with_system = [ - {"role": "system", "content": system_prompt}, - *self.messages, - ] - console.print( - f"[dim]mid-turn safety compression: {old_count} " - f"messages → {len(self.messages)}[/dim]", - ) - - # Reflection / stagnation / convergence / silence hints are - # volatile, so they ride at the tail of the request instead of - # being folded into the system message. Rewriting - # messages_with_system[0] on every iteration changed the whole - # prompt prefix byte-for-byte and defeated provider-side prompt - # caching, where cached input costs roughly a tenth of uncached. - # The system message and the accumulated history below it stay - # append-only and byte-stable. - # - # The hint has to be a user message, not a second system message: - # the Anthropic converter keeps only the last system message, so a - # second one would replace the real prompt outright, and the Tongyi - # converter only reads messages[0] as system. - # - # All five sections are rebuilt every iteration because tracker - # state changes as tools run; each returns "" when its condition - # is not met, so an empty hint costs no message at all. - hint_parts = { - "reflection": self._reflection_section(), - "stagnation": self._stagnation_section(), - "convergence": self._convergence_section(_loop_i), - "time_budget": self._time_budget_section(), - "compression": self._compression_section(), - "silence": self._silence_section(), - } - hint = "".join(hint_parts.values()).strip() - # Which behaviour hints fired this call, and their sizes. Kept - # out of the prompt and logged with the llm_call event so hint - # efficacy is measurable from traces instead of guessed. - hint_chars = {k: len(v) for k, v in hint_parts.items() if v} - request_messages = ( - [*messages_with_system, {"role": "user", "content": hint}] - if hint - else messages_with_system - ) - - # Hard stop for read-only stalls in oneshot mode: finish with - # whatever is on hand instead of inspecting forever. - if ( - self.oneshot - and self.readonly_hard_cap - and self.memory_manager.session.stagnation.readonly_streak - >= self.readonly_hard_cap - ): - stop_msg = ( - f"\n[Stopped: {self.readonly_hard_cap} consecutive " - "read-only tool calls with no progress. Ending the run " - "to avoid an infinite verification loop.]\n" - ) - yield stop_msg - last_content = stop_msg - break - - full_content = "" - full_reasoning = "" - tool_calls: list[ToolCall] = [] - seen_tool_calls: set[tuple[str, str]] = set() - last_chunk = None - finish_reason = "" - llm_start = time.monotonic() - - # Turn-level retry around the streaming call, for providers that - # carry no retry layer of their own. A HardenedProvider that spent - # its budget on slow attempts raises RetriesExhausted and is NOT - # re-attempted here: that multiplied max_retries x request_timeout - # by turn_retry_max, which against a stalled endpoint measured - # 819s of silent wall clock from a 60s configured timeout. A - # budget spent failing fast is still re-attempted — see - # _budget_burned_slowly. We only retry when nothing user-visible - # was streamed yet; once content is yielded, a retry would - # duplicate output, so that case degrades to a partial turn - # instead. Reasoning chunks do not count as emitted: they are - # never yielded to the user, so retrying a stream that carried - # only thinking duplicates nothing. A stalled stream of pure - # reasoning otherwise slipped past both retry layers and ended - # the run as an empty turn (dna-insert, 2026-10-12__fix-071). - turn_attempt = 0 - while True: - full_content = "" - full_reasoning = "" - tool_calls = [] - seen_tool_calls = set() - last_chunk = None - finish_reason = "" - emitted = False - try: - async for chunk in self.provider.chat_stream( - normalize_for_model( - request_messages, - self.model_name, - ), - tools_schema, - response_format=( - {"type": "json_object"} if self.json_mode else None - ), - ): - if chunk.delta_content: - emitted = True - full_content += chunk.delta_content - - yield chunk.delta_content - - if chunk.delta_reasoning_content: - full_reasoning += chunk.delta_reasoning_content - - if chunk.tool_calls: - emitted = True - for tc in chunk.tool_calls: - key = ( - tc.name, - json.dumps( - tc.arguments, - sort_keys=True, - ensure_ascii=False, - ), - ) - if key in seen_tool_calls: - continue - seen_tool_calls.add(key) - tool_calls.append(tc) - - # Record token usage from the last chunk - - if chunk.usage: - self.executor.record_api_call(chunk.usage) - last_chunk = chunk - if chunk.finish_reason: - finish_reason = chunk.finish_reason - break - except Exception as e: - if ( - not emitted - and is_retryable_error(e) - and turn_attempt < self.turn_retry_max - and not _budget_burned_slowly(e) - ): - turn_attempt += 1 - await asyncio.sleep( - min( - self.turn_retry_delay - * (2 ** (turn_attempt - 1)), - self.turn_retry_backoff_cap, - ), - ) - continue - if emitted and is_retryable_error(e): - # Mid-stream break. No layer retries this — the chunks - # already yielded would be duplicated — but it should - # not end the run either. Keep the partial response and - # let the loop move on: a connection reset 126s into a - # 1200s budget otherwise costs the other 1074s. - yield ( - f"\n[Stream interrupted ({e}); continuing with " - "the partial response]\n" - ) - break - raise - - # Log LLM call trace - prompt_stats = self.executor.record_prompt_composition( - request_messages, - tools_schema, - ) - # Char-based estimate logged next to the provider's usage in the - # same event, so the two token accounts can be reconciled from - # traces instead of drifting silently apart. - prompt_stats["est_prompt_tokens"] = estimate_message_tokens( - request_messages, - ) + estimate_tokens(json.dumps(tools_schema, ensure_ascii=False)) - if hint_chars: - prompt_stats["hint_chars"] = hint_chars - # Size of the cacheable request prefix: every bucket except the - # volatile trailing hint. A drop between consecutive llm_call - # events means the prefix changed byte-for-byte and the - # provider's prompt cache was defeated — this makes drift - # measurable from traces instead of guessed (dsh's tracker has - # the same lesson measured: 42% vs 99.8% cache hits). - prompt_stats["prefix_chars"] = ( - prompt_stats["system_chars"] - + prompt_stats["user_chars"] - + prompt_stats["assistant_chars"] - + prompt_stats["tool_result_chars"] - + prompt_stats["tool_call_args_chars"] - + prompt_stats["tools_schema_chars"] - - len(hint) - ) - self.trace_logger.log_llm_call( - messages=request_messages, - tools=tools_schema, - response={ - "content": full_content, - "tool_calls": [ - {"name": tc.name, "arguments": tc.arguments} - for tc in tool_calls - ], - "usage": ( - last_chunk.usage - if last_chunk and last_chunk.usage - else None - ), - }, - duration_ms=int((time.monotonic() - llm_start) * 1000), - model=self.model_name, - prompt_stats=prompt_stats, - ) - - # If this iteration carried a reflection hint, its answer is the - # diagnosis worth remembering. Placed before both exits from the - # iteration so neither path can skip it, and before the - # truncation warning is appended below so the stored lesson is - # the model's own text. - self._record_reflection_lesson(full_content) - - # Filter out invalid tool calls - tool_calls = [tc for tc in tool_calls if tc.name] - - self.turn_tool_calls += len(tool_calls) - # Silence is per-iteration, not cumulative over the turn: an - # iteration that streamed prose did talk to the user, so the run - # of unheard calls starts over from the next one. - if full_content.strip(): - self._silent_tool_calls = 0 - else: - self._silent_tool_calls += len(tool_calls) - if full_content.strip() or tool_calls: - self._empty_turns = 0 - for tc in tool_calls: - if tc.name in ("delegate", "subagent_invoke"): - self.turn_subagents += 1 - elif tc.name == "delegate_parallel": - tasks = tc.arguments.get("tasks") - self.turn_subagents += ( - len(tasks) if isinstance(tasks, list) and tasks else 1 - ) - if tc.name.startswith("mcp_"): - self.turn_mcp_calls += 1 - - if not tool_calls: - if finish_reason == "length": - # Output cap cut the generation mid-turn: this is not a - # final answer, so skip the gate and do not break. The - # partial goes into history and the loop continues with - # a shorter-chunks demand — which is also what makes - # every between-iteration mechanism (time budget, - # convergence, reflection) reachable again on the - # monolithic-generation failure shape (2 requests per - # 1800s in run 2026-10-12__time-069). - partial = full_content or ( - "[response cut off by the output-length limit " - "during planning, before any visible output]" - ) - cut_msg: dict = {"role": "assistant", "content": partial} - if full_reasoning: - cut_msg["reasoning_content"] = full_reasoning - self.messages.append(cut_msg) - messages_with_system.append(cut_msg) - nudge = ( - "[Output-length limit reached — your response was " - "cut off. Continue working, but keep each response " - "short: put plans and intermediate results into " - "files with write_file in small pieces instead of " - "one long response; if you were composing the " - "deliverable, write it now in several smaller " - "calls.]" - ) - yield f"\n{nudge}\n" - nudge_msg = {"role": "user", "content": nudge} - self.messages.append(nudge_msg) - messages_with_system.append(nudge_msg) - continue - # Acceptance gate. This branch is the loop's only voluntary - # exit, so it is the one place where "the model is done" can - # be checked against something other than the model's own - # opinion. Run before the answer is committed: a rejected - # answer belongs in history as an attempt, not in last_content - # as a result. - verdict = await self._acceptance_gate.check() - if verdict is not None and verdict.rejection: - self._withhold_answer( - full_content, - full_reasoning, - verdict.rejection, - messages_with_system, - ) - yield ( - "\n[Answer withheld: the verification check " - f"`{verdict.run.command}` did not pass; sending its " - "output back]\n" - ) - continue - if full_content: - # Detect truncated tool intent: LLM described an action but - # the tool call never materialized (stream cut short). - _TOOL_HINTS = ( - "write_file", - "read_file", - "run_command", - "search_files", - "list_directory", - "delete_file", - "edit_file", - "create_directory", - ) - if any(h in full_content for h in _TOOL_HINTS): - warn = ( - "\n[Response may be truncated: the model " - "described a tool action but produced no " - "complete tool call]\n" - ) - full_content += warn - yield warn - # The gate could not certify the answer but sending it back - # would not help either, so the caveat travels with it - # instead of the answer being silently downgraded. - if verdict is not None and verdict.note: - full_content += verdict.note - yield verdict.note - final_msg: dict = { - "role": "assistant", - "content": full_content, - } - if full_reasoning: - final_msg["reasoning_content"] = full_reasoning - self.messages.append(final_msg) - last_content = full_content - else: - if ( - self.oneshot - and self._empty_turns < self._empty_turn_max - ): - # A stall that slipped both retry layers (reasoning- - # only stream, then a clean-but-empty stop) must not - # pass for "done": with budget left, exiting here is - # how dna-insert died at 562/1800s with the - # deliverable never written (2026-10-12__fix-071). - self._empty_turns += 1 - nudge = ( - "[Empty response: the model returned no content " - "and no tool calls. The task is not finished — " - "keep working: re-read your plan file and take " - "the next step; if a deliverable file is " - "required, make sure it exists on disk.]" - ) - yield f"\n{nudge}\n" - nudge_msg = {"role": "user", "content": nudge} - self.messages.append(nudge_msg) - messages_with_system.append(nudge_msg) - continue - yield ( - "\n[Model returned no content; the model name " - "may not exist or the service is unresponsive]\n" - ) - break - - # Handle tool calls - if full_content: - last_content = full_content - response = LLMResponse( - content=full_content, - tool_calls=tool_calls, - reasoning_content=full_reasoning, - ) - assistant_msg = self._build_assistant_message(response) - self.messages.append(assistant_msg) - messages_with_system.append(assistant_msg) - - # Execute tool calls in parallel when multiple tools are called. - # If ANY tool may prompt for user confirmation (stdin), we MUST - # fall back to sequential execution — concurrent stdin prompts - # corrupt each other and produce garbled output. - yield "" # Stop thinking spinner before tool execution - any_needs_confirm = any( - self._needs_confirmation(tc) for tc in tool_calls - ) - if len(tool_calls) > 1 and not any_needs_confirm: - # Parallel execution - results = await asyncio.gather( - *[self._execute_tool(tc) for tc in tool_calls], - return_exceptions=True, - ) - supplement_info = None - aborted: UserAbortedTurn | None = None - for tool_call, result in zip(tool_calls, results): - if isinstance(result, UserAbortedTurn): - # Stop-the-turn signal (e.g. hook-forced confirm on an - # AUTO tool). Every call still gets a tool message so - # tool_call pairing stays intact, then we re-raise. - aborted = aborted or result - result = "Cancelled (user aborted this turn)" - elif isinstance(result, UserSupplement): - supplement_info = result.supplement - result = "Cancelled (user added info to replan)" - elif isinstance(result, Exception): - result = f"Error: {result}" - - yield ( - f"\n[{tool_call.name}] →\n" - f"{tool_result_for_display(tool_call.name, result)}\n" - ) - tool_msg = _tool_message(tool_call, result) - self.messages.append(tool_msg) - messages_with_system.append(tool_msg) - - if aborted: - raise aborted - if supplement_info: - supplement_msg = { - "role": "user", - "content": ( - f"[User supplement]: {supplement_info}\n" - f"Replan with the new info and continue." - ), - } - self.messages.append(supplement_msg) - messages_with_system.append(supplement_msg) - continue - else: - # Sequential execution: either a single tool, or multiple tools - # where at least one needs user confirmation (stdin prompts). - try: - answered_ids: set[str] = set() - for tool_call in tool_calls: - result = await self._execute_tool(tool_call) - rendered = tool_result_for_display( - tool_call.name, - result, - ) - yield f"\n[{tool_call.name}] →\n{rendered}\n" - tool_msg = _tool_message(tool_call, result) - self.messages.append(tool_msg) - messages_with_system.append(tool_msg) - answered_ids.add(tool_call.id) - except UserSupplement as e: - self._close_pending_tool_calls( - tool_calls, - answered_ids, - messages_with_system, - ) - supplement_text = ( - f"[User supplement]: {e.supplement}\n" - f"Replan with the new info and continue." - ) - yield f"\n{supplement_text}\n" - supplement_msg = { - "role": "user", - "content": supplement_text, - } - self.messages.append(supplement_msg) - messages_with_system.append(supplement_msg) - continue - except UserAbortedTurn: - # Close out unanswered tool_calls before propagating — - # otherwise the saved/in-memory history keeps an assistant - # message whose tool_calls have no tool responses, and the - # next API request is rejected by the provider. - self._close_pending_tool_calls( - tool_calls, - answered_ids, - messages_with_system, - reason="Cancelled (user aborted this turn)", - ) - raise - else: - yield "\n(max turn limit reached)" - - # Response hook - await self.hook_bus.dispatch_async( - HookContext( - event="on_response", - tool_name="", - arguments={"input": user_input, "content": last_content}, - ), - ) - - # Record the completed turn as events only for a persistent session. - # A session_path-less agent is intentionally ephemeral (SDK calls, - # disabled session persistence, isolated subagents); defaulting such a - # run to SessionManager.current_topic silently polluted the interactive - # "default" session. The event stream remains the source of truth for - # sessions that explicitly opted in. - if self.session_path is not None: - try: - from dashscope.acli.session import get_session_manager - - turn_topic = self.session_path.parent.name - 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, - ) - get_session_manager().record_messages_snapshot( - self.messages, - topic=turn_topic, - ) - 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: - # Flatten + ellipsis: raw prompts carry newlines, and a - # mid-word cut breaks the "Past Lessons" list rendering. - task_summary = truncate(" ".join(text_of(user_input).split()), 100) - outcome = _classify_outcome( - self._turn_tool_successes, - self._turn_tool_failures, - ) - self.memory_manager.record_experience( - task_summary=task_summary, - tools_used=self._current_turn_tools, - outcome=outcome, - ) - - # Record tool sequence for directives auto-learning - if outcome == "success": - from dashscope.acli.memory.directives_learning import ( - _load_patterns, - analyze_patterns, - propose_directive, - record_tool_sequence, - ) - - record_tool_sequence(self._current_turn_tools) - - # Periodically analyze patterns and auto-propose directives - try: - seq_count = len(_load_patterns().get("sequences", [])) - if seq_count >= 5 and seq_count % 5 == 0: - proposals = analyze_patterns() - for p in proposals: - propose_directive(p["directive"], p["pattern"]) - except Exception: - pass # directives learning must never break the agent - - def _store_history(self) -> None: - """Persist a conversation history summary. Swallows errors so a - storage failure never breaks the agent loop.""" - try: - from dashscope.acli.platforms.local import history - - history.store_history(self.messages) - except Exception as e: - console.print(f"[dim red](history error: {e})[/dim red]") - - async def _execute_tool(self, tool_call: ToolCall) -> str: - tool_def = registry.get(tool_call.name) - if not tool_def: - return f"Error: unknown tool '{tool_call.name}'" - - # Budget back-pressure on the one tool that can burn minutes of - # wall clock in a single call; see _clamp_run_command_timeout. - budget_note = self._clamp_run_command_timeout(tool_call) - - # Track tool usage for experience recording (deduped, keep - # first-use order) - if tool_call.name not in self._current_turn_tools: - self._current_turn_tools.append(tool_call.name) - - # Futility refusal, checked before the hook so the user is never asked - # to approve a call that will not run. Identical input cannot produce - # a different output, so a repeat of an unchanged failure is refused - # rather than nudged: the nudge was already being ignored. - refusal = self.memory_manager.session.stagnation.refuse_if_futile( - tool_call.name, - tool_call.arguments, - ) - if refusal: - from dashscope.acli.audit import get_audit_logger - - get_audit_logger().log_tool_call( - tool_call.name, - tool_call.arguments, - decision="denied", - reason="futility: identical failure, no state change since", - ) - return refusal - - # Before-tool-call hooks - before_ctx = HookContext( - event="before_tool_call", - tool_name=tool_call.name, - arguments=tool_call.arguments, - ) - before_result = await self.hook_bus.dispatch_async(before_ctx) - - if before_result.blocked: - msg = ( - before_result.warnings[0] - if before_result.warnings - else "blocked by hook" - ) - from dashscope.acli.audit import get_audit_logger - - get_audit_logger().log_tool_call( - tool_call.name, - tool_call.arguments, - decision="denied", - reason=f"hook: {msg}", - ) - return f"Blocked: {msg}" - - if before_result.confirm: - from dashscope.acli.tools.registry import ToolDefinition - - synthetic = ToolDefinition( - name=tool_call.name, - description=tool_def.description, - permission=PermissionLevel.CONFIRM, - func=tool_def.func, - parameters=tool_def.parameters, - ) - if not await self.executor._async_check_permission( - synthetic, - tool_call.arguments, - ): - from dashscope.acli.audit import get_audit_logger - - get_audit_logger().log_tool_call( - tool_call.name, - tool_call.arguments, - decision="denied", - reason="hook confirm declined", - ) - return "Cancelled" - - # Acceptance baseline, taken from the last point where the call is - # still guaranteed to run: after the futility refusal and the - # permission hooks, before the executor. Read-only calls skip it, so a - # question-answering turn never pays for a test suite, and the - # baseline still comes from a workspace this turn has not touched. - if not is_readonly_tool_call(tool_call.name, tool_call.arguments): - await self._acceptance_gate.ensure_baseline() - - start_time = time.time() - outcome = await self.executor.execute_detailed( - tool_def, - tool_call.arguments, - ) - duration_ms = int((time.time() - start_time) * 1000) - result = outcome.text - if budget_note: - result = budget_note + result - success = outcome.ok - self.trace_logger.log_tool_execution( - tool_call.name, - tool_call.arguments, - result, - duration_ms, - success, - ) - - # After-tool-call hooks - after_ctx = HookContext( - event="after_tool_call", - tool_name=tool_call.name, - arguments=tool_call.arguments, - result=result, - success=success, - ) - after_result = await self.hook_bus.dispatch_async(after_ctx) - - # On-error hooks - if not success: - error_ctx = HookContext( - event="on_error", - tool_name=tool_call.name, - arguments=tool_call.arguments, - result=result, - success=False, - ) - error_result = await self.hook_bus.dispatch_async(error_ctx) - after_result.warnings.extend(error_result.warnings) - after_result.alerts.extend(error_result.alerts) - after_result.outputs.extend(error_result.outputs) - after_result.logs.extend(error_result.logs) - - # Emit non-silent hook outputs to the console - for warning in after_result.warnings: - console.print(f"[yellow]⚠️ {warning}[/yellow]") - for alert in after_result.alerts: - console.print(f"[red]🚨 {alert}[/red]") - for log in after_result.logs: - console.print(f"[dim]{log}[/dim]") - - # Record tool execution via memory manager (trace + reflection) - self.memory_manager.log_trace( - "tool_execution", - { - "tool_name": tool_call.name, - "arguments": tool_call.arguments, - "result": result, - "duration_ms": duration_ms, - "success": success, - "signal_kind": outcome.signal_kind, - "exit_code": outcome.exit_code, - }, - ) - # Reflection counts only real failures — user rejection/cancellation - # is neutral and must not feed the consecutive-failure counter. The - # outcome text goes along with the verdict: the hint quotes it back - # verbatim, and outcome.text is the raw output as classified, before - # any fallback hint was appended to `result`. - if not outcome.cancelled: - self.memory_manager.record_tool_execution( - tool_call.name, - success, - evidence=outcome.text, - signal_kind=outcome.signal_kind, - exit_code=outcome.exit_code, - ) - self.memory_manager.session.stagnation.record( - is_readonly_tool_call(tool_call.name, tool_call.arguments), - tool_name=tool_call.name, - arguments=tool_call.arguments, - ok=success, - result=outcome.text, - ) - if success: - self._turn_tool_successes += 1 - else: - self._turn_tool_failures += 1 - - # Add fallback hints for common failures - if not success and not outcome.cancelled: - fallback_hint = ( - self.memory_manager.session.tool_chains.get_fallback_hints( - tool_call.name, - ) - ) - if fallback_hint: - result = result + fallback_hint - - return result - - async def _ensure_fits_context(self, extra_tokens: int = 0) -> None: - """Gateway-style safety compression before an API call. - - If the accumulated conversation plus request overhead is near the - model's hard threshold, compress older messages while keeping the most - recent ones. This prevents a single huge tool output from blowing - the window. - """ - compressed = await safety_compress_if_needed( - self.messages, - self.provider.chat, - context_window=self._context_window, - extra_tokens=extra_tokens, - ) - if compressed is not None: - old_count = len(self.messages) - self.messages = compressed - console.print( - f"[dim]safety compression: {old_count} messages → " - f"{len(self.messages)}[/dim]", - ) - - async def _auto_compress(self, extra_tokens: int = 0) -> None: - """Auto-compress conversation when the token budget runs high. - - Token-aware triggering against the model's context window, with - tail-message preservation and tool-output truncation. - """ - from dashscope.acli.compression import compress_messages - - compressed = await compress_messages( - self.messages, - self.provider.chat, - context_window=self._context_window, - extra_tokens=extra_tokens, - ) - if compressed is None: - return - - old_count = len(self.messages) - self.messages = compressed - self._compression_event = (old_count, len(self.messages)) - console.print( - f"[dim]auto-compression: {old_count} messages → " - f"{len(self.messages)}[/dim]", - ) - - # Log compression decision - self.trace_logger.log_decision( - "auto_compress", - { - "old_count": old_count, - "new_count": len(self.messages), - "context_window": self._context_window, - "extra_tokens": extra_tokens, - }, - ) - - # Tool families that may need interactive confirmation (stdin prompts). - # When multiple such tools appear in one batch, we must fall back to - # sequential execution — concurrent stdin prompts corrupt each other. - _NEEDS_CONFIRM_TOOLS = frozenset( - {"write_file", "delete_file", "delete_directory", "move_file"}, - ) - - def _needs_confirmation(self, tool_call: ToolCall) -> bool: - """Check whether a tool call may trigger an interactive - confirmation prompt. - - Conservative heuristic: known dangerous tool names OR any tool with - a non-AUTO permission level in the registry. - """ - if tool_call.name in self._NEEDS_CONFIRM_TOOLS: - return True - tool_def = registry.get(tool_call.name) - if tool_def and tool_def.permission != PermissionLevel.AUTO: - return True - return False - - def _withhold_answer( - self, - content: str, - reasoning: str, - rejection: str, - messages_with_system: list[dict], - ) -> None: - """Put a gate-rejected answer and its rejection into history. - - The answer is kept rather than dropped: the model has to see what it - claimed in order to retract the right part of it, and a rejection - quoting output from an attempt the history never mentions reads as - noise. Neither message becomes turn output — the turn is being sent - back, not finished. - """ - if content: - withheld: dict = {"role": "assistant", "content": content} - if reasoning: - withheld["reasoning_content"] = reasoning - self.messages.append(withheld) - messages_with_system.append(withheld) - # A user message, matching how the reflection/stagnation hints are - # delivered: the provider converters keep only one system message, so - # a second one would replace the real prompt. - rejection_msg = {"role": "user", "content": rejection} - self.messages.append(rejection_msg) - messages_with_system.append(rejection_msg) - - def _close_pending_tool_calls( - self, - tool_calls: list[ToolCall], - answered_ids: set[str], - messages_with_system: list[dict], - reason: str = "Cancelled (user added info to replan)", - ) -> None: - """Append synthetic tool responses for tool_calls left unanswered. - - A UserSupplement aborts the remaining tool loop, which would leave - the assistant message's tool_calls without matching tool responses — - the next API request requires one tool response per tool_call. - """ - for tc in tool_calls: - if tc.id in answered_ids: - continue - tool_msg = { - "role": "tool", - "content": reason, - "name": tc.name, - "tool_use_id": tc.id, - "tool_call_id": tc.id, - } - self.messages.append(tool_msg) - messages_with_system.append(tool_msg) - - def _build_assistant_message(self, response: LLMResponse) -> dict: - msg: dict = {"role": "assistant"} - if response.content: - msg["content"] = response.content - if getattr(response, "reasoning_content", ""): - msg["reasoning_content"] = response.reasoning_content - if response.tool_calls: - msg["tool_calls"] = [ - { - "id": tc.id, - "type": "function", - "function": { - "name": tc.name, - "arguments": json.dumps( - tc.arguments, - ensure_ascii=False, - ), - }, - } - for tc in response.tool_calls - ] - return msg diff --git a/dashscope/acli/agents/__init__.py b/dashscope/acli/agents/__init__.py deleted file mode 100644 index 73cf54ba..00000000 --- a/dashscope/acli/agents/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import annotations diff --git a/dashscope/acli/agents/delegate.py b/dashscope/acli/agents/delegate.py deleted file mode 100644 index aea791ad..00000000 --- a/dashscope/acli/agents/delegate.py +++ /dev/null @@ -1,379 +0,0 @@ -# -*- coding: utf-8 -*- -"""local.delegate: spawn parallel sub-agents for fan-out tasks. - -A `delegate` tool lets the main agent break a request into independent -sub-tasks, run them in parallel, and collect structured results. Child -agents share the parent's provider and executor but have isolated message -histories and cannot delegate further (no nested spawning). -""" -# pylint: disable=too-many-branches - -from __future__ import annotations - -import asyncio -import json -import uuid -from pathlib import Path -from typing import Any - -from dashscope.acli.tools.registry import registry - -# Set by cli._run_loop / _run_tui_mode after Agent construction so the -# delegate tool can spawn children of the live main agent. -_parent_agent = None # type: ignore[var-annotated] -_config = None # type: ignore[var-annotated] - -# Concurrency control across all delegate calls in the process. -# asyncio.Semaphore binds to the event loop that first uses it, so a single -# module-level instance breaks when delegates run on different loops (e.g. -# consecutive asyncio.run() calls or REPL vs TUI loops). Keep one semaphore -# per running loop, created lazily inside the async entry point. -_delegate_semaphores: dict[asyncio.AbstractEventLoop, asyncio.Semaphore] = {} - - -def set_parent_agent(agent) -> None: - global _parent_agent - _parent_agent = agent - - -def set_config(config) -> None: - global _config - _config = config - - -def _get_delegate_semaphore() -> asyncio.Semaphore: - """Return the delegate semaphore bound to the current running loop.""" - loop = asyncio.get_running_loop() - sem = _delegate_semaphores.get(loop) - if sem is None: - sem = asyncio.Semaphore(max(1, _delegation_config().max_concurrent)) - _delegate_semaphores[loop] = sem - return sem - - -def _has_parent() -> bool: - return _parent_agent is not None - - -def _delegation_config(): - from dashscope.acli.config import Config - - if _config is None: - return Config().delegation - return getattr(_config, "delegation", _default_delegation_config()) - - -def _default_delegation_config(): - """Fallback when config object has no delegation field (e.g. tests).""" - - class _Fallback: - max_concurrent = 5 - default_timeout = 120 - allow_nested = False - - return _Fallback() - - -def _child_system_prompt( - tools: list[str] | None, - override: str | None = None, -) -> str: - if override: - base = override - else: - base = ( - "You are an acli subagent focused on completing the " - "assigned sub-task." - ) - if tools: - base += f"\nYou may only use these tools: {', '.join(tools)}" - else: - base += "\nYou may use any currently available tool." - base += ( - "\nDo not call delegate, delegate_parallel, or subagent_invoke " - "(subagents may not delegate further)." - ) - return base - - -def _build_prompt(task: str, context_files: list[str] | None) -> str: - parts = [] - if context_files: - parts.append("Context files:") - for path in context_files: - parts.append(f"- {path}") - try: - content = Path(path).read_text( - encoding="utf-8", - errors="ignore", - ) - parts.append(f"```\n{content}\n```") - except Exception as e: - parts.append(f"(read failed: {e})") - parts.append("") - parts.append(f"Task: {task}") - return "\n".join(parts) - - -async def _drain_stream(stream, chunks: list[str]) -> None: - async for chunk in stream: - if chunk: - chunks.append(chunk) - - -async def _run_child( - task: str, - tools: list[str] | None, - context_files: list[str] | None, - timeout: int, - system_prompt: str | None = None, - max_turns: int | None = None, -) -> dict[str, Any]: - if _parent_agent is None: - return { - "task_id": str(uuid.uuid4())[:8], - "status": "failed", - "result": ( - "Error: delegate not initialized " - "(missing parent agent reference)" - ), - } - - from dashscope.acli.agent import Agent - from dashscope.acli.memory.manager import MemoryManager - - task_id = str(uuid.uuid4())[:8] - cfg = _delegation_config() - effective_timeout = timeout or cfg.default_timeout - effective_timeout = max(1, effective_timeout) - - # Capped max_turns: explicit param > subagents config > default 10, - # hard cap 50. - if max_turns is not None: - capped_turns = min(max(1, max_turns), 50) - else: - capped_turns = 10 - if _config is not None: - agent_cfg = _config.subagents.get("local.delegate") - if agent_cfg and agent_cfg.max_turns: - capped_turns = min(max(1, agent_cfg.max_turns), 50) - - sub = Agent( - provider=_parent_agent.provider, - executor=_parent_agent.executor, - max_turns=capped_turns, - memory=None, - user_name=_parent_agent.user_name, - provider_name=_parent_agent.provider_name, - # The child runs on the parent's provider, whose model is fixed at - # construction. model_name drives vision/audio normalization and the - # truncation window, so any other name would have the child prepare - # messages for a model that never sees them. - model_name=_parent_agent.model_name, - session_path=None, - disabled_caps_provider=_parent_agent.disabled_caps_provider, - directives_provider=_parent_agent.directives_provider, - system_prompt=_child_system_prompt(tools, override=system_prompt), - allow_delegate=False, - allowed_tools=tools, - hook_bus=getattr(_parent_agent, "hook_bus", None), - # Isolated session tier: child plans/failures must not move the - # parent's plan tracker or reflection counters. - memory_manager=( - MemoryManager.derive_child(_parent_agent.memory_manager) - if _parent_agent.memory_manager is not None - else None - ), - ) - - prompt = _build_prompt(task, context_files) - chunks: list[str] = [] - stream_task = asyncio.create_task( - _drain_stream(sub.run_stream(prompt), chunks), - ) - status = "failed" - - try: - try: - await asyncio.wait_for( - asyncio.shield(stream_task), - timeout=effective_timeout, - ) - status = "completed" - except asyncio.TimeoutError: - status = "timeout" - except asyncio.CancelledError: # pylint: disable=try-except-raise - # Cancelled by the caller — the finally below stops the child - # stream first, then the cancellation propagates. - raise - except Exception: - status = "failed" - finally: - # Never abandon the child stream: cancel it on timeout/failure/ - # cancellation and wait for it to actually finish (suppress errors). - if not stream_task.done(): - stream_task.cancel() - try: - await stream_task - except asyncio.CancelledError: - pass - except Exception: - status = "failed" - - # Strip tool-call trail markers from the child output. - cleaned: list[str] = [] - for chunk in chunks: - if chunk.startswith("\n[") and "] →" in chunk: - continue - cleaned.append(chunk) - - result = ("".join(cleaned)).strip() or "(subagent returned no content)" - return {"task_id": task_id, "status": status, "result": result} - - -async def _delegate( - task: str, - tools: list[str] | None = None, - context_files: list[str] | None = None, - timeout: int | None = None, - # Accepted and ignored. It used to reach Agent.model_name, which never - # changed the model served but did change normalization and truncation. - # Kept so a model that already learned to send it gets no TypeError. - model: str | None = None, # pylint: disable=unused-argument - system_prompt: str | None = None, - max_turns: int | None = None, -) -> str: - """Spawn a single sub-agent and return a JSON result.""" - async with _get_delegate_semaphore(): - result = await _run_child( - task, - tools, - context_files, - timeout, - system_prompt, - max_turns, - ) - return json.dumps(result, ensure_ascii=False) - - -async def _delegate_parallel( - tasks: list[dict[str, Any]], - max_concurrent: int | None = None, -) -> str: - """Spawn multiple sub-agents in parallel with controlled concurrency. - - Each entry in `tasks` is a dict with optional keys: - task, tools, context_files, timeout, system_prompt, max_turns. - """ - cfg = _delegation_config() - limit = max_concurrent or cfg.max_concurrent - limit = max(1, limit) - semaphore = asyncio.Semaphore(limit) - - async def _run_one(task_def: dict[str, Any]) -> dict[str, Any]: - async with semaphore: - return await _run_child( - task=task_def.get("task", ""), - tools=task_def.get("tools"), - context_files=task_def.get("context_files"), - timeout=task_def.get("timeout"), - system_prompt=task_def.get("system_prompt"), - max_turns=task_def.get("max_turns"), - ) - - results = await asyncio.gather(*[_run_one(t) for t in tasks]) - return json.dumps(results, ensure_ascii=False) - - -def register_delegate_tools() -> None: - """Wire delegate tools into the registry.""" - registry.register_mcp_tool( - name="delegate", - description=( - "Delegate a sub-task to an independent subagent. The " - "subagent shares the current provider/executor but has an " - "isolated message history that won't pollute the main " - "conversation. You may restrict its tool whitelist, pass " - "context files, and set timeout, system_prompt, and " - "max_turns. Returns JSON with {task_id, status, result}." - ), - parameters={ - "type": "object", - "properties": { - "task": { - "type": "string", - "description": ( - "sub-task description; the subagent treats it " - "as a user message." - ), - }, - "tools": { - "type": "array", - "items": {"type": "string"}, - "description": ( - "tool whitelist the subagent may use " - "(optional; default is all)." - ), - }, - "context_files": { - "type": "array", - "items": {"type": "string"}, - "description": ( - "file paths whose contents are prepended to " - "the sub-task." - ), - }, - "timeout": { - "type": "integer", - "description": ( - "subagent timeout in seconds (default 120)." - ), - "default": 120, - }, - "system_prompt": { - "type": "string", - "description": ( - "override the subagent's system prompt to " - "specialize its role (optional)." - ), - }, - "max_turns": { - "type": "integer", - "description": ( - "max subagent turns (default 10, cap 50)." - ), - }, - }, - "required": ["task"], - }, - call_fn=_delegate, - ) - - registry.register_mcp_tool( - name="delegate_parallel", - description=( - "Delegate multiple sub-tasks in parallel with a cap on " - "max concurrency. Each element is a task config supporting " - "task/tools/context_files/timeout/system_prompt/max_turns. " - "Returns a JSON array of task results." - ), - parameters={ - "type": "object", - "properties": { - "tasks": { - "type": "array", - "items": {"type": "object"}, - "description": "list of sub-task configs.", - }, - "max_concurrent": { - "type": "integer", - "description": ( - "max concurrency (optional; defaults to config " - "delegation.max_concurrent)." - ), - }, - }, - "required": ["tasks"], - }, - call_fn=_delegate_parallel, - ) diff --git a/dashscope/acli/agents/subagent.py b/dashscope/acli/agents/subagent.py deleted file mode 100644 index 76bd4a7b..00000000 --- a/dashscope/acli/agents/subagent.py +++ /dev/null @@ -1,317 +0,0 @@ -# -*- coding: utf-8 -*- -"""local.subagent: spawn an isolated Agent loop sharing the parent's -provider + executor + tool registry, but with its own messages list so -the sub-task doesn't pollute the parent's conversation context. - -Use cases the parent agent reaches for this tool: - - "review file X end-to-end" — fan out a long read+analyze loop whose - intermediate tool calls should NOT clog the main thread - - "summarize this 50-file directory" — sub-agent does the scanning, - returns one paragraph - - "explore an unfamiliar API" — speculative reads in isolation; if it - finds nothing useful, parent context stays clean - -What it is NOT: - - A different LLM provider (it inherits whatever the parent is using). - For "use Claude for hard reasoning while main uses tongyi" you want a - different provider per call, not subagent. - - A long-running daemon. Each invocation is one synchronous call. -""" - -from __future__ import annotations - -from dashscope.acli.tools.registry import registry - -# Set by cli._run_loop after Agent construction. We pin a reference here -# so the registered subagent_invoke tool can spin up a child Agent from -# whatever provider/executor the main one currently holds — without a -# circular import to cli.py. -_parent_agent = None # type: ignore[var-annotated] -_config = None # type: ignore[var-annotated] - - -def set_parent_agent(agent) -> None: - global _parent_agent - _parent_agent = agent - - -def set_config(config) -> None: - """Store a reference to the Config so subagent_invoke can look up the - per-agent max_turns override.""" - global _config - _config = config - - -def _has_parent() -> bool: - return _parent_agent is not None - - -async def _subagent_invoke( - prompt: str, - system_prompt: str = "", - max_turns: int = 10, -) -> str: - """Spawn a one-shot sub-agent with isolated message history. - - prompt The task the sub-agent should handle. Treat it as the - user message it'll receive. - system_prompt Optional extra system prompt segment to specialize the - sub-agent (e.g. "You are a strict code reviewer; only - use read_file / list_directory; report findings as a - bulleted list."). If empty, the sub-agent uses the - parent's default system prompt. - max_turns Bound on the sub-agent loop length. Default 10 covers - short focused tasks; bump for deeper exploration. - Hard cap at 50 to prevent runaway sub-loops. - - Returns the sub-agent's final assistant content (text only — tool call - intermediates are NOT included; that's the whole point). - """ - if _parent_agent is None: - return ( - "Error: subagent not initialized " - "(missing parent agent reference)" - ) - - from dashscope.acli.agent import ( - Agent, - ) # local import to avoid module-load cycle - from dashscope.acli.memory.manager import MemoryManager - - # Only max_turns is read; see SubagentConfig for why the rest is inert. - capped_turns = min(max(1, max_turns), 50) - if _config is not None: - agent_cfg = _config.subagents.get("local.subagent") - if agent_cfg and agent_cfg.max_turns: - capped_turns = min(max(1, agent_cfg.max_turns), 50) - - # Compose a single system prompt: parent's base prompt with the caller's - # specialization appended. Without a specialization, pass None so the - # child ctor resolves the default prompt itself — either way the child - # ends up with exactly one system message. - composed_prompt = None - if system_prompt: - base = getattr(_parent_agent, "system_prompt", None) or "" - composed_prompt = ( - f"{base}\n\n{system_prompt}" if base else system_prompt - ) - - sub = Agent( - provider=_parent_agent.provider, - executor=_parent_agent.executor, - max_turns=capped_turns, - memory=None, # don't recall main user's profile - user_name=_parent_agent.user_name, - provider_name=_parent_agent.provider_name, - model_name=_parent_agent.model_name, - session_path=None, # never persist sub-agent runs - # disabled_caps + directives DO inherit — they express user intent - # that should apply to any agent acting on the user's behalf. - disabled_caps_provider=_parent_agent.disabled_caps_provider, - directives_provider=_parent_agent.directives_provider, - system_prompt=composed_prompt, - allow_delegate=False, # no nested spawning from a sub-agent - hook_bus=getattr(_parent_agent, "hook_bus", None), - # Isolated session tier: child plans/failures must not move the - # parent's plan tracker or reflection counters. - memory_manager=( - MemoryManager.derive_child(_parent_agent.memory_manager) - if _parent_agent.memory_manager is not None - else None - ), - ) - - # Drain run_stream into a single result string. Strip the "[tool] →" - # trail lines that the agent inserts for the UI — for the parent agent - # they're noise, not signal. - buffer: list[str] = [] - async for chunk in sub.run_stream(prompt): - if chunk.startswith("\n[") and "] →" in chunk: - continue # skip tool-call trail markers - buffer.append(chunk) - return ("".join(buffer)).strip() or "(subagent returned no content)" - - -def register_subagent_tool() -> None: - """Wire subagent_invoke into the tool registry. Called from - tools/platform.register_one_capability when local.subagent is enabled.""" - registry.register_mcp_tool( - name="subagent_invoke", - description=( - "Spawn an isolated sub-agent (same provider, same tools, fresh " - "context) to handle a self-contained task whose intermediate " - "reasoning shouldn't pollute the main conversation. Returns " - "only the sub-agent's final answer as text. Good for: long " - "code reviews, multi-file scans, speculative exploration. " - "Pass system_prompt to specialize role (e.g. strict reviewer, " - "researcher)." - ), - parameters={ - "type": "object", - "properties": { - "prompt": { - "type": "string", - "description": ( - "The task description the sub-agent acts on." - ), - }, - "system_prompt": { - "type": "string", - "description": ( - "Optional system prompt to specialize the sub-agent's " - "role. Leave empty to inherit parent's default." - ), - }, - "max_turns": { - "type": "integer", - "description": ( - "Cap on sub-agent loop iterations " - "(default 10, max 50)." - ), - "default": 10, - }, - }, - "required": ["prompt"], - }, - call_fn=_subagent_invoke, - ) - - # Register specialist subagent tools - _register_specialist_tools() - - -# Specialist subagent system prompts -CODE_REVIEWER_PROMPT = """You are a professional code reviewer. -When reviewing code, focus on: -1. Potential bugs and logic errors -2. Security issues (SQL injection, XSS, permission bypass, etc.) -3. Performance problems (inefficient algorithms, unnecessary loops) -4. Code style (naming, comments, readability) - -Use the read_file and search_files tools to read code. Output format: -- Critical issues (must fix) -- Suggested improvements (optional) -- Code strengths - -Report only concrete findings; avoid generic remarks.""" - -TEST_WRITER_PROMPT = """You are a professional test engineer. -Generate comprehensive test cases for the given code. Focus on: -1. Happy-path tests -2. Boundary conditions (empty, extreme, and special-character values) -3. Error handling (exceptions, failure scenarios) -4. Performance tests (where applicable) - -Use read_file to read source code and write_file to create test files. -Prefer pytest as the test framework. -Output test code only, without extra explanation.""" - -DOC_GENERATOR_PROMPT = """You are a professional technical writer. -Generate clear documentation for code. Include: -1. Overview of the module/class purpose -2. Detailed public API reference (parameters, return values, examples) -3. Usage examples -4. Caveats and limitations - -Use read_file to read source code and write_file to generate docs -(Markdown format or code comments). -Keep documentation concise and practical; avoid redundancy.""" - - -async def _specialist_invoke(specialist_type: str, task: str) -> str: - """Invoke a specialist subagent with pre-defined system prompt.""" - prompts = { - "code_reviewer": CODE_REVIEWER_PROMPT, - "test_writer": TEST_WRITER_PROMPT, - "doc_generator": DOC_GENERATOR_PROMPT, - } - system_prompt = prompts.get(specialist_type, "") - if not system_prompt: - return ( - f"Error: unknown specialist type '{specialist_type}'; " - f"options: {', '.join(prompts.keys())}" - ) - - return await _subagent_invoke( - prompt=task, - system_prompt=system_prompt, - max_turns=15, - ) - - -def _register_specialist_tools() -> None: - """Register specialist subagent tools.""" - - async def review_code(task: str) -> str: - """Review code for bugs, security issues, performance, and style.""" - return await _specialist_invoke("code_reviewer", task) - - async def write_tests(task: str) -> str: - """Generate comprehensive test cases for the given code.""" - return await _specialist_invoke("test_writer", task) - - async def generate_docs(task: str) -> str: - """Generate clear documentation for the given code.""" - return await _specialist_invoke("doc_generator", task) - - registry.register_mcp_tool( - name="review_code", - description=( - "Invoke a specialist code reviewer to find bugs, security " - "issues, performance problems, and style issues" - ), - parameters={ - "type": "object", - "properties": { - "task": { - "type": "string", - "description": ( - "review task, e.g. 'check src/auth.py for " - "security issues'" - ), - }, - }, - "required": ["task"], - }, - call_fn=review_code, - ) - registry.register_mcp_tool( - name="write_tests", - description=( - "Invoke a specialist test engineer to generate " - "comprehensive test cases for code" - ), - parameters={ - "type": "object", - "properties": { - "task": { - "type": "string", - "description": ( - "test task, e.g. 'generate tests for " "src/utils.py'" - ), - }, - }, - "required": ["task"], - }, - call_fn=write_tests, - ) - registry.register_mcp_tool( - name="generate_docs", - description=( - "Invoke a specialist doc writer to generate clear " - "documentation for code" - ), - parameters={ - "type": "object", - "properties": { - "task": { - "type": "string", - "description": ( - "doc task, e.g. 'generate API docs for " "src/api.py'" - ), - }, - }, - "required": ["task"], - }, - call_fn=generate_docs, - ) diff --git a/dashscope/acli/agents/subagents.py b/dashscope/acli/agents/subagents.py deleted file mode 100644 index f8b4ee86..00000000 --- a/dashscope/acli/agents/subagents.py +++ /dev/null @@ -1,357 +0,0 @@ -# -*- coding: utf-8 -*- -"""Subagent management — discover, enable/disable, configure subagents. - -Subagents are a subset of capabilities that function as autonomous workers -you delegate tasks to (vs. tool capabilities that the main agent calls). -Currently: local.subagent (built-in) + extension capabilities that act as -remote agents (e.g., a hosted workflow engine). - -The /subagents command provides: - - list: show all discovered subagents with status - - reload: re-scan custom-extensions.toml without restart - - enable/disable: toggle subagents (delegates to capability system) - - config: per-subagent settings (only max_turns is applied today) -""" -# pylint: disable=wrong-import-position,too-many-return-statements -# pylint: disable=too-many-branches,too-many-statements - -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from dashscope.acli.config import Config - -from rich.console import Console - -console = Console() - - -# Capability keys that are subagents (vs. tool capabilities) -SUBAGENT_CAPABILITY_KEYS = { - "local.subagent", - "local.delegate", -} - -# Config keys /subagents accepts and persists but nothing applies — only -# max_turns takes effect. See SubagentConfig for why. -INERT_CONFIG_KEYS = ("model", "temperature") -_INERT = "[dim](stored, not applied)[/dim]" - - -@dataclass -class SubagentDef: - """Discovered subagent definition.""" - - key: str - name: str - description: str - source: str # "built-in", "extension", "remote" - category: str # "local", "remote" - enabled: bool - - -def discover_subagents(config: Config) -> list[SubagentDef]: - """Scan capabilities to find subagent-type ones.""" - from dashscope.acli.cli import CAPABILITY_CATALOG, _cap_enabled - from dashscope.acli.extensions import current as ext_current - - ext = ext_current() - ext_cap_keys = {c.key for c in ext.capabilities} - - subagents = [] - for cap in CAPABILITY_CATALOG: - if cap["key"] not in SUBAGENT_CAPABILITY_KEYS: - continue - source = "extension" if cap["key"] in ext_cap_keys else "built-in" - category = "local" if cap["platform"] == "local" else "remote" - subagents.append( - SubagentDef( - key=cap["key"], - name=cap["name"], - description=f"{cap['platform']}.{cap['cap']}", - source=source, - category=category, - enabled=_cap_enabled(config, cap["key"]), - ), - ) - return subagents - - -# ===== Per-subagent config get/set ===== - - -def set_subagent_config(config: Config, key: str, **kwargs): - """Create or update a subagent's config with the supplied overrides.""" - from dashscope.acli.config import SubagentConfig - - ac = config.subagents.get(key, SubagentConfig()) - for k, v in kwargs.items(): - if hasattr(ac, k): - setattr(ac, k, v) - config.subagents[key] = ac - return ac - - -# ===== /subagents command dispatch ===== - - -def handle_subagents_command(cmd: str, config: Config) -> None: - """Handle /subagents .""" - parts = cmd.strip().split() - sub = parts[1] if len(parts) > 1 else "" - args = parts[2:] - - if not sub or sub == "list": - _subagents_list(config) - elif sub == "reload": - _subagents_reload(config) - elif sub == "enable": - _subagents_enable(config, args) - elif sub == "disable": - _subagents_disable(config, args) - elif sub == "config": - _subagents_config(config, args) - else: - console.print( - "[dim]Usage: /subagents [list|reload|enable |disable " - "|config [key value]][/dim]", - ) - - -def _subagents_list(config: Config) -> None: - """List all discovered subagents with status.""" - subagents = discover_subagents(config) - console.print("[bold]Available subagents:[/bold]") - console.print("=" * 50) - - if not subagents: - console.print("[dim] (no subagents found)[/dim]") - return - - inert_shown = False - for a in subagents: - status = "[green]✓[/green]" if a.enabled else "[dim]✗[/dim]" - cfg = config.subagents.get(a.key) - cfg_str = "" - if cfg: - parts = [] - if cfg.model: - parts.append(f"model={cfg.model}*") - inert_shown = True - if cfg.temperature: - parts.append(f"temp={cfg.temperature}*") - inert_shown = True - if cfg.max_turns: - parts.append(f"turns={cfg.max_turns}") - if parts: - cfg_str = f" [cyan][{', '.join(parts)}][/cyan]" - - console.print(f" {status} {a.key:25s} — {a.name}") - console.print( - f" [dim]{a.description} " - f"[{a.source}/{a.category}]{cfg_str}[/dim]", - ) - - if inert_shown: - console.print( - "\n[dim]* stored but not applied — a subagent runs on the " - "parent's provider.[/dim]", - ) - console.print( - "\n[dim]Usage: /subagents enable|disable|config [/dim]", - ) - - -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.extensions import apply_extensions - - ext = apply_extensions(PROVIDER_MODELS) - sync_extensions_into_catalog(ext) - console.print("[green]✓ Subagent registry reloaded[/green]") - - -def _subagents_enable(config: Config, args: list[str]) -> None: - """Enable a specific subagent.""" - if not args: - console.print("[red]Missing subagent name[/red]") - console.print("[dim]Usage: /subagents enable [/dim]") - return - - key = args[0] - if key not in SUBAGENT_CAPABILITY_KEYS: - console.print(f"[red]Unknown subagent: {key}[/red]") - console.print( - f"[dim]Options: " - f"{', '.join(sorted(SUBAGENT_CAPABILITY_KEYS))}[/dim]", - ) - return - - if config.enabled_capabilities is None: - console.print( - f"[dim]{key} already enabled (all-enabled state)[/dim]", - ) - return - - if key in config.enabled_capabilities: - console.print(f"[dim]{key} already enabled[/dim]") - return - - config.enabled_capabilities.append(key) - config.save_workspace() - from dashscope.acli.tools.platform import register_one_capability - - added = register_one_capability(config, key) - suffix = f" ({added} tools registered)" if added else "" - console.print(f"[green]✓ Enabled: {key}[/green]{suffix}") - - -def _subagents_disable(config: Config, args: list[str]) -> None: - """Disable a specific subagent.""" - if not args: - console.print("[red]Missing subagent name[/red]") - console.print("[dim]Usage: /subagents disable [/dim]") - return - - key = args[0] - if key not in SUBAGENT_CAPABILITY_KEYS: - console.print(f"[red]Unknown subagent: {key}[/red]") - console.print( - f"[dim]Options: " - f"{', '.join(sorted(SUBAGENT_CAPABILITY_KEYS))}[/dim]", - ) - return - - if config.enabled_capabilities is None: - from dashscope.acli.cli import ALL_CAPABILITY_KEYS - - config.enabled_capabilities = [ - k for k in ALL_CAPABILITY_KEYS if k != key - ] - elif key in config.enabled_capabilities: - config.enabled_capabilities.remove(key) - else: - console.print(f"[dim]{key} not enabled[/dim]") - return - - config.save_workspace() - from dashscope.acli.tools.platform import unregister_capability_tools - - removed = unregister_capability_tools(key) - console.print( - f"[yellow]✗ Disabled: {key}[/yellow] " - f"({removed} tools unregistered)", - ) - - -def _subagents_config(config: Config, args: list[str]) -> None: - """View or set per-subagent configuration. - - Syntax: - /subagents config — show current config - /subagents config — set a config value - - Supported keys: model, temperature, max_turns. Only max_turns is - applied; the other two are stored and shown as inert. - """ - if not args: - # Show all subagent configs - console.print("[bold]Subagent configs:[/bold]") - for key in SUBAGENT_CAPABILITY_KEYS: - cfg = config.subagents.get(key) - if cfg: - console.print(f" [cyan]{key}[/cyan]") - console.print( - f" model: {cfg.model or '(default)'} {_INERT}", - ) - console.print( - " temperature: " - f"{cfg.temperature or '(default)'} {_INERT}", - ) - console.print( - f" max_turns: {cfg.max_turns or '(default)'}", - ) - else: - console.print(f" [dim]{key}: (no custom config)[/dim]") - return - - key = args[0] - if key not in SUBAGENT_CAPABILITY_KEYS: - console.print(f"[red]Unknown subagent: {key}[/red]") - console.print( - f"[dim]Options: " - f"{', '.join(sorted(SUBAGENT_CAPABILITY_KEYS))}[/dim]", - ) - return - - if len(args) == 1: - # Show config for this subagent - from dashscope.acli.config import SubagentConfig - - cfg = config.subagents.get(key, SubagentConfig()) - console.print(f"[bold]{key} config:[/bold]") - console.print(f" model: {cfg.model or '(default)'} {_INERT}") - console.print( - f" temperature: {cfg.temperature or '(default)'} {_INERT}", - ) - console.print(f" max_turns: {cfg.max_turns or '(default)'}") - console.print( - f"\n[dim]Usage: /subagents config {key} " - f" [/dim]", - ) - return - - if len(args) < 3: - console.print("[red]Missing value[/red]") - console.print( - f"[dim]Usage: /subagents config {key} " - f" [/dim]", - ) - return - - cfg_key = args[1] - cfg_val = args[2] - - if cfg_key == "model": - set_subagent_config(config, key, model=cfg_val) - elif cfg_key == "temperature": - try: - temp = float(cfg_val) - if not 0.0 <= temp <= 2.0: - console.print( - "[red]temperature must be between 0.0-2.0[/red]", - ) - return - set_subagent_config(config, key, temperature=temp) - except ValueError: - console.print("[red]temperature must be a number[/red]") - return - elif cfg_key == "max_turns": - try: - turns = int(cfg_val) - if not 1 <= turns <= 100: - console.print( - "[red]max_turns must be between 1-100[/red]", - ) - return - set_subagent_config(config, key, max_turns=turns) - except ValueError: - console.print("[red]max_turns must be an integer[/red]") - return - else: - console.print(f"[red]Unknown config key: {cfg_key}[/red]") - console.print("[dim]Options: model, temperature, max_turns[/dim]") - return - - config.save_workspace() - note = f" {_INERT}" if cfg_key in INERT_CONFIG_KEYS else "" - console.print( - f"[green]✓ Updated {key}.{cfg_key} = {cfg_val}[/green]{note}", - ) diff --git a/dashscope/acli/audit.py b/dashscope/acli/audit.py deleted file mode 100644 index b4e707b6..00000000 --- a/dashscope/acli/audit.py +++ /dev/null @@ -1,252 +0,0 @@ -# -*- coding: utf-8 -*- -"""Audit logging for user input and agent decisions. - -The design doc places ``AuditLog`` in the Governance layer, receiving events -from both ``Interface`` (user input) and ``Agent`` (tool decisions). This -module provides the ``AuditEvent`` value type and ``AuditLogger`` that -persists events as JSONL to ``~/.acli/audit.log``. - -Wiring: - - User input: ``log_user_input(text)`` called from the REPL/TUI before - each turn. - - Tool decisions: ``log_tool_call(name, args, decision, reason)`` called - from ``Executor`` after permission resolution. - - Query: ``/audit query`` slash command reads back events. - -Privacy: when ``privacy_mode`` is on, the audit log still records -*decisions* (which tool was approved/denied) but redacts argument values -that may contain user data. The tool name and decision are always kept so -the audit trail remains useful for compliance. -""" - -from __future__ import annotations - -import json -from dataclasses import asdict, dataclass, field -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -from dashscope.acli.config import WORKSPACE_DIR - -_AUDIT_FILE = WORKSPACE_DIR / "audit.log" - -# Tool argument keys whose values are redacted in privacy mode. -_SENSITIVE_ARG_KEYS = frozenset( - {"content", "command", "query", "text", "prompt", "url", "path", "file"}, -) - -# Credential-like keys are redacted in full regardless of length — even a -# short token must never land in the log. -_CREDENTIAL_ARG_KEYS = frozenset( - { - "api_key", - "apikey", - "key", - "token", - "secret", - "password", - "passwd", - "authorization", - "auth", - "credential", - "cookie", - }, -) - - -@dataclass -class AuditEvent: - """One auditable action in the system.""" - - timestamp: str - source: str # "user" | "agent" | "scheduler" | "system" - action: str # "input" | "tool_call" | "permission" | "cron" | "mcp" - subject: str # tool name, user input preview, etc. - decision: str # "approved" | "denied" | "executed" | "skipped" | "auto" - reason: str = "" - details: dict[str, Any] = field(default_factory=dict) - - @classmethod - def now( - cls, - source: str, - action: str, - subject: str, - decision: str, - reason: str = "", - **details, - ) -> "AuditEvent": - return cls( - timestamp=datetime.now(timezone.utc).isoformat(), - source=source, - action=action, - subject=subject, - decision=decision, - reason=reason, - details=details, - ) - - -def _redact_args(args: dict[str, Any]) -> dict[str, Any]: - """Redact sensitive argument values for privacy mode (recursive).""" - redacted: dict[str, Any] = {} - for k, v in args.items(): - key = k.lower() - if key in _CREDENTIAL_ARG_KEYS: - redacted[k] = "…(redacted)" - elif isinstance(v, dict): - redacted[k] = _redact_args(v) - elif key in _SENSITIVE_ARG_KEYS and isinstance(v, str) and len(v) > 20: - redacted[k] = v[:20] + "…(redacted)" - else: - redacted[k] = v - return redacted - - -def _redact_text(text: str, max_len: int = 20) -> str: - """Truncate free-form text for privacy mode.""" - if len(text) <= max_len: - return text - return text[:max_len] + "…(redacted)" - - -class AuditLogger: - """Append-only JSONL audit log.""" - - def __init__(self, path: Path | None = None): - self._path = path or _AUDIT_FILE - self._privacy_mode = False - - def set_privacy_mode(self, enabled: bool) -> None: - self._privacy_mode = enabled - - def log(self, event: AuditEvent) -> None: - """Append one event. Swallows I/O errors so audit never breaks - the agent.""" - try: - self._path.parent.mkdir(parents=True, exist_ok=True) - record = asdict(event) - if self._privacy_mode: - record["details"] = _redact_args(record.get("details", {})) - if record.get("source") == "user": - record["subject"] = _redact_text(record.get("subject", "")) - with self._path.open("a", encoding="utf-8") as f: - f.write( - json.dumps(record, ensure_ascii=False, default=str) + "\n", - ) - except Exception: - pass # audit must never break the agent loop - - def log_user_input(self, text: str, preview_len: int = 100) -> None: - preview = text[:preview_len] + ("…" if len(text) > preview_len else "") - self.log(AuditEvent.now("user", "input", preview, "received")) - - def log_tool_call( - self, - tool_name: str, - args: dict[str, Any], - decision: str, - reason: str = "", - ) -> None: - self.log( - AuditEvent.now( - "agent", - "tool_call", - tool_name, - decision, - reason, - **args, - ), - ) - - def log_cron(self, job_id: str, decision: str, reason: str = "") -> None: - self.log(AuditEvent.now("scheduler", "cron", job_id, decision, reason)) - - def query( - self, - source: str | None = None, - action: str | None = None, - subject: str | None = None, - decision: str | None = None, - limit: int = 50, - ) -> list[dict[str, Any]]: - """Read back events matching the given filters.""" - if not self._path.exists(): - return [] - results: list[dict[str, Any]] = [] - try: - with self._path.open("r", encoding="utf-8") as f: - for line in f: - line = line.strip() - if not line: - continue - try: - record = json.loads(line) - except json.JSONDecodeError: - continue - if source and record.get("source") != source: - continue - if action and record.get("action") != action: - continue - if subject and record.get("subject") != subject: - continue - if decision and record.get("decision") != decision: - continue - results.append(record) - if len(results) >= limit: - break - except Exception: - pass # unreadable log file → empty result, query must not raise - return results - - def recent(self, limit: int = 20) -> list[dict[str, Any]]: - """Return the last N events (tail of the log).""" - if not self._path.exists(): - return [] - try: - lines = self._path.read_text(encoding="utf-8").strip().split("\n") - except Exception: - return [] - records: list[dict[str, Any]] = [] - for line in lines[-limit:]: - line = line.strip() - if not line: - continue - try: - records.append(json.loads(line)) - except json.JSONDecodeError: - continue - return records - - def clear(self) -> None: - try: - self._path.unlink(missing_ok=True) - except Exception: - pass # best-effort: a locked/readonly log file is not fatal - - -# ── Global instance ──────────────────────────────────────────────────── - -_logger = AuditLogger() - - -def get_audit_logger() -> AuditLogger: - return _logger - - -def set_audit_logger(logger: AuditLogger) -> None: - global _logger - _logger = logger - - -def configure_audit_logger(config) -> AuditLogger: - """Wire config settings into the global audit logger at startup. - - Re-arms privacy redaction after a restart: the logger is constructed at - import time with privacy off, so without this the persisted - ``privacy_mode = true`` would not redact until the user re-ran - ``/privacy on``. - """ - _logger.set_privacy_mode(bool(getattr(config, "privacy_mode", False))) - return _logger diff --git a/dashscope/acli/capabilities/__init__.py b/dashscope/acli/capabilities/__init__.py deleted file mode 100644 index cfd02a8c..00000000 --- a/dashscope/acli/capabilities/__init__.py +++ /dev/null @@ -1,102 +0,0 @@ -# -*- coding: utf-8 -*- -"""Capability Interface Abstraction: Decouple from Bailian. - -Abstracts cloud capabilities into pluggable interfaces. -Enables: -- Local implementations (for privacy mode, offline usage) -- Multiple providers (not just Bailian) -- Easy capability swapping - -Architecture: -- CapabilityInterface: base class with common methods -- MemoryCapability: user memory/profile operations -- CapabilityRegistry: manages active capability implementations -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import Any - - -class CapabilityInterface(ABC): - """Base interface for all capabilities.""" - - @property - @abstractmethod - def name(self) -> str: - """Capability identifier (e.g., 'bailian.mcp', 'bailian.cli').""" - - @property - @abstractmethod - def provider(self) -> str: - """Provider name (e.g., 'bailian', 'local', 'ollama').""" - - @abstractmethod - def is_available(self) -> bool: - """Check if this capability is currently available.""" - - @abstractmethod - async def initialize(self) -> None: - """Initialize the capability (connect, authenticate, etc.).""" - - @abstractmethod - async def shutdown(self) -> None: - """Clean up resources.""" - - -class MemoryCapability(CapabilityInterface): - """Interface for user memory/profile operations.""" - - @abstractmethod - async def search(self, query: str, top_k: int = 5) -> list[dict[str, Any]]: - """Search user memory for relevant information.""" - - @abstractmethod - async def add( - self, - content: str, - metadata: dict[str, Any] | None = None, - ) -> str: - """Add a memory entry. Returns entry ID.""" - - @abstractmethod - async def delete(self, entry_id: str) -> bool: - """Delete a memory entry.""" - - @abstractmethod - async def list(self, limit: int = 20) -> list[dict[str, Any]]: - """List recent memory entries.""" - - -class CapabilityRegistry: - """Registry for managing capability implementations.""" - - def __init__(self): - self._capabilities: dict[str, CapabilityInterface] = {} - - def register(self, capability: CapabilityInterface) -> None: - """Register a capability implementation.""" - self._capabilities[capability.name] = capability - - def unregister(self, name: str) -> None: - """Unregister a capability.""" - self._capabilities.pop(name, None) - - def get(self, name: str) -> CapabilityInterface | None: - """Get a registered capability by name.""" - return self._capabilities.get(name) - - -# Global registry instance -_registry = CapabilityRegistry() - - -def get_capability_registry() -> CapabilityRegistry: - """Get the global capability registry.""" - return _registry - - -def get_capability(name: str) -> CapabilityInterface | None: - """Get a capability from the global registry.""" - return _registry.get(name) diff --git a/dashscope/acli/capabilities/local_memory.py b/dashscope/acli/capabilities/local_memory.py deleted file mode 100644 index 02e8e960..00000000 --- a/dashscope/acli/capabilities/local_memory.py +++ /dev/null @@ -1,92 +0,0 @@ -# -*- coding: utf-8 -*- -"""Local Memory Capability Implementation. - -Local, privacy-first implementation of the MemoryCapability interface, -backed by LocalMemoryClient — the same store that serves /profile and -/memory. One store, one file (.acli/memory/default.json): facts stored -through the agent's memory_store tool are searchable from /profile and -vice versa. -""" - -from __future__ import annotations - -from typing import Any - -from dashscope.acli.capabilities import MemoryCapability -from dashscope.acli.platforms.local.memory import LocalMemoryClient - - -class LocalMemoryCapability(MemoryCapability): - """Local file-based memory implementation.""" - - def __init__(self, client: LocalMemoryClient | None = None): - self._client = client or LocalMemoryClient() - self._initialized = False - - @property - def name(self) -> str: - return "local.memory" - - @property - def provider(self) -> str: - return "local" - - def is_available(self) -> bool: - return True # Always available (local) - - async def initialize(self) -> None: - """Local client reads lazily per operation; nothing to warm up.""" - self._initialized = True - - async def shutdown(self) -> None: - """Writes are persisted atomically as they happen; nothing to flush.""" - - async def search(self, query: str, top_k: int = 5) -> list[dict[str, Any]]: - """Search memories by keyword matching.""" - if not query or not query.strip(): - return [] - nodes = await self._client.search(query, top_k=top_k, min_score=0.1) - return [ - { - "id": n.id, - "content": n.content, - "score": n.score, - "created_at": n.created_at, - } - for n in nodes - ] - - async def add( - self, - content: str, - metadata: dict[str, Any] | None = None, - ) -> str: - """Add a memory entry. Returns entry ID.""" - nodes = await self._client.add( - [], - custom_content=content, - metadata=metadata, - ) - if not nodes: - return "" - return nodes[0].id - - async def delete(self, entry_id: str) -> bool: - """Delete a memory entry.""" - return await self._client.delete(entry_id) - - async def list(self, limit: int = 20) -> list[dict[str, Any]]: - """List the most recent memory entries.""" - # Client paginates from the oldest entry; pull all and take the tail - # so callers see the most recent items (local store is file-backed, - # so a single wide page is fine). - nodes = await self._client.list(page_num=1, page_size=10**6) - return [ - { - "id": n.id, - "content": n.content, - "created_at": n.created_at, - "metadata": n.metadata, - } - for n in nodes[-limit:] - ] diff --git a/dashscope/acli/cli/__init__.py b/dashscope/acli/cli/__init__.py deleted file mode 100644 index aeaaf3fb..00000000 --- a/dashscope/acli/cli/__init__.py +++ /dev/null @@ -1,250 +0,0 @@ -# -*- coding: utf-8 -*- -# pylint: disable=wrong-import-position,unused-import -# pylint: disable=too-many-branches,too-many-statements -from __future__ import annotations - -import asyncio -import sys -import warnings - -warnings.filterwarnings("ignore", message=".*urllib3.*OpenSSL.*") - -from rich.console import Console # noqa: E402 - -import dashscope.acli.tools.browser # noqa: F401,E402 -import dashscope.acli.tools.camera # noqa: F401,E402 - -# Ensure tools are registered by importing -import dashscope.acli.tools.filesystem # noqa: F401,E402 -import dashscope.acli.tools.shell # noqa: F401,E402 -import dashscope.acli.tools.web_search # noqa: F401,E402 -from dashscope.acli import __version__ # noqa: E402 -from dashscope.acli.config import PROVIDER_MODELS, Config # noqa: F401,E402 -from dashscope.acli.dev import handle_dev_command # noqa: F401,E402 -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, -) - -# Import constants and multimodal handling from submodules -from dashscope.acli.cli.constants import ( # noqa: F401,E402 - _AT_AUDIO_MAX_BYTES, - _AT_IMAGE_MAX_BYTES, - _AT_PATH_AT_CURSOR_RE, - _DEV_SUBCOMMANDS, - _PATH_COMPLETION_LIMIT, - _SUBCOMMANDS, - _TOP_LEVEL_COMMANDS, - ALL_CAPABILITY_KEYS, - CAPABILITY_CATALOG, -) -from dashscope.acli.cli.multimodal import ( # noqa: F401,E402 - _expand_at_references, - _to_multimodal_content, -) - -console = Console() -from dashscope.acli.cli.dispatch import ( # noqa: F401,E402 - _handle_skill_continue, - _handle_slash_command, - dispatch_async_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 _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.repl import _run_loop # noqa: E402 -from dashscope.acli.cli.runners import ( # noqa: E402 - _run_dry_run, - _run_oneshot, - _run_tui_mode, -) -from dashscope.acli.cli.startup import ( # noqa: F401,E402 - _compose_system_prompt, - _load_system_prompt, -) -from dashscope.acli.cli.streaming import ( # noqa: F401,E402 - _do_compress, - _do_summarize, -) - -# Cron scheduler -_scheduler = None - - -def _apply_cli_overrides( - config: Config, - protocol_override: str | None, - max_turns_override: str | None, -) -> None: - """Apply --protocol/--max-turns to a loaded config; exit on bad input.""" - if protocol_override: - if protocol_override.lower() in ("openai", "anthropic"): - config.protocol = protocol_override.lower() - else: - print( - f"Error: unknown protocol '{protocol_override}' " - f"(choices: openai, anthropic)", - ) - sys.exit(1) - - if max_turns_override is not None: - try: - config.max_turns = int(max_turns_override) - except ValueError: - print( - f"Error: --max-turns must be an integer, got " - f"'{max_turns_override}'", - ) - sys.exit(1) - - -def main(): - # Parse --cli / --tui / --dry-run flags (can appear anywhere in argv) - use_cli = "--cli" in sys.argv - use_tui = "--tui" in sys.argv - use_dry_run = "--dry-run" in sys.argv - sys.argv = [ - a for a in sys.argv if a not in ("--cli", "--tui", "--dry-run") - ] - - # Parse --protocol flag (can appear anywhere in argv) - protocol_override = None - max_turns_override = None - new_argv = [] - i = 0 - while i < len(sys.argv): - if sys.argv[i] == "--protocol" and i + 1 < len(sys.argv): - protocol_override = sys.argv[i + 1] - i += 2 - continue - if sys.argv[i] == "--max-turns" and i + 1 < len(sys.argv): - max_turns_override = sys.argv[i + 1] - i += 2 - continue - new_argv.append(sys.argv[i]) - i += 1 - sys.argv = new_argv - - if len(sys.argv) > 1: - arg = sys.argv[1] - if arg in ("--version", "-v", "-V"): - print(f"acli {__version__}") - return - elif arg in ("--help", "-h"): - print( - "Usage: acli [--version] [--help] [-c ] " - "[--cli | --tui] [--dry-run]", - ) - print() - print("Chat is action — describe what you need, AI executes it.") - print() - print("Options:") - print( - " -c, --command Run a single prompt, then " - "exit (good for scripts/pipes)", - ) - print( - " --cli Use the classic readline " - "REPL mode", - ) - print( - " --tui " - "Use the Textual rich UI mode (default read from " - "config.toml)", - ) - print( - " --protocol API protocol " - "(openai | anthropic)", - ) - print( - " --max-turns Override max conversation " - "turns (default from config: 50)", - ) - print( - " --dry-run Preview current config (loaded " - "skills, MCP, tools, etc.) without starting", - ) - print() - print("Subcommands:") - print( - " example List available examples", - ) - print( - " example download Flat-merge an example " - "into the current directory (conflicts auto-backed-up)", - ) - print( - " mcp-server " - "Start an MCP Server (exposes tools over stdio)", - ) - print() - print("Type /help after startup to see built-in commands.") - return - elif arg in ("-c", "--command"): - prompt = " ".join(sys.argv[2:]) if len(sys.argv) > 2 else "" - if not prompt: - print("Error: -c requires a prompt argument") - print('Usage: acli -c "your request"') - sys.exit(1) - config = Config.load() - # This branch returns before main()'s override tail, so the - # flags must be applied here or oneshot silently ignores them. - _apply_cli_overrides( - config, - protocol_override, - max_turns_override, - ) - asyncio.run(_run_oneshot(config, prompt)) - return - elif arg in ("example", "examples"): - _handle_example_command(sys.argv[2:]) - return - elif arg == "mcp-server": - from dashscope.acli.mcp_server import main as _mcp_server_main - - _mcp_server_main() - return - else: - # Only a bare `acli` and the explicit -c/--command one-shot enter - # the agent — the same contract the dashscope CLI enforces. An - # unrecognized token used to fall through and start a chat, which - # discarded the whole command line: `acli auth whoami` opened the - # agent instead of reporting that no such command exists. Exit 2 - # matches the usage-error code typer/click use for the same case. - print(f"Error: No such command '{arg}'.") - print("Try 'acli --help' for help.") - sys.exit(2) - config = Config.load() - _apply_cli_overrides(config, protocol_override, max_turns_override) - - # Handle --dry-run: preview configuration without starting the agent - if use_dry_run: - _run_dry_run(config) - return - - try: - if use_cli: - asyncio.run(_run_loop(config)) - elif use_tui: - _run_tui_mode(config) - elif config.tui: - _run_tui_mode(config) - else: - asyncio.run(_run_loop(config)) - except (KeyboardInterrupt, SystemExit): - pass diff --git a/dashscope/acli/cli/completer.py b/dashscope/acli/cli/completer.py deleted file mode 100644 index a5b08552..00000000 --- a/dashscope/acli/cli/completer.py +++ /dev/null @@ -1,316 +0,0 @@ -# -*- coding: utf-8 -*- -"""Prompt completion and history management.""" -# pylint: disable=too-many-branches,too-many-return-statements,unused-argument - -from __future__ import annotations - -import os -from pathlib import Path -from typing import TYPE_CHECKING - -from prompt_toolkit.completion import Completer, Completion -from prompt_toolkit.formatted_text import StyleAndTextTuples -from prompt_toolkit.history import FileHistory -from prompt_toolkit.layout.processors import Processor, Transformation - -from dashscope.acli.cli.constants import ( - _ARG_HINTS, - _AT_PATH_AT_CURSOR_RE, - _DEV_SUBCOMMANDS, - _PATH_COMPLETION_LIMIT, - _SECRET_PATTERN, - _SUBCOMMANDS, - _TOP_LEVEL_COMMANDS, - CAPABILITY_CATALOG, -) -from dashscope.acli.config import Config -from dashscope.acli.skills import BUILTIN_SKILLS, KNOWN_MCP_SERVICES -from dashscope.acli.utils import sanitize_text - -if TYPE_CHECKING: - pass - - -class SafeFileHistory(FileHistory): - """FileHistory that redacts API keys before saving.""" - - def store_string(self, string: str) -> None: - sanitized = _SECRET_PATTERN.sub(r"\1***", string) - sanitized = sanitize_text(sanitized) - # Ensure parent directory exists (FileHistory silently drops on - # failure) - Path(self.filename).parent.mkdir(parents=True, exist_ok=True) - super().store_string(sanitized) - - -def _get_arg_hint(text: str) -> str: - """Given current input text, return the ghost hint to show (or empty).""" - stripped = text.lstrip() - if not stripped.startswith("/"): - return "" - if not text.endswith(" "): - return "" - tokens = stripped.split() - arg_index = len(tokens) - if not tokens: - return "" - cmd = tokens[0] - sub = tokens[1] if len(tokens) >= 2 else None - hint = _ARG_HINTS.get((cmd, sub, arg_index), "") - if not hint: - hint = _ARG_HINTS.get((cmd, None, arg_index), "") - return hint - - -class _HintProcessor(Processor): - """Appends dim ghost text at end-of-line showing expected arguments.""" - - def apply_transformation(self, transformation_input): - doc = transformation_input.document - lineno = transformation_input.lineno - if lineno != 0: - return Transformation(transformation_input.fragments) - - text = doc.text_before_cursor - hint = _get_arg_hint(text) - if hint: - fragments: StyleAndTextTuples = transformation_input.fragments + [ - ("class:hint", hint), - ] - return Transformation(fragments) - return Transformation(transformation_input.fragments) - - -def _is_dir_safe(entry) -> bool: - """is_dir() with PermissionError / broken-symlink swallowed.""" - try: - return entry.is_dir() - except OSError: - return False - - -class AcliCompleter(Completer): - """Context-aware completer for the REPL prompt. - - Two completion modes share one entry: - - - ``/`` at line start triggers slash-command completion (commands, - sub-commands, arg-slot values like model / capability / mcp service). - Reads live state (``config``, ``CAPABILITY_CATALOG``, extension - registry, ``_mcp_clients`` …) on every keystroke. - - - ``@`` anywhere in the line triggers filesystem - completion against that path's directory portion — typing ``@`` alone - lists cwd, ``@../`` lists the parent, ``@~/`` lists home, etc. - Directories get a trailing ``/`` so you can keep drilling in. - - Plain natural-language input gets nothing — the popup never gets in - the way of normal prompts. - """ - - def __init__(self, config: Config): - self.config = config - - def get_completions(self, document, complete_event): - text = document.text_before_cursor - - # @path completion takes priority — works even inside natural - # language (e.g., `check @src/acli/cli.py`). - m = _AT_PATH_AT_CURSOR_RE.search(text) - if m: - yield from self._path_completions(m.group(1)) - return - - stripped = text.lstrip() - if not stripped.startswith("/"): - return - - # Distinguish "user is mid-word" vs "user just hit space, starting a - # fresh arg slot". split() loses the trailing-whitespace signal, so - # check the raw text first. - ends_with_space = text != "" and text[-1] in (" ", "\t") - tokens = stripped.split() - - if ends_with_space: - current = "" - arg_index = len(tokens) - else: - current = tokens[-1] if tokens else "" - arg_index = max(0, len(tokens) - 1) - - candidates = self._slot_candidates(tokens, arg_index) - for c in candidates: - if c.startswith(current): - yield Completion(c, start_position=-len(current)) - - def _path_completions(self, raw_path: str): - """List entries of the directory implied by ``raw_path`` (the - substring captured after ``@``). Returns ``Completion`` objects - whose insertion text is just the filename — prompt_toolkit - replaces the prefix-before-cursor, so the rest of the typed path - stays put. Directories get a trailing ``/``.""" - # Decompose raw_path into (directory_to_list, name_prefix_to_match). - # Cases handled: - # "" -> cwd, no prefix - # "src/" -> src/, no prefix - # "src/a" -> src/, prefix="a" - # "../" -> parent dir, no prefix - # ".." -> cwd, prefix=".." (so `..` and `../` get suggested) - # "~" -> home, no prefix - # "~/x" -> home, prefix="x" - sep = os.sep - if raw_path == "": - dir_str, prefix = ".", "" - elif raw_path.endswith("/") or raw_path.endswith(sep): - dir_str, prefix = raw_path, "" - elif "/" in raw_path or sep in raw_path: - last = max(raw_path.rfind("/"), raw_path.rfind(sep)) - dir_str, prefix = raw_path[: last + 1], raw_path[last + 1 :] - elif raw_path in (".", "..", "~"): - # Bare `.`, `..` or `~` — list that directory directly so - # `@..` shows the parent immediately without needing the user - # to add the trailing `/`. - dir_str, prefix = raw_path + sep, "" - else: - dir_str, prefix = ".", raw_path - - directory = Path(dir_str).expanduser() - if not directory.is_dir(): - return - - try: - # Materialize but cap size — directories like node_modules - # would otherwise lag the popup. iterdir() is unsorted; we - # sort dirs-first / case-insensitive-by-name for stable UX. - entries = [] - for entry in directory.iterdir(): - entries.append(entry) - if len(entries) >= _PATH_COMPLETION_LIMIT * 4: - break - except (OSError, PermissionError): - return - entries.sort(key=lambda e: (not _is_dir_safe(e), e.name.lower())) - - shown = 0 - for entry in entries: - if shown >= _PATH_COMPLETION_LIMIT: - break - name = entry.name - # Hide dotfiles unless the user is explicitly typing a `.` prefix. - if name.startswith(".") and not prefix.startswith("."): - continue - if not name.startswith(prefix): - continue - is_dir = _is_dir_safe(entry) - insertion = name + sep if is_dir else name - yield Completion( - insertion, - start_position=-len(prefix), - display=insertion, - display_meta="dir" if is_dir else "file", - ) - shown += 1 - - def _slot_candidates(self, tokens: list[str], arg_index: int) -> list[str]: - # Import runtime state here to avoid circular imports - from dashscope.acli.cli import _mcp_clients, _scheduler - - if arg_index == 0: - return _TOP_LEVEL_COMMANDS - - cmd = tokens[0] - - if cmd == "/skill" and arg_index == 1: - return _SUBCOMMANDS["/skill"] + list(BUILTIN_SKILLS.keys()) - - if cmd == "/capability": - if arg_index == 1: - return _SUBCOMMANDS["/capability"] - if arg_index == 2: - sub = tokens[1] - all_keys = [c["key"] for c in CAPABILITY_CATALOG] - enabled = ( - set(self.config.enabled_capabilities) - if self.config.enabled_capabilities is not None - else set(all_keys) - ) - if sub == "enable": - return [k for k in all_keys if k not in enabled] - if sub == "disable": - return [k for k in all_keys if k in enabled] - if sub == "config": - return list(self.config.subagents.keys()) - if arg_index == 3 and tokens[1] == "config": - return ["model", "temperature", "max_turns"] - return [] - - if cmd == "/subagents": - from dashscope.acli.agents.subagents import ( - SUBAGENT_CAPABILITY_KEYS, - ) - - if arg_index == 1: - return _SUBCOMMANDS["/subagents"] - if arg_index == 2: - sub = tokens[1] - if sub in ("enable", "disable", "config"): - return sorted(SUBAGENT_CAPABILITY_KEYS) - if arg_index == 3 and tokens[1] == "config": - return ["model", "temperature", "max_turns"] - return [] - - if cmd == "/mcp": - if arg_index == 1: - return _SUBCOMMANDS["/mcp"] - if arg_index == 2: - if tokens[1] == "add": - return list(KNOWN_MCP_SERVICES.keys()) - if tokens[1] == "remove": - return list(_mcp_clients.keys()) - return [] - - if cmd == "/dev": - if arg_index == 1: - return _SUBCOMMANDS["/dev"] - if arg_index == 2: - return _DEV_SUBCOMMANDS.get(tokens[1], []) - if arg_index == 3 and tokens[2] == "rm": - from dashscope.acli.extensions import current as ext_current - - if tokens[1] == "model": - return list(self.config.custom_models) - if tokens[1] == "provider": - return [p.name for p in ext_current().providers] - if tokens[1] == "capability": - return [c.key for c in ext_current().capabilities] - return [] - - if cmd == "/cron": - if arg_index == 1: - return _SUBCOMMANDS["/cron"] - sub = tokens[1] if len(tokens) > 1 else "" - if sub in ("remove", "pause", "resume") and arg_index == 2: - if _scheduler: - return list(_scheduler.jobs.keys()) - return [] - if sub == "add": - prev = tokens[arg_index - 1] if arg_index > 1 else "" - if prev == "skill": - return list(BUILTIN_SKILLS.keys()) - if prev in ("every", "at", "cron"): - return [] - return [ - "every", - "at", - "cron", - "skill", - "condition", - "no-subagent", - ] - return [] - - # Generic fallback: any command listed in _SUBCOMMANDS gets its - # second-level keywords offered at arg_index 1. - if arg_index == 1 and cmd in _SUBCOMMANDS: - return _SUBCOMMANDS[cmd] - return [] diff --git a/dashscope/acli/cli/constants.py b/dashscope/acli/cli/constants.py deleted file mode 100644 index 6433f9cb..00000000 --- a/dashscope/acli/cli/constants.py +++ /dev/null @@ -1,320 +0,0 @@ -# -*- coding: utf-8 -*- -"""Constants and configuration data for acli CLI.""" - -from __future__ import annotations - -import re -from typing import Any - -# API key target configurations -KEY_TARGETS = { - "tongyi": { - "field": "tongyi_api_key", - "env": "DASHSCOPE_API_KEY", - "scope": "global", - "desc": "Tongyi Qwen / Bailian", - }, - "anthropic": { - "field": "anthropic_api_key", - "env": "ANTHROPIC_API_KEY", - "scope": "global", - "desc": "Anthropic Claude", - }, - "openai": { - "field": "openai_api_key", - "env": "OPENAI_API_KEY", - "scope": "global", - "desc": "OpenAI", - }, -} - -# ASR model options -ASR_MODELS = [ - "paraformer-realtime-v2", - "paraformer-realtime-v1", - "sensevoice-v1", -] - -# Theme presets -THEME_PRESETS = { - "dark": { - "background": "#1e1e1e", - "text": "#d4d4d4", - "border": "ansi_bright_blue", - "border_style": "solid", - "accent": "#569cd6", - "muted": "#6a6a6a", - "panel_border": "bright_blue", - }, - "light": { - "background": "#ffffff", - "text": "#1e1e1e", - "border": "ansi_blue", - "border_style": "solid", - "accent": "#007acc", - "muted": "#595959", - "panel_border": "blue", - }, - "monokai": { - "background": "#272822", - "text": "#f8f8f2", - "border": "#a6e22e", - "border_style": "solid", - "accent": "#a6e22e", - "muted": "#75715e", - "panel_border": "#a6e22e", - }, - "dracula": { - "background": "#282a36", - "text": "#f8f8f2", - "border": "#bd93f9", - "border_style": "solid", - "accent": "#bd93f9", - "muted": "#6272a4", - "panel_border": "#bd93f9", - }, - "nord": { - "background": "#2e3440", - "text": "#d8dee9", - "border": "#88c0d0", - "border_style": "solid", - "accent": "#88c0d0", - "muted": "#4c566a", - "panel_border": "#88c0d0", - }, - "solarized": { - "background": "#002b36", - "text": "#839496", - "border": "#268bd2", - "border_style": "solid", - "accent": "#268bd2", - "muted": "#586e75", - "panel_border": "#268bd2", - }, -} - -# Capability catalog -CAPABILITY_CATALOG: list[dict[str, Any]] = [ - { - "key": "bailian.mcp", - "name": "Cloud tool extensions", - "platform": "bailian", - "cap": "mcp", - "requires": ["tongyi_api_key"], - }, - { - "key": "bailian.cli", - "name": "Bailian CLI suite (bl)", - "platform": "bailian", - "cap": "cli", - "requires": [], - }, - { - "key": "local.subagent", - "name": "Subagent (isolated context)", - "platform": "local", - "cap": "subagent", - "requires": [], - }, - { - "key": "local.delegate", - "name": "Delegate (parallel subagents)", - "platform": "local", - "cap": "delegate", - "requires": [], - }, - { - "key": "local.memory", - "name": "Long-term memory (memory_search/store)", - "platform": "local", - "cap": "memory", - "requires": [], - }, -] - -ALL_CAPABILITY_KEYS = [c["key"] for c in CAPABILITY_CATALOG] - -# Regex patterns for sanitization and parsing -_SECRET_PATTERN = re.compile( - r"(/(?:provider|key)\s+\w+\s+)\S+", -) - -# Matches @ where path is anything that looks like a file -_AT_FILE_PATTERN = re.compile(r"@([^\s,;'\"()<>{}\[\]]+)") - -# Cap per-file content inlined into the prompt -_AT_FILE_MAX_CHARS = 20000 - -# Cap per-image bytes before base64 -_AT_IMAGE_MAX_BYTES = 5 * 1024 * 1024 # 5 MB - -# Image extensions → MIME type -_IMAGE_MIME = { - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".bmp": "image/bmp", -} - -# Audio extensions → MIME type and OpenAI input_audio format name -_AUDIO_MIME = { - ".mp3": ("audio/mp3", "mp3"), - ".wav": ("audio/wav", "wav"), - ".m4a": ("audio/m4a", "mp3"), - ".ogg": ("audio/ogg", "ogg"), - ".webm": ("audio/webm", "webm"), - ".flac": ("audio/flac", "flac"), -} - -# Cap per-audio bytes before base64 -_AT_AUDIO_MAX_BYTES = 10 * 1024 * 1024 # 10 MB - -# Matches image or audio sentinels inside expanded text -_MEDIA_SENTINEL_RE = re.compile(r"<<__ACLI_(IMG|AUDIO)_(\d+)__>>") - -# Top-level slash commands -_TOP_LEVEL_COMMANDS = [ - "/help", - "/clear", - "/info", - "/copy", - "/compress", - "/summarize", - "/stats", - "/report", - "/feedback", - "/history", - "/undo", - "/json", - "/save", - "/privacy", - "/audit", - "/directives", - "/exit", - "/voice", - "/tts", - "/camera", - "/provider", - "/model", - "/setup", - "/capability", - "/subagents", - "/trust", - "/rule", - "/skill", - "/profile", - "/memory", - "/session", - "/mcp", - "/cron", - "/dev", - "/debug", - "/log", - "/trace", - "/theme", - "/example", -] - -# Static second-level subcommands -_SUBCOMMANDS: dict[str, list[str]] = { - "/example": ["list", "download", "restore"], - "/capability": ["list", "enable", "disable", "reload", "config"], - "/subagents": ["list", "reload", "enable", "disable", "config"], - "/trust": ["list", "clear", "allow", "deny"], - "/rule": ["list", "add", "remove", "edit", "clear"], - "/profile": ["list", "search", "add", "remove", "clear"], - "/memory": ["list", "search", "remove", "clear"], - "/session": ["new", "list", "switch", "rename", "fork", "remove"], - "/mcp": ["list", "add", "remove"], - "/cron": ["add", "list", "remove", "pause", "resume"], - "/feedback": ["good", "bad"], - "/history": ["stats", "list", "search", "export", "clear"], - "/json": ["on", "off"], - "/privacy": ["on", "off", "status"], - "/audit": ["recent", "query", "clear"], - "/directives": ["add", "rm", "clear", "proposals", "accept", "reject"], - "/skill": [ - "list", - "add", - "remove", - "install", - "uninstall", - "enable", - "disable", - "update", - "search", - "publish", - "link", - ], - "/dev": [ - "model", - "provider", - "capability", - "platform", - "tool", - "skill", - "debug", - "test", - "reload", - "log", - ], - "/camera": ["capture", "record"], - "/debug": ["on", "off", "status"], - "/log": ["tail", "search", "clear"], - "/trace": ["tail", "search", "clear"], - "/voice": ["on", "off", "status", "model", "silence", "max", "threshold"], - "/tts": ["on", "off", "status", "model", "voice", "speed", "say", "last"], - "/theme": [ - "list", - "set", - "background", - "text", - "border", - "accent", - "muted", - "border_style", - ], -} - -_DEV_SUBCOMMANDS: dict[str, list[str]] = { - "model": ["list", "add", "remove"], - "provider": ["list", "add", "remove"], - "capability": ["list", "add", "remove"], - "skill": ["list", "add", "remove"], - "tool": ["list", "add", "remove"], - "debug": ["tools", "schema", "call", "prompt"], - "test": ["provider"], -} - -# Completion-related patterns -_AT_PATH_AT_CURSOR_RE = re.compile(r"@([^\s]*)$") -_PATH_COMPLETION_LIMIT = 300 -# Argument hints: dim ghost text appended after cursor to guide -# free-form input. -# Key = (command, subcommand_or_None, arg_index) → hint text. -_ARG_HINTS: dict[tuple, str] = { - ("/camera", "capture", 2): " ", - ("/camera", "record", 2): " ", - ("/camera", "record", 3): " ", - ("/voice", "model", 2): " ", - ("/voice", "silence", 2): " ", - ("/voice", "max", 2): " ", - ("/voice", "threshold", 2): " ", - ("/tts", "model", 2): " ", - ("/tts", "voice", 2): " ", - ("/tts", "speed", 2): " <0.5-2.0>", - ("/tts", "say", 2): " ", - ("/rule", "add", 2): ' ""', - ("/rule", "remove", 2): " ", - ("/rule", "edit", 2): " ", - ("/profile", "search", 2): " ", - ("/profile", "add", 2): " ", - ("/profile", "remove", 2): " ", - ("/memory", "search", 2): " ", - ("/memory", "remove", 2): " ", - ("/mcp", "add", 2): " ", - ("/mcp", "remove", 2): " ", - ("/skill", None, 2): " ", -} diff --git a/dashscope/acli/cli/dispatch.py b/dashscope/acli/cli/dispatch.py deleted file mode 100644 index 02b7f607..00000000 --- a/dashscope/acli/cli/dispatch.py +++ /dev/null @@ -1,560 +0,0 @@ -# -*- coding: utf-8 -*- -"""Command dispatch functions.""" -# pylint: disable=too-many-return-statements,too-many-branches -# pylint: disable=too-many-statements,too-many-nested-blocks - -from __future__ import annotations - -import sys - -from rich.console import Console - -from dashscope.acli.agent import Agent -from dashscope.acli.cli.handlers_config import ( - _handle_debug_command, - _handle_directives_command, - _handle_log_command, - _handle_privacy_command, - _handle_rule_command, - _handle_theme_command, - _handle_trace_command, -) -from dashscope.acli.cli.handlers_device import ( - _handle_camera_command, - _handle_tts_command, - _handle_voice_command, -) -from dashscope.acli.cli.handlers_misc import ( - _handle_history_command, - _handle_report_command, - _handle_trust_command, -) -from dashscope.acli.cli.handlers_session import _handle_session_command -from dashscope.acli.commands import render_help_text -from dashscope.acli.config import Config -from dashscope.acli.dev import handle_dev_command - -console = Console() - - -def _handle_slash_command( - cmd: str, - agent: Agent, - config: Config, -) -> bool | str: - """Handle slash commands. - - Returns True if handled, 'async' if needs async handling, 'voice' for - voice input, False otherwise. - """ - cmd = cmd.strip() - if cmd == "/v": - return "voice" - if cmd == "/voice" or cmd.startswith("/voice "): - voice_result = _handle_voice_command(cmd, config) - if voice_result == "voice": - return "voice" - return True - if cmd == "/tts" or cmd.startswith("/tts "): - _handle_tts_command(cmd, config, agent) - return True - if cmd == "/camera" or cmd.startswith("/camera "): - _handle_camera_command(cmd) - return True - if cmd in ("/exit", "/quit", "/q"): - console.print("[dim]Bye![/dim]") - sys.exit(0) - elif cmd == "/copy": - import dashscope.acli.cli as _cli - - _cons = _cli.console - if not agent.last_output: - _cons.print("[yellow]Nothing to copy[/yellow]") - return True - import shutil - import subprocess as _sp - - if shutil.which("pbcopy"): - _sp.run(["pbcopy"], input=agent.last_output.encode(), check=False) - elif shutil.which("xclip"): - _sp.run( - ["xclip", "-selection", "clipboard"], - input=agent.last_output.encode(), - check=False, - ) - elif shutil.which("clip"): - _sp.run(["clip"], input=agent.last_output.encode(), check=False) - else: - _cons.print( - "[yellow]No clipboard tool found " - "(pbcopy/xclip/clip)[/yellow]", - ) - return True - _cons.print( - f"[green]✓ Copied {len(agent.last_output)} " - f"chars to clipboard[/green]", - ) - return True - elif cmd == "/json" or cmd.startswith("/json "): - parts = cmd.strip().split() - if len(parts) >= 2: - arg = parts[1].lower() - if arg in ("on", "true", "1"): - agent.json_mode = True - console.print( - "[green]✓ JSON output mode on " - "(replies forced to JSON)[/green]", - ) - elif arg in ("off", "false", "0"): - agent.json_mode = False - console.print("[green]✓ JSON output mode off[/green]") - else: - console.print("[dim]Usage: /json on|off[/dim]") - else: - state = "on" if agent.json_mode else "off" - console.print(f"[bold]JSON output mode: {state}[/bold]") - console.print( - "[dim]Usage: /json on|off — when on, " - "replies are forced to JSON[/dim]", - ) - return True - elif cmd == "/save" or cmd.startswith("/save "): - parts = cmd.strip().split(None, 1) - if not agent.last_output: - console.print("[yellow]Nothing to save[/yellow]") - return True - save_path = parts[1].strip() if len(parts) > 1 else None - if not save_path: - import datetime - - save_path = ( - f"acli_output_" - f"{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.md" - ) - try: - from pathlib import Path - - p = Path(save_path) - p.write_text(agent.last_output, encoding="utf-8") - console.print( - f"[green]✓ Saved {len(agent.last_output)} " - f"chars to: {p}[/green]", - ) - except Exception as e: - console.print(f"[red]Save failed: {e}[/red]") - return True - elif cmd == "/clear": - agent.reset() - # Persist empty history so the cleared state survives restart. - if agent.session_path: - agent.save_session() - console.print("[dim]Conversation cleared[/dim]") - return True - elif cmd == "/compress": - if len(agent.messages) < 4: - console.print( - "[yellow]Conversation too short to compress[/yellow]", - ) - return True - return "compress" - elif cmd == "/info": - from dashscope.acli.config import CONFIG_FILE, WORKSPACE_CONFIG_FILE - from dashscope.acli.extensions import ( - GLOBAL_EXTENSIONS_FILE, - WORKSPACE_EXTENSIONS_FILE, - ) - - console.print() - console.print("[bold]ℹ️ Current runtime info[/bold]") - console.print( - f" Provider: [cyan]" - f"{agent.provider_name or config.provider}[/cyan]", - ) - console.print( - f" Model: [cyan]{agent.model_name or config.model}[/cyan]", - ) - console.print( - f" Protocol: [cyan]{config.protocol or 'auto'}[/cyan]", - ) - # Resolve actual base URL from provider profile - from dashscope.acli.providers.profile import build_profiles_from_config - - profiles = build_profiles_from_config(config) - actual_base_url = profiles[0].base_url if profiles else config.base_url - console.print( - f" Base URL: [cyan]{actual_base_url or '(default)'}[/cyan]", - ) - console.print(f" User: [cyan]{config.user_name}[/cyan]") - console.print( - f" Memory: " - f"{'[green]on' if config.memory_enabled else '[dim]off'}[/]", - ) - console.print( - f" Capabilities: [cyan]" - f"{', '.join(config.enabled_capabilities or []) or '(none)'}" - f"[/cyan]", - ) - console.print( - f" JSON mode: " - f"{'[green]on' if agent.json_mode else '[dim]off'}[/]", - ) - loop_label = config.loop_mode - if loop_label not in ("", "auto"): - loop_label += " [yellow](legacy; inactive)[/yellow]" - console.print(f" Loop mode: [cyan]{loop_label}[/cyan]") - console.print(f" Max turns: [cyan]{config.max_turns}[/cyan]") - # config.timeout is the LLM request timeout; run_command enforces its - # own, so showing one "Timeout" line reported a limit nothing used. - from dashscope.acli.tools.shell import default_timeout - - console.print(f" LLM timeout: [cyan]{config.timeout}s[/cyan]") - console.print( - f" Cmd timeout: [cyan]{default_timeout()}s " - f"(ACLI_COMMAND_TIMEOUT)[/cyan]", - ) - console.print( - f" TUI: {'[green]on' if config.tui else '[dim]off'}[/]", - ) - console.print( - f" Privacy: " - f"{'[green]on' if config.privacy_mode else '[dim]off'}[/]", - ) - - console.print("\n[bold]Config files[/bold]") - for label, path in [ - ("Global config", CONFIG_FILE), - ("Workspace config", WORKSPACE_CONFIG_FILE), - ("Global extensions", GLOBAL_EXTENSIONS_FILE), - ("Workspace extensions", WORKSPACE_EXTENSIONS_FILE), - ]: - if path.exists(): - console.print(f" [green]✓[/green] {label}: [dim]{path}[/dim]") - else: - console.print(f" [dim]✗ {label}: {path}[/dim]") - console.print() - return True - elif cmd == "/stats": - stats = ( - agent.executor.get_stats() if hasattr(agent, "executor") else {} - ) - console.print() - console.print("[bold]📊 Session stats[/bold]") - console.print(f" Provider: [cyan]{agent.provider_name}[/cyan]") - console.print(f" Model: [cyan]{agent.model_name}[/cyan]") - - # Token usage - token_usage = stats.get("token_usage", {}) - if token_usage.get("total_tokens", 0) > 0: - cached = token_usage.get("cached_tokens", 0) - cached_part = f", cached: {cached}" if cached else "" - console.print( - f" Tokens: [green]{token_usage['total_tokens']}[/green]" - f" (in: {token_usage['input_tokens']}," - f" out: {token_usage['output_tokens']}{cached_part})", - ) - - # Prompt composition (chars sent per LLM call, cumulative) - prompt_comp = stats.get("prompt_composition", {}) - comp_total = sum(prompt_comp.values()) - if comp_total > 0: - console.print(f" Prompt mix: [green]{comp_total}[/green] chars") - for key, label in [ - ("system", "system"), - ("user", "user"), - ("assistant", "assistant"), - ("tools", "tools"), - ]: - v = prompt_comp.get(key, 0) - if v: - console.print( - f" [dim]{label}[/dim] → {v} " - f"({v * 100 // comp_total}%)", - ) - - # API calls - api_calls = stats.get("api_calls", 0) - if api_calls > 0: - console.print(f" API calls: [blue]{api_calls}[/blue]") - - # Tool calls - console.print( - f" Tool calls: [yellow]" - f"{stats.get('total_tool_calls', 0)}[/yellow]", - ) - tool_counts = stats.get("tool_counts", {}) - if tool_counts: - console.print(" Tool breakdown:") - for name, count in sorted( - tool_counts.items(), - key=lambda x: -x[1], - ): - console.print(f" [dim]{name}[/dim] → {count}") - - # Skill activations - skill_calls = stats.get("skill_calls", 0) - if skill_calls > 0: - console.print(f" Skill calls: [magenta]{skill_calls}[/magenta]") - skill_counts = stats.get("skill_counts", {}) - if skill_counts: - console.print(" Skill breakdown:") - for name, count in sorted( - skill_counts.items(), - key=lambda x: -x[1], - ): - console.print(f" [dim]{name}[/dim] → {count}") - - # Errors - errors = stats.get("errors", 0) - if errors > 0: - console.print(f" Errors: [red]{errors}[/red]") - - # Session duration - duration = stats.get("session_duration", 0) - if duration > 0: - minutes = int(duration // 60) - seconds = int(duration % 60) - if minutes > 0: - console.print( - f" Duration: [magenta]{minutes}m{seconds}s[/magenta]", - ) - else: - console.print(f" Duration: [magenta]{seconds}s[/magenta]") - - console.print() - return True - elif cmd in ("/feedback good", "/feedback bad"): - outcome = "success" if "good" in cmd else "failure" - tracker = getattr(agent, "experience_tracker", None) - if tracker and agent.last_output: - # Get recent tools used - tools_used = [] - for msg in reversed(agent.messages): - if msg.get("role") == "assistant" and "tool_calls" in msg: - for tc in msg["tool_calls"]: - if "function" in tc: - tools_used.append(tc["function"]["name"]) - break - - task_summary = ( - agent.messages[-2]["content"][:100] - if len(agent.messages) > 1 - else "user task" - ) - lesson = ( - "User feedback: satisfied" - if outcome == "success" - else "User feedback: unsatisfied" - ) - tracker.record_experience( - task_summary=task_summary, - tools_used=tools_used, - outcome=outcome, - lesson=lesson, - ) - console.print(f"[green]Feedback recorded: {outcome}[/green]") - else: - console.print("[yellow]No task result to record[/yellow]") - return True - elif cmd == "/summarize": - return "summarize" - elif cmd == "/report": - _handle_report_command(agent) - return True - elif cmd == "/help": - console.print(render_help_text()) - return True - elif cmd.startswith("/provider"): - from dashscope.acli.cli.handlers_provider import ( - handle_provider_command, - ) - - handle_provider_command(cmd, agent, config) - return True - elif cmd.startswith("/model"): - from dashscope.acli.cli.handlers_provider import ( - handle_model_command, - ) - - handle_model_command(cmd, agent, config) - return True - elif cmd.startswith("/dev test provider") or cmd.startswith( - "/dev debug call", - ): - # Provider connectivity test and manual tool call must run async; the - # synchronous dev handler would try to start a nested event loop and - # crash inside the TUI. - return "async" - elif cmd.startswith("/dev"): - handle_dev_command(cmd, config) - return True - elif cmd.startswith("/trust"): - _handle_trust_command(cmd, agent) - return True - elif cmd.startswith("/rule"): - _handle_rule_command(cmd, config) - return True - elif cmd.startswith("/skill"): - return "skill" - elif cmd.startswith("/mcp"): - return "async" - elif cmd.startswith("/profile"): - return "async" - elif cmd.startswith("/memory"): - return "async" - elif cmd.startswith("/session"): - _handle_session_command(cmd, config, agent) - return True - elif cmd.startswith("/setup"): - return "async" - elif cmd.startswith("/capability"): - return "async" - elif cmd.startswith("/subagents"): - from dashscope.acli.agents.subagents import handle_subagents_command - - handle_subagents_command(cmd, config) - return True - elif cmd.startswith("/cron"): - return "async" - elif cmd.startswith("/theme"): - _handle_theme_command(cmd, config) - return True - 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 - elif cmd.startswith("/debug"): - _handle_debug_command(cmd, config) - return True - elif cmd.startswith("/log"): - _handle_log_command(cmd, agent) - return True - elif cmd.startswith("/trace"): - _handle_trace_command(cmd, agent) - return True - elif cmd.startswith("/directives"): - _handle_directives_command(cmd, config) - return True - elif cmd.startswith("/example"): - from dashscope.acli.cli.examples import _handle_example_command - - args = cmd.split()[1:] - _handle_example_command(args) - return True - elif cmd.startswith("/audit"): - from dashscope.acli.audit import get_audit_logger - - logger = get_audit_logger() - parts = cmd.strip().split(None, 1) - sub = parts[1].strip() if len(parts) > 1 else "recent" - sub_tokens = sub.split() - action = sub_tokens[0] if sub_tokens else "recent" - if action == "recent": - limit = 20 - if len(sub_tokens) > 1: - try: - limit = int(sub_tokens[1]) - if limit <= 0: - raise ValueError - except ValueError: - console.print( - "[yellow]Usage: /audit recent [N] " - "(N = positive integer)[/yellow]", - ) - return True - events = logger.recent(limit=limit) - elif action == "clear": - logger.clear() - console.print("[green]✓ Audit log cleared[/green]") - return True - else: - events = logger.query(limit=50) - if not events: - console.print("[dim]No audit records[/dim]") - return True - console.print(f"[bold]Audit log ({len(events)} entries)[/bold]") - for ev in events: - ts = ev.get("timestamp", "")[:19] - src = ev.get("source", "") - act = ev.get("action", "") - subj = ev.get("subject", "")[:50] - dec = ev.get("decision", "") - console.print( - f" [dim]{ts}[/dim] [{src}] {act}: {subj} → " - f"[yellow]{dec}[/yellow]", - ) - return True - return False - - -async def dispatch_async_command( - cmd: str, - config: Config, - agent: Agent, -) -> None: - """Dispatch async slash commands that need awaitable handlers. - - Centralizes the prefix-based routing previously duplicated between the - CLI main loop and the TUI `_handle_async_command` method. - """ - from dashscope.acli.cli.handlers_capability import ( - _handle_capability_command, - _require_capability, - ) - from dashscope.acli.cli.handlers_profile import ( - _handle_memory_command, - _handle_profile_command, - ) - from dashscope.acli.cli.handlers_setup import _handle_setup - from dashscope.acli.cli.handlers_skill import _handle_cron_command - from dashscope.acli.cli.mcp import _handle_mcp_command - - if cmd.startswith("/profile"): - await _handle_profile_command(cmd, config) - elif cmd.startswith("/memory"): - await _handle_memory_command(cmd) - elif cmd.startswith("/setup"): - await _handle_setup(config, agent) - elif cmd.startswith("/capability"): - _handle_capability_command(cmd, config) - elif cmd.startswith("/cron"): - await _handle_cron_command(cmd, config, agent) - elif cmd.startswith("/dev test provider"): - parts = cmd.strip().split() - if len(parts) >= 4: - from dashscope.acli.dev import _test_provider - - await _test_provider(parts[3], config) - else: - # No provider name — let the sync handler print usage. - handle_dev_command(cmd, config) - elif cmd.startswith("/dev debug call"): - from dashscope.acli.dev import _debug_call - - await _debug_call(cmd.strip().split()) - elif cmd.startswith("/dev"): - handle_dev_command(cmd, config) - else: - if _require_capability(config, "bailian.mcp"): - await _handle_mcp_command(cmd, config) - - -async def _handle_skill_continue( - cmd: str, - config: Config, - agent=None, -) -> str | None: - """Handle /skill: render the skill prompt if applicable, else return None. - - Used by both CLI and TUI so the `/skill` branch is not duplicated. - """ - from dashscope.acli.cli.handlers_skill import _handle_skill_command - - skill_result = await _handle_skill_command(cmd, config, agent) - return skill_result if skill_result else None diff --git a/dashscope/acli/cli/examples.py b/dashscope/acli/cli/examples.py deleted file mode 100644 index bcd66335..00000000 --- a/dashscope/acli/cli/examples.py +++ /dev/null @@ -1,510 +0,0 @@ -# -*- coding: utf-8 -*- -"""Example management functions.""" -# pylint: disable=too-many-branches,too-many-return-statements -# pylint: disable=too-many-statements - -from __future__ import annotations - -import re -import shutil -import subprocess -import sys -import tempfile -from pathlib import Path - -from rich.console import Console - -console = Console() - -_DEFAULT_EXAMPLES_REPO = "" # set via examples_repo in ~/.acli/config.toml -_DEFAULT_EXAMPLES_BRANCH = "main" - -_KNOWN_EXAMPLES = [ - "basic-chat", - "dashscope-sdk-expert", -] - - -def _get_examples_dir() -> Path: - """Return a local examples checkout if present (dev mode only).""" - project_root = Path(__file__).resolve().parent.parent.parent.parent - packaged = Path(__file__).resolve().parent.parent / "examples" - for candidate in ( - project_root / "examples", - packaged, - ): - if candidate.is_dir(): - return candidate - return packaged - - -def _get_configured_repo() -> tuple[str, str]: - try: - from dashscope.acli.config import Config - - cfg = Config.load() - return ( - cfg.examples_repo or _DEFAULT_EXAMPLES_REPO, - cfg.examples_branch or _DEFAULT_EXAMPLES_BRANCH, - ) - except Exception: - return _DEFAULT_EXAMPLES_REPO, _DEFAULT_EXAMPLES_BRANCH - - -def _list_examples() -> list[str]: - examples_dir = _get_examples_dir() - if not examples_dir.is_dir(): - return [] - return sorted( - d.name - for d in examples_dir.iterdir() - if d.is_dir() and not d.name.startswith((".", "_")) - ) - - -def _clone_example_to_temp( - name: str, - repo: str, - branch: str, - tmp: Path, -) -> Path: - """Sparse-clone from the examples repo into tmp.""" - try: - subprocess.run( - [ - "git", - "clone", - "--depth", - "1", - "--filter=blob:none", - "--sparse", - "--no-checkout", - "-b", - branch, - repo, - str(tmp), - ], - check=True, - capture_output=True, - text=True, - ) - subprocess.run( - [ - "git", - "-C", - str(tmp), - "sparse-checkout", - "set", - name, - ], - check=True, - capture_output=True, - text=True, - ) - subprocess.run( - ["git", "-C", str(tmp), "checkout"], - check=True, - capture_output=True, - text=True, - ) - except subprocess.CalledProcessError as e: - err = (e.stderr or "").strip() - raise RuntimeError(f"git operation failed: {err or e}") from e - except FileNotFoundError: - raise RuntimeError("git not found; please install git") from None - - src = tmp / name - if not src.is_dir(): - raise FileNotFoundError(f"example {name} not found in the repo") - return src - - -def _list_remote_examples(repo: str, branch: str) -> list[str]: - """List top-level example dirs in the remote repo via a blob-less clone.""" - with tempfile.TemporaryDirectory() as tmp: - subprocess.run( - [ - "git", - "clone", - "--depth", - "1", - "--filter=blob:none", - "--no-checkout", - "-b", - branch, - repo, - tmp, - ], - check=True, - capture_output=True, - text=True, - ) - out = subprocess.run( - ["git", "-C", tmp, "ls-tree", "-d", "--name-only", "HEAD"], - check=True, - capture_output=True, - text=True, - ).stdout - return sorted(d for d in out.splitlines() if not d.startswith((".", "_"))) - - -def _save_examples_repo(repo: str) -> None: - """Write examples_repo into the global ~/.acli/config.toml (create or - replace).""" - from dashscope.acli.config import CONFIG_DIR, CONFIG_FILE - from dashscope.acli.utils.paths import atomic_write_text - from dashscope.acli.utils.toml import toml_str - - CONFIG_DIR.mkdir(parents=True, exist_ok=True) - text = ( - CONFIG_FILE.read_text(encoding="utf-8") if CONFIG_FILE.exists() else "" - ) - line = f"examples_repo = {toml_str(repo)}" - if re.search(r"(?m)^examples_repo\s*=", text): - text = re.sub(r"(?m)^examples_repo\s*=.*$", line, text) - else: - if text and not text.endswith("\n"): - text += "\n" - text += line + "\n" - atomic_write_text(CONFIG_FILE, text) - - -def _example_files(src: Path) -> list[Path]: - """All copyable files under an example dir (relative-safe, no junk).""" - return [ - p - for p in sorted(src.rglob("*")) - if p.is_file() - and "__pycache__" not in p.parts - and ".git" not in p.parts - and p.suffix != ".pyc" - ] - - -def _copy_example_flat(src: Path, dst: Path, *, force: bool) -> bool: - """Merge the example into dst/.acli/, backing up conflicting files. - - The example's own .acli/ tree merges as-is; root-level files (README, - build scripts) land in .acli/ too; the standalone-repo root .gitignore is - skipped. Without --force: TTY lists conflicts and asks (default Yes); - non-TTY aborts. Conflicting files are copied to .acli/backup/ first - (single rolling backup, replaced on each download) so the last merge - can be rolled back. - """ - acli_dir = dst / ".acli" - copies: list[ - tuple[Path, Path] - ] = [] # (source file, path relative to dst/.acli) - for p in _example_files(src): - rel = p.relative_to(src) - if rel.parts[0] == ".acli": - rel = rel.relative_to(".acli") - elif rel.name == ".gitignore" and len(rel.parts) == 1: - continue - copies.append((p, rel)) - - conflicts = [(p, rel) for p, rel in copies if (acli_dir / rel).exists()] - - if conflicts and not force: - listing = "\n".join(f" .acli/{rel}" for _, rel in conflicts) - console.print( - f"[yellow]These files already exist; continuing will " - f"overwrite them (originals are auto-backed up):[/yellow]\n" - f"{listing}", - ) - if not sys.stdin.isatty(): - console.print( - "[yellow]Conflicts found in non-interactive mode; " - "cancelled.[/yellow]", - ) - console.print( - "[dim]Pass --force to overwrite (conflict files are " - "auto-backed up).[/dim]", - ) - return False - try: - answer = input("Continue? [Y/n] ").strip().lower() - except (EOFError, KeyboardInterrupt): - console.print() - return False - if answer in ("n", "no"): - console.print("[dim]Cancelled.[/dim]") - return False - - backup_dir: Path | None = None - if conflicts: - backup_dir = acli_dir / "backup" - if backup_dir.exists(): - shutil.rmtree(backup_dir) # single rolling backup: latest only - for _, rel in conflicts: - old = acli_dir / rel - bdst = backup_dir / rel - bdst.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(old, bdst) - - acli_dir.mkdir(parents=True, exist_ok=True) - for p, rel in copies: - target = acli_dir / rel - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(p, target) - - if backup_dir is not None: - manifest = "\n".join(str(Path(".acli") / rel) for _, rel in copies) - (backup_dir / ".manifest").write_text( - manifest + "\n", - encoding="utf-8", - ) - - console.print(f"[green]✓ Example copied to: {acli_dir}[/green]") - if src.name == "dashscope-sdk-expert": - from dashscope.acli.cli.handlers_key import _GUIDE_DOC, _doc_locale - - console.print( - f"[dim]Guide: {_GUIDE_DOC.format(_doc_locale())}[/dim]", - ) - if backup_dir is not None: - console.print( - f"[dim]Overwritten files backed up to: {backup_dir} " - f"(undo with /example restore)[/dim]", - ) - readmes = sorted( - ( - acli_dir / rel - for _, rel in copies - if rel.name.lower().startswith("readme") - ), - key=lambda p: (len(p.parts), str(p)), - ) - if readmes: - console.print(f"[dim]See README for usage: {readmes[0]}[/dim]") - console.print( - "[dim]Edit config and skills under .acli/ " - "to customize your Agent[/dim]", - ) - console.print( - "[dim]Run /setup to personalize, /help for all commands[/dim]", - ) - return True - - -def _prune_empty_dirs(path: Path, stop: Path) -> None: - while path != stop and path.is_dir(): - try: - path.rmdir() - except OSError: - break - path = path.parent - - -def _restore_example_backup(dst: Path) -> bool: - """Restore the single .acli/backup: originals copied back, added files - removed.""" - backup = dst / ".acli" / "backup" - if not backup.is_dir(): - console.print( - "[yellow]No restorable backup found (.acli/backup/).[/yellow]", - ) - return False - - removed = 0 - manifest = backup / ".manifest" - if manifest.is_file(): - for line in manifest.read_text(encoding="utf-8").splitlines(): - rel = line.strip() - if not rel: - continue - backup_rel = Path(rel).relative_to(".acli") - if (backup / backup_rel).exists(): - continue # overwritten files are restored below - target = dst / rel - if target.is_file(): - target.unlink() - removed += 1 - _prune_empty_dirs(target.parent, dst) - - restored = 0 - acli_dir = dst / ".acli" - for p in sorted(backup.rglob("*")): - if not p.is_file() or p.name == ".manifest": - continue - rel = p.relative_to(backup) - target = acli_dir / rel - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(p, target) - restored += 1 - - shutil.rmtree(backup) - _prune_empty_dirs(backup.parent, dst) - console.print( - f"[green]✓ Restored ({restored} original files restored, " - f"{removed} example-added files removed)[/green]", - ) - return True - - -def _print_example_list(): - examples_dir = _get_examples_dir() - available = _list_examples() - if not available: - repo, branch = _get_configured_repo() - if repo: - try: - available = _list_remote_examples(repo, branch) - except Exception: - available = [] - if not available: - available = _KNOWN_EXAMPLES - console.print("[bold]Available examples:[/bold]") - for name in available: - readme = examples_dir / name / "README.md" - desc = "" - if readme.exists(): - first_line = readme.read_text(encoding="utf-8").split("\n", 1)[0] - desc = first_line.lstrip("# ").strip() - console.print(f" [cyan]{name}[/cyan] — {desc}") - console.print() - console.print("[bold]Usage:[/bold]") - console.print( - " acli example download " - "[dim]# merge into ./.acli/ (conflicts backed up)[/dim]", - ) - console.print( - " acli example download --target " - "[dim]# merge into the given directory[/dim]", - ) - console.print( - " acli example download --force " - "[dim]# overwrite without asking (still backed up)[/dim]", - ) - console.print( - " acli example download --repo " - "[dim]# use this repo and save it to config[/dim]", - ) - console.print( - " acli example restore " - "[dim]# restore the latest backup (undo merge)[/dim]", - ) - - -def _handle_example_command(args): - """Handle `acli example [list | download ]`.""" - examples_dir = _get_examples_dir() - - if not args: - _print_example_list() - return - - sub = args[0] - - if sub in ("list", "--list", "-l"): - _print_example_list() - return - - # restore [--target DIR] - if sub == "restore": - target_dir = Path.cwd() - if "--target" in args or "-t" in args: - idx = ( - args.index("--target") - if "--target" in args - else args.index("-t") - ) - if idx + 1 < len(args): - target_dir = Path(args[idx + 1]) - _restore_example_backup(target_dir) - return - - # download [--target DIR] [--force] - if sub == "download": - if len(args) < 2: - console.print( - "[yellow]Error: download requires an example name[/yellow]", - ) - available = _list_examples() or _KNOWN_EXAMPLES - console.print(f"[dim]Available: {', '.join(available)}[/dim]") - return - name = args[1] - target_dir = Path.cwd() - if "--target" in args or "-t" in args: - idx = ( - args.index("--target") - if "--target" in args - else args.index("-t") - ) - if idx + 1 < len(args): - target_dir = Path(args[idx + 1]) - force = "--force" in args or "-f" in args - provided = None - if "--repo" in args: - idx = args.index("--repo") - if idx + 1 < len(args): - provided = args[idx + 1] - - src = examples_dir / name - if src.is_dir(): - _copy_example_flat(src, target_dir, force=force) - return - - repo, branch = _get_configured_repo() - if not repo and not provided and sys.stdin.isatty(): - console.print("[yellow]No examples repo configured.[/yellow]") - try: - provided = input( - "Enter the examples git repo URL (empty to cancel): ", - ).strip() - except (EOFError, KeyboardInterrupt): - console.print() - return - if not provided: - console.print("[dim]Cancelled.[/dim]") - return - repo = provided or repo - if not repo: - console.print( - "[yellow]No examples repo configured; " - "cannot download.[/yellow]", - ) - console.print( - "Pass one directly (auto-saved to ~/.acli/config.toml):", - ) - console.print( - f" acli example download {name} --repo ", - ) - console.print("Or add it manually to ~/.acli/config.toml:") - console.print(' examples_repo = ""') - return - if provided: - _save_examples_repo(provided) - console.print( - "[dim]Saved examples_repo to ~/.acli/config.toml[/dim]", - ) - console.print( - f"[dim]Downloading example '{name}' from {repo}...[/dim]", - ) - try: - with tempfile.TemporaryDirectory() as tmp: - fetched = _clone_example_to_temp(name, repo, branch, Path(tmp)) - _copy_example_flat(fetched, target_dir, force=force) - except (RuntimeError, FileNotFoundError, OSError) as e: - console.print(f"[yellow]Error: {e}[/yellow]") - console.print(f"[dim]Repo: {repo} (branch: {branch})[/dim]") - console.print( - "[dim]You can set examples_repo in " - "~/.acli/config.toml[/dim]", - ) - return - - # Unknown subcommand — show hint - console.print(f"[yellow]Unknown subcommand: '{sub}'[/yellow]") - console.print("[dim]Usage:[/dim]") - console.print( - " acli example [dim]# list examples[/dim]", - ) - console.print( - " acli example download " - "[dim]# download an example[/dim]", - ) - console.print( - " acli example restore " - "[dim]# restore the latest backup[/dim]", - ) diff --git a/dashscope/acli/cli/handlers_capability.py b/dashscope/acli/cli/handlers_capability.py deleted file mode 100644 index 464870aa..00000000 --- a/dashscope/acli/cli/handlers_capability.py +++ /dev/null @@ -1,306 +0,0 @@ -# -*- coding: utf-8 -*- -"""Capability management command handlers.""" -# pylint: disable=too-many-return-statements,too-many-branches -# pylint: disable=too-many-statements - -from __future__ import annotations - -from rich.console import Console - -from dashscope.acli.cli.constants import ( - ALL_CAPABILITY_KEYS, - CAPABILITY_CATALOG, -) -from dashscope.acli.config import PROVIDER_MODELS, Config - -console = Console() - - -def sync_extensions_into_catalog(ext) -> None: - """Fold extension capabilities from custom-extensions.toml into - CAPABILITY_CATALOG / ALL_CAPABILITY_KEYS so /capability list / enable - / disable see them as first-class entries alongside built-ins. - - Called from _run_loop at startup AND from dev._hot_reload after any - /dev capability add|remove — so newly added caps are immediately - eligible for /capability enable without restarting. - - Idempotent + removal-aware: caps not in `ext.capabilities` but - currently in the catalog as extension entries get dropped (covers - /dev capability remove). - """ - from dashscope.acli.tools.platform import _CAPABILITY_KEYS as _BUILTIN_KEYS - from dashscope.acli.tools.platform import unregister_capability_tools - - ext_keys = {c.key for c in ext.capabilities} - - # Remove extension entries that no longer exist - for i in range(len(CAPABILITY_CATALOG) - 1, -1, -1): - cap = CAPABILITY_CATALOG[i] - if cap["key"] in _BUILTIN_KEYS: - continue # built-ins stay - if cap["key"] not in ext_keys: - removed = CAPABILITY_CATALOG.pop(i) - if removed["key"] in ALL_CAPABILITY_KEYS: - ALL_CAPABILITY_KEYS.remove(removed["key"]) - # Cap vanished from toml (/dev capability rm): drop its tools too, - # otherwise they stay registered until restart or manual disable. - unregister_capability_tools(removed["key"]) - - # Add new ones - for cap in ext.capabilities: - if cap.key in ALL_CAPABILITY_KEYS: - continue - platform_part, _, cap_part = cap.key.partition(".") - CAPABILITY_CATALOG.append( - { - "key": cap.key, - "name": cap.display or cap.key, - "platform": platform_part, - "cap": cap_part or cap.key, - # creds checked at HTTP call time, not registration - "requires": [], - }, - ) - ALL_CAPABILITY_KEYS.append(cap.key) - - -def _cap_enabled(config: Config, cap_key: str) -> bool: - """Check if a platform capability is enabled. - - None = all enabled (not configured). - When privacy_mode is True, all cloud capabilities are disabled.""" - if config.privacy_mode: - return False - if config.enabled_capabilities is None: - return True - return cap_key in config.enabled_capabilities - - -def _require_capability(config: Config, cap_key: str) -> bool: - """Gate a slash command on a platform capability. - - Returns True when the capability is enabled (or all enabled). Otherwise - prints a hint with the available ways to enable it and returns False. - """ - if _cap_enabled(config, cap_key): - return True - cap_name = next( - (c["name"] for c in CAPABILITY_CATALOG if c["key"] == cap_key), - cap_key, - ) - console.print( - f"[yellow]Capability not enabled: {cap_key} " - f"({cap_name})[/yellow]\n" - f"[dim]Ways to enable:[/dim]\n" - f"[dim] /capability enable {cap_key} — enable now, " - f"prompting for credentials as needed[/dim]\n" - f"[dim] /setup — re-select " - f"capabilities[/dim]\n" - f"[dim] /capability list — view all " - f"capability states[/dim]", - ) - return False - - -def _prompt_missing_config(config: Config, requires: list[str]): - """Prompt user for missing config fields, return True if all filled.""" - import getpass - - from dashscope.acli.utils.sanitizer import is_secret_field - - for field_name in requires: - current = getattr(config, field_name, "") - if not current: - try: - if is_secret_field(field_name): - value = getpass.getpass(f" Enter {field_name}: ").strip() - else: - value = input(f" Enter {field_name}: ").strip() - except (EOFError, KeyboardInterrupt): - value = "" - if not value: - return False - setattr(config, field_name, value) - return True - - -def _handle_capability_command(cmd: str, config: Config): - """Handle /capability command — list/enable/disable capabilities.""" - parts = cmd.strip().split() - sub = parts[1] if len(parts) > 1 else "" - - if not sub or sub == "list": - console.print("[bold]Capability status:[/bold]") - for cap in CAPABILITY_CATALOG: - enabled = _cap_enabled(config, cap["key"]) - status = "[green]✓[/green]" if enabled else "[dim]✗[/dim]" - console.print(f" {status} {cap['key']:20s} — {cap['name']}") - return - - if sub == "enable" and len(parts) > 2: - cap_key = parts[2] - if cap_key not in ALL_CAPABILITY_KEYS: - console.print(f"[red]Unknown capability: {cap_key}[/red]") - console.print( - f"[dim]Options: {', '.join(ALL_CAPABILITY_KEYS)}[/dim]", - ) - return - if config.enabled_capabilities is None: - console.print("[dim]All capabilities currently enabled[/dim]") - return - if cap_key in config.enabled_capabilities: - # 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, - ) - - _maybe_prompt_extension_token(cap_key, config) - from dashscope.acli.cli.mcp import _connect_mcp - from dashscope.acli.tools.platform import ( - register_one_capability, - unregister_capability_tools, - ) - - # Drop stale registrations first so extension tool closures rebind - # to the (possibly just-entered) credentials. - unregister_capability_tools(cap_key) - added = register_one_capability( - config, - cap_key, - connect_mcp_fn=_connect_mcp, - ) - suffix = f" ({added} tools registered)" if added else "" - console.print(f"[dim]{cap_key} already enabled[/dim]{suffix}") - return - cap_info = next(c for c in CAPABILITY_CATALOG if c["key"] == cap_key) - missing = [ - f for f in cap_info["requires"] if not getattr(config, f, "") - ] - if missing: - console.print( - f"[yellow]{cap_info['key']} needs " f"configuration:[/yellow]", - ) - if not _prompt_missing_config(config, cap_info["requires"]): - console.print("[red]Config incomplete; not enabled[/red]") - return - - # Extension-capability bearer/apikey-header token - from dashscope.acli.cli.handlers_key import ( - _maybe_prompt_extension_token, - ) - - _maybe_prompt_extension_token(cap_key, config) - - config.enabled_capabilities.append(cap_key) - config.save_workspace() - from dashscope.acli.cli.mcp import _connect_mcp - from dashscope.acli.tools.platform import register_one_capability - - added = register_one_capability( - config, - cap_key, - connect_mcp_fn=_connect_mcp, - ) - suffix = ( - f" ({added} tools registered)" - if added - else " (no tools registered — credentials may be missing)" - ) - console.print(f"[green]✓ Enabled: {cap_key}[/green]{suffix}") - return - - if sub == "disable" and len(parts) > 2: - cap_key = parts[2] - if cap_key not in ALL_CAPABILITY_KEYS: - console.print(f"[red]Unknown capability: {cap_key}[/red]") - console.print( - f"[dim]Options: {', '.join(ALL_CAPABILITY_KEYS)}[/dim]", - ) - return - if config.enabled_capabilities is None: - config.enabled_capabilities = [ - k for k in ALL_CAPABILITY_KEYS if k != cap_key - ] - elif cap_key in config.enabled_capabilities: - config.enabled_capabilities.remove(cap_key) - else: - console.print(f"[dim]{cap_key} not enabled[/dim]") - return - config.save_workspace() - from dashscope.acli.tools.platform import unregister_capability_tools - - removed = unregister_capability_tools(cap_key) - console.print( - f"[yellow]✗ Disabled: {cap_key}[/yellow] " - f"({removed} tools unregistered)", - ) - return - - if sub in ("reload", "refresh"): - from dashscope.acli.extensions import apply_extensions - from dashscope.acli.tools.platform import ( - refresh_extension_capability_tools, - ) - - ext = apply_extensions(PROVIDER_MODELS) - sync_extensions_into_catalog(ext) - # Re-register enabled extension caps so tool closures rebind to the - # freshly loaded toml (endpoint/auth/token edits take effect now). - refreshed = refresh_extension_capability_tools(config) - console.print( - f"[green]✓ Capability registry reloaded[/green] " - f"({refreshed} extension tools refreshed)", - ) - return - - if sub == "config": - args = parts[2:] - if not args: - console.print("[bold]Capability config:[/bold]") - console.print( - "[dim]Use /subagents config [/dim]", - ) - console.print( - "[dim]Supported: " + ", ".join(ALL_CAPABILITY_KEYS) + "[/dim]", - ) - return - # Delegate to subagents config for subagent-type capabilities - from dashscope.acli.agents.subagents import ( - SUBAGENT_CAPABILITY_KEYS, - handle_subagents_command, - ) - - cap_key = args[0] - if cap_key in SUBAGENT_CAPABILITY_KEYS: - # Rewrite as /subagents config - handle_subagents_command( - f"/subagents config {' '.join(args)}", - config, - ) - else: - console.print( - f"[dim]{cap_key} is not a configurable subagent; " - f"use /subagents config[/dim]", - ) - return - - # User typed a known cap without an action — suggest enable/disable for it - if sub in ALL_CAPABILITY_KEYS: - currently_enabled = _cap_enabled(config, sub) - action_hint = "disable" if currently_enabled else "enable" - console.print( - f"[yellow]Missing action: enable or disable[/yellow]\n" - f"[dim]Current {sub}: " - f"{'enabled' if currently_enabled else 'disabled'}[/dim]\n" - f"[dim]e.g.: /capability {action_hint} {sub}[/dim]", - ) - return - - console.print( - r"[dim]Usage: /capability \[list|enable |disable |" - r"reload|config ][/dim]" + "\n" - f"[dim]Available caps: {', '.join(ALL_CAPABILITY_KEYS)}[/dim]", - ) diff --git a/dashscope/acli/cli/handlers_config.py b/dashscope/acli/cli/handlers_config.py deleted file mode 100644 index 868cb62e..00000000 --- a/dashscope/acli/cli/handlers_config.py +++ /dev/null @@ -1,850 +0,0 @@ -# -*- coding: utf-8 -*- -"""Configuration-related command handlers (privacy, theme, directives, -rule).""" -# pylint: disable=too-many-branches,too-many-return-statements -# pylint: disable=too-many-statements,unused-argument - -from __future__ import annotations - -import copy -import json - -from rich.console import Console - -from dashscope.acli.cli.constants import THEME_PRESETS -from dashscope.acli.config import Config - -console = Console() - - -def _handle_privacy_command(cmd: str, config: Config) -> None: - """Manage privacy mode.""" - parts = cmd.strip().split() - - def _print_status(): - status = "🔒 Enabled" if config.privacy_mode else "🔓 Disabled" - console.print(f"[bold]Privacy mode:[/bold] {status}") - if config.privacy_mode: - console.print( - " [dim]✓ Cloud capabilities disabled " - "(profile, kb, cloud memory, mcp)[/dim]", - ) - console.print(" [dim]✓ Scheduled tasks (cron) paused[/dim]") - console.print( - " [dim]✓ Audit log redacted (tool args and user input " - "truncated)[/dim]", - ) - console.print( - " [yellow]⚠ LLM API calls are uncontrolled; " - "conversation content is still sent to the model[/yellow]", - ) - - if len(parts) < 2: - _print_status() - return - - sub = parts[1].lower() - - if sub == "on": - config.privacy_mode = True - config.save_workspace() - from dashscope.acli.audit import get_audit_logger - - 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.registry import registry - - removed = unregister_cloud_capability_tools() - mcp_names = [ - t.name for t in registry.list_tools() if t.name.startswith("mcp_") - ] - for name in mcp_names: - registry.unregister(name) - console.print("[green]✓ Privacy mode enabled[/green]") - console.print( - " [dim]Cloud capabilities disabled · cron paused · " - "audit redacted[/dim]", - ) - if removed or mcp_names: - console.print( - f" [dim]Revoked {removed + len(mcp_names)} " - f"cloud tools[/dim]", - ) - console.print(" [yellow]Note: LLM API calls uncontrolled[/yellow]") - return - - if sub == "off": - config.privacy_mode = False - config.save_workspace() - from dashscope.acli.audit import get_audit_logger - - get_audit_logger().set_privacy_mode(False) - # Re-register platform tools so enabled capabilities (incl. cloud) - # are usable again without a restart. - from dashscope.acli.cli.mcp import _connect_mcp - from dashscope.acli.tools.platform import register_platform_tools - - register_platform_tools(config, connect_mcp_fn=_connect_mcp) - console.print("[green]✓ Privacy mode disabled[/green]") - return - - if sub == "status": - _print_status() - return - - console.print("[dim]Usage: /privacy [on|off|status][/dim]") - - -def _handle_debug_command(cmd: str, config: Config) -> None: - """Toggle debug mode: record full LLM request payloads in the session - trace.""" - from dashscope.acli import debuglog - - parts = cmd.strip().split() - sub = parts[1].lower() if len(parts) > 1 else "status" - - if sub == "on": - config.debug = True - config.save_workspace() - debuglog.set_debug_enabled(True) - console.print("[green]✓ Debug mode enabled[/green]") - console.print( - " [dim]Full prompts will be recorded to the session trace " - "(/log, /trace to view)[/dim]", - ) - return - - if sub == "off": - config.debug = False - config.save_workspace() - debuglog.set_debug_enabled(False) - console.print("[green]✓ Debug mode disabled[/green]") - return - - if sub == "status": - status = "Enabled" if config.debug else "Disabled" - console.print(f"[bold]Debug mode:[/bold] {status}") - if config.debug: - console.print( - " [dim]Full prompts of every LLM call are recorded to " - "the session trace (/log, /trace to view)[/dim]", - ) - return - - console.print("[dim]Usage: /debug [on|off|status][/dim]") - - -def _messages_text(msgs: list, *, full: bool) -> str: - lines: list[str] = [] - for m in msgs: - role = m.get("role", "?") - content = m.get("content") - if content is None: - content = "" - elif not isinstance(content, str): - content = json.dumps(content, ensure_ascii=False, default=str) - if not full and len(content) > 500: - content = content[:500] + " …(truncated)" - lines.append(f"\n[{role}]\n{content}") - tool_calls = m.get("tool_calls") - if tool_calls: - calls = json.dumps(tool_calls, ensure_ascii=False, default=str) - lines.append(f" tool_calls: {calls}") - return "\n".join(lines) - - -def _usage_text(usage) -> str: - if not isinstance(usage, dict): - return "" - parts = [] - inp = usage.get("input_tokens") or usage.get("prompt_tokens") - if inp: - s = f"↑{inp}" - if usage.get("cached_tokens"): - s += f" ({usage['cached_tokens']} cached)" - parts.append(s) - out = usage.get("output_tokens") or usage.get("completion_tokens") - if out: - parts.append(f"↓{out}") - return " ".join(parts) - - -def _log_record_text(rec: dict, idx: int, *, full: bool) -> str: - """Render one llm_call trace event; truncate message bodies unless full.""" - ts = str(rec.get("timestamp", ""))[:19].replace("T", " ") - model = rec.get("model", "?") - msgs = rec.get("messages", []) - header = f"━━━ #{idx} {ts} model={model} messages={len(msgs)}" - dur = rec.get("duration_ms", 0) - if dur: - header += f" {dur / 1000:.1f}s" - usage = _usage_text(rec.get("usage")) - if usage: - header += f" {usage}" - header += " ━━━" - return header + _messages_text(msgs, full=full) - - -def _can_page() -> bool: - """True only when the module console writes to a real terminal (CLI mode). - - In the TUI the console is captured into a buffer, and a pager would fight - Textual for the screen. - """ - import sys - - return console.file is sys.stdout and sys.stdout.isatty() - - -def _page_text(text: str) -> None: - """Page text through less (scroll ↑/↓, search with /); print inline - if unavailable. - - rich's console.pager() routes through pydoc, whose no-arg `(less)` probe - fails on macOS ("Missing filename") and degrades to a plain dump — so - invoke less directly on piped stdin instead. - """ - import subprocess - - try: - proc = subprocess.run( - ["less", "-R"], - input=text.encode("utf-8"), - stderr=subprocess.DEVNULL, - check=False, - ) - except (FileNotFoundError, OSError): - proc = None - if proc is None or proc.returncode != 0: - console.print(text, markup=False, highlight=False) - - -def _trace_events(agent, event_type: str | None = None) -> list[dict]: - tracer = ( - getattr(agent, "trace_logger", None) if agent is not None else None - ) - if tracer is None: - return [] - events = list(tracer.iter_events()) - if event_type is not None: - events = [e for e in events if e.get("event") == event_type] - return events - - -def _handle_log_command(cmd: str, agent=None) -> None: - """View recorded LLM request prompts: /log [tail [N] | search | - clear]. - - Data source: llm_call trace events that carry the full message payload, - which are only recorded while debug mode is on (/debug on). - """ - parts = cmd.strip().split(None, 2) - sub = parts[1] if len(parts) > 1 else "tail" - - tracer = ( - getattr(agent, "trace_logger", None) if agent is not None else None - ) - - if sub == "clear": - if tracer is not None: - tracer.clear() - console.print("[green]✓ Session trace cleared[/green]") - return - - events = [e for e in _trace_events(agent, "llm_call") if e.get("messages")] - - if sub == "search": - keyword = parts[2].strip() if len(parts) > 2 else "" - if not keyword: - console.print("[yellow]Usage: /log search [/yellow]") - return - kw = keyword.lower() - events = [ - e - for e in events - if kw in json.dumps(e, ensure_ascii=False, default=str).lower() - ] - header = f'LLM request search "{keyword}" ({len(events)} hits)' - elif sub == "tail": - limit = 3 - if len(parts) > 2: - try: - limit = int(parts[2].strip()) - if limit <= 0: - raise ValueError - except ValueError: - console.print( - "[yellow]Usage: /log tail [N] " - "(N = positive integer)[/yellow]", - ) - return - events = events[-limit:] - header = f"Last {len(events)} LLM requests" - else: - console.print( - "[dim]Usage: /log [tail [N] | search | clear][/dim]", - ) - return - - if not events: - console.print( - "[dim]No full prompts recorded yet " - "(/debug on, then ask again)[/dim]", - ) - return - - path = getattr(tracer, "trace_file", None) - try: - size_kb = path.stat().st_size // 1024 if path else 0 - except OSError: - size_kb = 0 - - if _can_page(): - text = f"{header} ({path}, {size_kb} KB)\n\n" + "\n\n".join( - _log_record_text(r, i + 1, full=True) for i, r in enumerate(events) - ) - _page_text(text) - else: - console.print( - f"[bold]{header}[/bold] [dim]({path}, {size_kb} KB)[/dim]", - ) - for i, r in enumerate(events): - console.print(_log_record_text(r, i + 1, full=False), markup=False) - console.print( - "[dim]Content truncated; page full logs in CLI mode[/dim]", - ) - - -def _trace_event_text(ev: dict, idx: int, *, full: bool) -> str: - """Render one trace event compactly; full mode expands llm_call prompts.""" - elapsed = ev.get("elapsed_ms", 0) - stamp = f"[+{elapsed / 1000:.1f}s]" - event = ev.get("event", "?") - - if event == "llm_call": - parts = [f"━━━ #{idx} {stamp} llm_call"] - if ev.get("model"): - parts.append(f"model={ev['model']}") - parts.append(f"msgs={ev.get('message_count', 0)}") - usage = _usage_text(ev.get("usage")) - if usage: - parts.append(usage) - dur = ev.get("duration_ms", 0) - if dur: - parts.append(f"{dur / 1000:.1f}s") - tcs = ev.get("tool_calls") or [] - if tcs: - names = ", ".join( - str(t.get("name", "?")) for t in tcs if isinstance(t, dict) - ) - if names: - parts.append(f"→ {names}") - header = " ".join(parts) + " ━━━" - msgs = ev.get("messages") - if msgs: - return header + _messages_text(msgs, full=full) - return header - - if event == "tool_execution": - ok = "✓" if ev.get("success") else "✗" - dur = ev.get("duration_ms", 0) - args = json.dumps( - ev.get("arguments", {}), - ensure_ascii=False, - default=str, - ) - if not full and len(args) > 120: - args = args[:120] + " …" - preview = ev.get("result_preview", "") - if not full and len(preview) > 120: - preview = preview[:120] + " …" - return ( - f"━━━ #{idx} {stamp} tool {ev.get('tool', '?')} · {dur}ms {ok}\n" - f" args: {args}\n result: {preview}" - ) - - if event == "decision": - details = { - k: v - for k, v in ev.items() - if k not in ("timestamp", "elapsed_ms", "event") - } - body = json.dumps(details, ensure_ascii=False, default=str) - return f"━━━ #{idx} {stamp} decision {ev.get('type', '?')} · {body}" - - body = json.dumps( - { - k: v - for k, v in ev.items() - if k not in ("timestamp", "elapsed_ms", "event") - }, - ensure_ascii=False, - default=str, - ) - return f"━━━ #{idx} {stamp} {event} · {body}" - - -def _handle_trace_command(cmd: str, agent=None) -> None: - """View the session execution trace: /trace [tail [N] | search | - clear]. - - Lightweight events (LLM call latency/usage, tool call latency, - decision points) are always recorded; full prompts need /debug on. - """ - parts = cmd.strip().split(None, 2) - sub = parts[1] if len(parts) > 1 else "tail" - - tracer = ( - getattr(agent, "trace_logger", None) if agent is not None else None - ) - - if sub == "clear": - if tracer is not None: - tracer.clear() - console.print("[green]✓ Session trace cleared[/green]") - return - - if sub == "search": - keyword = parts[2].strip() if len(parts) > 2 else "" - if not keyword: - console.print("[yellow]Usage: /trace search [/yellow]") - return - events = tracer.search_events(keyword) if tracer is not None else [] - header = f'Trace search "{keyword}" ({len(events)} hits)' - elif sub == "tail": - limit = 5 - if len(parts) > 2: - try: - limit = int(parts[2].strip()) - if limit <= 0: - raise ValueError - except ValueError: - console.print( - "[yellow]Usage: /trace tail [N] " - "(N = positive integer)[/yellow]", - ) - return - events = tracer.tail_events(limit) if tracer is not None else [] - header = f"Last {len(events)} trace events" - else: - console.print( - "[dim]Usage: /trace [tail [N] | search | " - "clear][/dim]", - ) - return - - if not events: - console.print( - "[dim]No trace records yet (recorded automatically once " - "this conversation makes calls)[/dim]", - ) - return - - path = getattr(tracer, "trace_file", None) - try: - size_kb = path.stat().st_size // 1024 if path else 0 - except OSError: - size_kb = 0 - - if _can_page(): - text = f"{header} ({path}, {size_kb} KB)\n\n" + "\n\n".join( - _trace_event_text(e, i + 1, full=True) - for i, e in enumerate(events) - ) - _page_text(text) - else: - console.print( - f"[bold]{header}[/bold] [dim]({path}, {size_kb} KB)[/dim]", - ) - for i, e in enumerate(events): - console.print( - _trace_event_text(e, i + 1, full=False), - markup=False, - ) - console.print( - "[dim]Content truncated; page full trace in CLI mode[/dim]", - ) - - -def _handle_theme_command(cmd: str, config: Config) -> None: - """Manage UI theme settings.""" - parts = cmd.strip().split(maxsplit=2) - if len(parts) < 2: - current_name = next( - ( - name - for name, preset in THEME_PRESETS.items() - if preset == config.theme - ), - "custom", - ) - console.print(f"[bold]Current theme:[/bold] {current_name}") - console.print( - f"[dim]Available presets: " - f"{', '.join(THEME_PRESETS.keys())}[/dim]", - ) - console.print( - "[dim]Usage: /theme list | /theme set | /theme " - " | /theme [/dim]", - ) - return - - sub = parts[1].lower() - - if sub == "list": - current_name = next( - ( - name - for name, preset in THEME_PRESETS.items() - if preset == config.theme - ), - "custom", - ) - console.print(f"[bold]Current theme:[/bold] {current_name}") - console.print( - f"[dim]Available presets: " - f"{', '.join(THEME_PRESETS.keys())}[/dim]", - ) - return - - if sub == "set": - theme_name = parts[2].lower() if len(parts) > 2 else "" - if not theme_name or theme_name not in THEME_PRESETS: - console.print(f"[red]Unknown theme: {theme_name}[/red]") - console.print( - f"[dim]Available: " f"{', '.join(THEME_PRESETS.keys())}[/dim]", - ) - return - # Deep-copy: later `/theme ` overrides mutate - # config.theme, which must not alias the global preset. - config.theme = copy.deepcopy(THEME_PRESETS[theme_name]) - config.save_workspace() - console.print(f"[green]✓ Theme switched to: {theme_name}[/green]") - console.print("[dim]Takes effect after restart[/dim]") - return - - # Backward compatibility: /theme - theme_name = sub - if theme_name in THEME_PRESETS: - config.theme = copy.deepcopy(THEME_PRESETS[theme_name]) - config.save_workspace() - console.print(f"[green]✓ Theme switched to: {theme_name}[/green]") - console.print("[dim]Takes effect after restart[/dim]") - return - - # Custom color key: /theme background #1e1e1e - if len(parts) >= 3 and sub in config.theme: - config.theme[sub] = parts[2] - config.save_workspace() - console.print(f"[green]✓ Set {sub} = {parts[2]}[/green]") - console.print("[dim]Takes effect after restart[/dim]") - return - - console.print(f"[red]Unknown theme or attribute: {theme_name}[/red]") - console.print( - f"[dim]Available presets: " f"{', '.join(THEME_PRESETS.keys())}[/dim]", - ) - - -def _handle_directives_command(cmd: str, config: Config) -> None: - """Manage custom directives (rules injected into system prompt).""" - parts = cmd.strip().split(maxsplit=1) - if len(parts) < 2: - if not config.user_directives: - console.print("[dim]No custom directives yet[/dim]") - else: - console.print("[bold]Custom directives:[/bold]") - for i, directive in enumerate(config.user_directives, 1): - console.print(f" {i}. {directive}") - console.print( - "\n[dim]Usage:\n" - " /directives add — add a directive\n" - " /directives rm — remove a directive\n" - " /directives clear — clear all directives\n" - " /directives proposals — view auto-learned proposals\n" - " /directives accept — accept proposal as a rule\n" - " /directives reject — reject a proposal[/dim]", - ) - return - - sub = parts[1].strip() - - if sub.startswith("add "): - text = sub[4:].strip() - if not text: - console.print("[red]Please provide directive text[/red]") - return - config.user_directives.append(text) - config.save_workspace() - console.print( - f"[green]✓ Directive added " - f"({len(config.user_directives)} total)[/green]", - ) - return - - if sub.startswith("rm "): - try: - idx = int(sub[3:].strip()) - 1 - if 0 <= idx < len(config.user_directives): - removed = config.user_directives.pop(idx) - config.save_workspace() - console.print(f"[green]✓ Removed: {removed}[/green]") - else: - console.print(f"[red]Invalid number: {idx + 1}[/red]") - except ValueError: - console.print("[red]Please provide a valid number[/red]") - return - - if sub == "clear": - count = len(config.user_directives) - config.user_directives.clear() - config.save_workspace() - console.print(f"[green]✓ Cleared {count} directives[/green]") - return - - if sub == "proposals": - from dashscope.acli.memory.directives_learning import ( - list_proposed_directives, - ) - - proposals = list_proposed_directives("pending") - if not proposals: - console.print("[dim]No auto-learned proposals yet[/dim]") - else: - console.print("[bold]Behavior pattern proposals:[/bold]") - for p in proposals: - console.print(f" [{p['id']}] {p['directive']}") - console.print(f" [dim]{p['rationale']}[/dim]") - return - - if sub.startswith("accept "): - proposal_id = sub[7:].strip() - from dashscope.acli.memory.directives_learning import accept_directive - - if accept_directive(proposal_id, config): - console.print( - f"[green]✓ Accepted proposal {proposal_id} " - f"as a rule[/green]", - ) - else: - console.print( - f"[red]Proposal {proposal_id} not found or " - f"already handled[/red]", - ) - return - - if sub.startswith("reject "): - proposal_id = sub[7:].strip() - from dashscope.acli.memory.directives_learning import reject_directive - - if reject_directive(proposal_id): - console.print( - f"[yellow]✗ Rejected proposal {proposal_id}[/yellow]", - ) - else: - console.print( - f"[red]Proposal {proposal_id} not found or " - f"already handled[/red]", - ) - return - - console.print( - "[dim]Usage: /directives " - "[add|rm|clear|proposals|accept|reject][/dim]", - ) - - -def _handle_rule_command(cmd: str, config: Config) -> None: - """Manage workspace rules (.acli/rules.jsonl).""" - from dashscope.acli.config import WORKSPACE_DIR - - rules_file = WORKSPACE_DIR / "rules.jsonl" - parts = cmd.strip().split(maxsplit=1) - - def _load_entries() -> list[dict]: - if not rules_file.exists(): - return [] - entries: list[dict] = [] - for line in rules_file.read_text(encoding="utf-8").splitlines(): - 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 _save_entries(entries: list[dict]) -> None: - WORKSPACE_DIR.mkdir(parents=True, exist_ok=True) - rules_file.write_text( - "".join(json.dumps(e, ensure_ascii=False) + "\n" for e in entries), - encoding="utf-8", - ) - - def _print_rules(): - entries = _load_entries() - if not entries: - console.print("[dim]No workspace rules configured yet[/dim]") - return - console.print("[bold]Workspace rules:[/bold]") - for i, entry in enumerate(entries, 1): - text = entry.get("text", "") - enabled = ( - "[green]✓[/green]" - if entry.get("enabled", True) - else "[dim]✗[/dim]" - ) - console.print(f" {enabled} {i}. {text}") - - if len(parts) < 2: - _print_rules() - console.print( - "\n[dim]Usage:\n" - " /rule list — list rules\n" - " /rule add — add a rule\n" - " /rule remove — remove a rule (alias: rm)\n" - " /rule edit — open rules file in an editor\n" - " /rule edit — edit the given rule\n" - " /rule enable — enable a rule\n" - " /rule disable — disable a rule\n" - " /rule clear — clear all rules[/dim]", - ) - return - - sub = parts[1].strip() - - if sub == "list": - _print_rules() - return - - if sub.startswith("add "): - text = sub[4:].strip() - if not text: - console.print("[red]Please provide rule text[/red]") - return - entries = _load_entries() - entries.append({"text": text, "enabled": True}) - _save_entries(entries) - console.print(f"[green]✓ Rule added: {text}[/green]") - return - - if sub == "edit": - if not rules_file.exists(): - WORKSPACE_DIR.mkdir(parents=True, exist_ok=True) - rules_file.write_text("", encoding="utf-8") - import os - - editor = os.environ.get("EDITOR", "nano") - os.system(f"{editor} {rules_file}") - console.print("[green]✓ Rules file updated[/green]") - return - - if sub.startswith("edit "): - rest = sub[5:].strip() - parts2 = rest.split(maxsplit=1) - if len(parts2) < 2: - console.print("[red]Usage: /rule edit [/red]") - return - try: - idx = int(parts2[0]) - 1 - except ValueError: - console.print("[red]Please provide a valid number[/red]") - return - new_text = parts2[1] - entries = _load_entries() - if idx < 0 or idx >= len(entries): - console.print(f"[red]Invalid number: {idx + 1}[/red]") - return - entries[idx]["text"] = new_text - _save_entries(entries) - console.print(f"[green]✓ Updated rule #{idx + 1}: {new_text}[/green]") - return - - if sub.startswith("enable "): - num_part = sub[7:].strip() - try: - idx = int(num_part) - 1 - except ValueError: - console.print("[red]Please provide a valid number[/red]") - return - entries = _load_entries() - if idx < 0 or idx >= len(entries): - console.print(f"[red]Invalid number: {idx + 1}[/red]") - return - entries[idx]["enabled"] = True - _save_entries(entries) - console.print(f"[green]✓ Enabled rule #{idx + 1}[/green]") - return - - if sub.startswith("disable "): - num_part = sub[8:].strip() - try: - idx = int(num_part) - 1 - except ValueError: - console.print("[red]Please provide a valid number[/red]") - return - entries = _load_entries() - if idx < 0 or idx >= len(entries): - console.print(f"[red]Invalid number: {idx + 1}[/red]") - return - entries[idx]["enabled"] = False - _save_entries(entries) - console.print(f"[green]✓ Disabled rule #{idx + 1}[/green]") - return - - if ( - sub == "remove" - or sub.startswith("remove ") - or sub == "rm" - or sub.startswith("rm ") - ): - entries = _load_entries() - if not entries: - console.print("[dim]No rules to remove[/dim]") - return - if sub.startswith("remove "): - num_part = sub[7:].strip() - elif sub.startswith("rm "): - num_part = sub[3:].strip() - else: - num_part = "" - if not num_part: - console.print( - "[red]Please provide a number, e.g. /rule remove 1 " - "or /rule rm 1[/red]", - ) - return - try: - idx = int(num_part) - 1 - except ValueError: - console.print("[red]Please provide a valid number[/red]") - return - if idx < 0 or idx >= len(entries): - console.print(f"[red]Invalid number: {idx + 1}[/red]") - return - removed = entries.pop(idx) - _save_entries(entries) - console.print( - f"[green]✓ Removed rule: {removed.get('text', '')}[/green]", - ) - return - - if sub == "clear": - if rules_file.exists(): - rules_file.unlink() - console.print("[green]✓ Rules cleared[/green]") - else: - console.print("[dim]No rules to clear[/dim]") - return - - console.print( - "[dim]Usage: /rule [list|add|remove(rm)|edit|edit " - "|enable|disable|clear][/dim]", - ) diff --git a/dashscope/acli/cli/handlers_device.py b/dashscope/acli/cli/handlers_device.py deleted file mode 100644 index 480f324f..00000000 --- a/dashscope/acli/cli/handlers_device.py +++ /dev/null @@ -1,432 +0,0 @@ -# -*- coding: utf-8 -*- -"""Device and multimedia command handlers (camera, voice, tts).""" -# pylint: disable=too-many-return-statements,too-many-branches -# pylint: disable=too-many-statements - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from rich.console import Console - -from dashscope.acli.cli.constants import ASR_MODELS -from dashscope.acli.config import Config - -if TYPE_CHECKING: - from dashscope.acli.agent import Agent - -console = Console() - - -def _handle_camera_command(cmd: str) -> None: - """/camera capture [file] | /camera record [duration] [file]""" - from dashscope.acli.ui.camera import capture, is_available, record - - parts = cmd.split() - if len(parts) < 2: - ok, backend = is_available() - status = ( - f"available ({backend})" - if ok - else ( - "unavailable (install: pip install acli[camera] " - "or brew install imagesnap)" - ) - ) - console.print(f"[dim]Camera status: {status}[/dim]") - console.print( - "[dim]Usage: /camera capture [file] | " - "/camera record [duration] [file][/dim]", - ) - return - if parts[1] == "capture": - filename = parts[2] if len(parts) >= 3 else "camera_capture.jpg" - result = capture(filename) - elif parts[1] == "record": - duration = 5.0 - filename = "camera_record.mp4" - args = parts[2:] - if args: - try: - duration = float(args[0]) - filename = args[1] if len(args) >= 2 else filename - except ValueError: - filename = args[0] - console.print(f"[dim]📹 Recording ({duration}s)...[/dim]") - result = record(filename, duration) - else: - console.print( - "[dim]Usage: /camera capture [file] | " - "/camera record [duration] [file][/dim]", - ) - return - if result.startswith(("错误", "Error")): - console.print(f"[red]{result}[/red]") - else: - console.print(f"[green]{result}[/green]") - - -def _handle_voice_command(cmd: str, config: Config) -> bool | str: - """Unified /voice command. - - /voice — show status - /voice on — start voice input (replaces /v) - /voice off — cancel ongoing voice input (TUI only) - /voice model — switch ASR model - /voice silence — seconds of silence before auto-stop - /voice max — maximum recording duration - /voice threshold — RMS threshold for silence detection - - Returns "voice" to request voice input, True when handled normally. - """ - parts = cmd.strip().split(maxsplit=2) - sub = parts[1] if len(parts) > 1 else "status" - - if sub in ("on", "start", "record"): - return "voice" - - if sub == "off": - from dashscope.acli.ui.voice import try_cancel_voice_input - - if try_cancel_voice_input(): - console.print("[dim]Recording cancelled[/dim]") - else: - console.print("[dim]No recording in progress[/dim]") - return True - - if sub == "model": - if len(parts) < 3: - console.print(f"[dim]Current ASR model: {config.asr_model}[/dim]") - console.print(f"[dim]Available: {', '.join(ASR_MODELS)}[/dim]") - return True - name = parts[2] - if name not in ASR_MODELS: - console.print(f"[red]Unknown ASR model: {name}[/red]") - console.print(f"[dim]Available: {', '.join(ASR_MODELS)}[/dim]") - return True - config.asr_model = name - config.save_workspace() - config.save_global() - console.print(f"[green]✓ ASR model switched to: {name}[/green]") - return True - - if sub == "silence": - if len(parts) < 3: - console.print( - f"[dim]Current silence threshold: " - f"{config.voice_silence_duration}s[/dim]", - ) - console.print("[dim]Usage: /voice silence [/dim]") - return True - try: - val = float(parts[2]) - if val <= 0: - raise ValueError - config.voice_silence_duration = val - config.save_workspace() - config.save_global() - console.print( - f"[green]✓ Silence threshold set to: {val}s[/green]", - ) - except ValueError: - console.print("[red]Please enter a number greater than 0[/red]") - return True - - if sub == "max": - if len(parts) < 3: - console.print( - f"[dim]Current max recording duration: " - f"{config.voice_max_seconds}s[/dim]", - ) - console.print("[dim]Usage: /voice max [/dim]") - return True - try: - val = int(parts[2]) - if val <= 0: - raise ValueError - config.voice_max_seconds = val - config.save_workspace() - config.save_global() - console.print( - f"[green]✓ Max recording duration set to: {val}s[/green]", - ) - except ValueError: - console.print("[red]Please enter an integer greater than 0[/red]") - return True - - if sub == "threshold": - if len(parts) < 3: - console.print( - f"[dim]Current silence RMS threshold: " - f"{config.voice_silence_threshold}[/dim]", - ) - console.print("[dim]Usage: /voice threshold [/dim]") - return True - try: - val = int(parts[2]) - if val < 0: - raise ValueError - config.voice_silence_threshold = val - config.save_workspace() - config.save_global() - console.print( - f"[green]✓ Silence RMS threshold set to: {val}[/green]", - ) - except ValueError: - console.print("[red]Please enter a non-negative integer[/red]") - return True - - if sub == "status": - pass - - # Default: show status/help - console.print("[bold]Voice Input[/bold]") - console.print(f" ASR model: [cyan]{config.asr_model}[/cyan]") - console.print( - f" Silence stop: [cyan]" f"{config.voice_silence_duration}s[/cyan]", - ) - console.print( - f" Max duration: [cyan]{config.voice_max_seconds}s[/cyan]", - ) - console.print( - f" RMS threshold: [cyan]" f"{config.voice_silence_threshold}[/cyan]", - ) - console.print("\n[dim]Usage:[/dim]") - console.print(" /voice on — start recording") - console.print(" /voice off — cancel recording") - console.print(" /voice model — switch ASR model") - console.print(" /voice silence — set silence-stop seconds") - console.print(" /voice max — set max recording seconds") - console.print(" /voice threshold — silence detection threshold") - return True - - -def _handle_tts_command( - cmd: str, - config: Config, - agent: "Agent" = None, -) -> None: - """/tts — Text-to-Speech voice output control. - - /tts on/off — enable/disable auto TTS on agent replies - /tts status — show current TTS config - /tts model — switch TTS model - /tts voice — switch TTS voice - /tts speed — set speech rate (0.5-2.0) - /tts say — speak given text - /tts last — speak last agent reply - """ - from dashscope.acli.ui.tts import ( - DEFAULT_VOICE, - TTS_MODELS, - TTS_VOICES, - VOICE_DISPLAY, - is_available, - speak_text, - ) - - parts = cmd.split(maxsplit=2) - sub = parts[1] if len(parts) >= 2 else "status" - - if sub == "on": - ok, err = is_available() - if not ok: - console.print(f"[red]{err}[/red]") - return - config.tts_enabled = True - config.save_workspace() - config.save_global() - console.print("[green]✓ Voice output enabled[/green]") - return - - if sub == "off": - config.tts_enabled = False - config.save_workspace() - config.save_global() - console.print("[dim]Voice output disabled[/dim]") - return - - if sub == "status": - console.print("[bold]TTS Voice Output[/bold]") - if config.tts_enabled: - console.print(" Status: [green]on[/green]") - else: - console.print(" Status: [dim]off[/dim]") - console.print( - " [yellow]Hint: auto-speak is off by default; " - "run /tts on to enable it[/yellow]", - ) - console.print(f" Model: [cyan]{config.tts_model}[/cyan]") - voice_name = VOICE_DISPLAY.get(config.tts_voice, config.tts_voice) - console.print( - f" Voice: [cyan]{config.tts_voice}[/cyan] ({voice_name})", - ) - console.print(f" Speed: [cyan]{config.tts_speed:.1f}[/cyan]") - ok, err = is_available() - if not ok: - console.print(f"\n[yellow]Hint: {err}[/yellow]") - else: - console.print( - f"\n[dim]Available models: " f"{', '.join(TTS_MODELS)}[/dim]", - ) - voices = TTS_VOICES.get(config.tts_model, []) - if voices: - voice_list = ", ".join( - f"{v}({VOICE_DISPLAY.get(v, v)})" for v in voices[:5] - ) - console.print(f"[dim]Available voices: {voice_list}...[/dim]") - return - - if sub == "model": - if len(parts) < 3: - console.print(f"[dim]Current model: {config.tts_model}[/dim]") - console.print(f"[dim]Available: {', '.join(TTS_MODELS)}[/dim]") - return - model_name = parts[2] - if model_name not in TTS_MODELS: - console.print(f"[red]Unknown model: {model_name}[/red]") - console.print(f"[dim]Available: {', '.join(TTS_MODELS)}[/dim]") - return - config.tts_model = model_name - config.tts_voice = DEFAULT_VOICE.get(model_name, config.tts_voice) - config.save_workspace() - config.save_global() - console.print( - f"[green]✓ TTS model switched to: {model_name}[/green]", - ) - return - - if sub == "voice": - if len(parts) < 3: - # Interactive voice selection - voices = TTS_VOICES.get(config.tts_model, []) - if not voices: - console.print(f"[dim]Current voice: {config.tts_voice}[/dim]") - console.print("[dim]No voices available for this model[/dim]") - return - console.print("[bold]Select TTS Voice[/bold]") - console.print( - f"[dim]Current: {config.tts_voice} " - f"({VOICE_DISPLAY.get(config.tts_voice, config.tts_voice)}" - f")[/dim]\n", - ) - for i, v in enumerate(voices, 1): - display_name = VOICE_DISPLAY.get(v, v) - marker = ( - " [green]← current[/green]" - if v == config.tts_voice - else "" - ) - console.print( - f" [cyan][{i}][/cyan] {v} — {display_name}{marker}", - ) - console.print("\n[dim]Enter a number to select, q to cancel[/dim]") - try: - choice = input("> ").strip() - except (EOFError, KeyboardInterrupt): - console.print("[dim]Cancelled[/dim]") - return - if choice.lower() == "q" or not choice: - console.print("[dim]Cancelled[/dim]") - return - try: - idx = int(choice) - 1 - if 0 <= idx < len(voices): - config.tts_voice = voices[idx] - config.save_workspace() - config.save_global() - console.print( - f"[green]✓ TTS voice switched to: {voices[idx]} " - f"({VOICE_DISPLAY.get(voices[idx], voices[idx])}" - f")[/green]", - ) - else: - console.print("[red]Invalid selection[/red]") - except ValueError: - console.print("[red]Please enter a number[/red]") - return - - voice_name = parts[2] - voices = TTS_VOICES.get(config.tts_model, []) - if voice_name not in voices: - console.print(f"[red]Unknown voice: {voice_name}[/red]") - if voices: - voice_list = ", ".join( - f"{v}({VOICE_DISPLAY.get(v, v)})" for v in voices[:5] - ) - console.print(f"[dim]Available voices: {voice_list}...[/dim]") - return - config.tts_voice = voice_name - config.save_workspace() - config.save_global() - console.print( - f"[green]✓ TTS voice switched to: {voice_name} " - f"({VOICE_DISPLAY.get(voice_name, voice_name)})[/green]", - ) - return - - if sub == "speed": - if len(parts) < 3: - console.print(f"[dim]Current speed: {config.tts_speed:.1f}[/dim]") - console.print("[dim]Valid range: 0.5-2.0[/dim]") - return - try: - speed = float(parts[2]) - if not 0.5 <= speed <= 2.0: - console.print("[red]Speed must be between 0.5 and 2.0[/red]") - return - config.tts_speed = speed - config.save_workspace() - config.save_global() - console.print(f"[green]✓ TTS speed set to: {speed:.1f}[/green]") - except ValueError: - console.print("[red]Please enter a valid number[/red]") - return - - if sub == "say": - if len(parts) < 3: - console.print("[dim]Usage: /tts say [/dim]") - return - text = parts[2] - ok, err = is_available() - if not ok: - console.print(f"[red]{err}[/red]") - return - console.print(f"[dim]Speaking: {text}[/dim]") - speak_text( - config.tongyi_api_key, - text, - config.tts_model, - config.tts_voice, - config.tts_speed, - ) - return - - if sub == "last": - if not agent or not agent.last_output: - console.print("[dim]No previous reply to speak[/dim]") - return - ok, err = is_available() - if not ok: - console.print(f"[red]{err}[/red]") - return - console.print("[dim]Speaking last reply...[/dim]") - speak_text( - config.tongyi_api_key, - agent.last_output, - config.tts_model, - config.tts_voice, - config.tts_speed, - ) - return - - console.print( - "[dim]Usage:\n" - " /tts on/off — enable/disable auto-speak\n" - " /tts status — show current config\n" - " /tts model — switch model\n" - " /tts voice — switch voice\n" - " /tts speed <0.5-2.0> — set speech rate\n" - " /tts say — speak given text\n" - " /tts last — speak last reply[/dim]", - ) diff --git a/dashscope/acli/cli/handlers_key.py b/dashscope/acli/cli/handlers_key.py deleted file mode 100644 index afc4491b..00000000 --- a/dashscope/acli/cli/handlers_key.py +++ /dev/null @@ -1,318 +0,0 @@ -# -*- coding: utf-8 -*- -"""API key management command handlers.""" -# pylint: disable=too-many-branches,unused-argument - -from __future__ import annotations - -import sys - -from rich.console import Console - -from dashscope.acli.cli.constants import KEY_TARGETS -from dashscope.acli.config import PROVIDER_MODELS, Config -from dashscope.acli.extensions import find_provider -from dashscope.acli.providers import get_provider_chain -from dashscope.acli.providers.profile import build_profiles_from_config - -console = Console() - -# Model Studio doc links for the no-key startup prompt; locale segment -# comes from _doc_locale(). -_GET_API_KEY_DOC = "https://help.aliyun.com/{}/model-studio/get-api-key" -_GUIDE_DOC = "https://help.aliyun.com/{}/model-studio/dashscope-sdk-expert" - - -def _doc_locale() -> str: - """Pick the help-center locale from the process locale env vars.""" - import os - - for var in ("LC_ALL", "LC_MESSAGES", "LANG"): - if "zh" in (os.environ.get(var) or "").lower(): - return "zh" - return "en" - - -def all_key_targets(config: Config | None = None) -> dict[str, dict]: - """Merge KEY_TARGETS (built-in) with extension providers into one dict. - - Extension provider entries are synthesized from CustomProvider fields so - they look identical to built-in targets downstream. - """ - merged: dict[str, dict] = dict(KEY_TARGETS) - try: - from dashscope.acli.extensions import current - except Exception: - return merged - - for prov in current().providers: - if prov.name in merged: - continue - if not prov.auth: - merged[prov.name] = { - "field": f"{prov.name}_api_key", - "env": "", - "scope": "global", - "desc": f"{prov.name} (no auth)", - "no_auth": True, - } - continue - merged[prov.name] = { - "field": f"{prov.name}_api_key", - "env": prov.api_key_env or "", - "scope": "global", - "desc": prov.name, - "extension": True, - } - return merged - - -def _prompt_input(prompt: str, secret: bool = False) -> str: - """Read user input. When secret=True, characters are not echoed.""" - try: - if secret: - import getpass - - return getpass.getpass(prompt).strip() - return input(prompt).strip() - except (EOFError, KeyboardInterrupt): - return "" - - -def _print_no_key_prompt(config: Config, env_name: str) -> None: - """Present the missing-key notice, doc links, and setup menu.""" - console.print( - f"\n[yellow]No API Key detected for " f"{config.provider}[/yellow]", - ) - if config.provider.lower() == "tongyi": - lang = _doc_locale() - console.print( - f"[dim]Get an API Key: {_GET_API_KEY_DOC.format(lang)}[/dim]", - ) - console.print(f"[dim]Guide: {_GUIDE_DOC.format(lang)}[/dim]") - console.print("Choose how to set it up:") - if env_name: - console.print(f" [1] Set env var {env_name} (exit and set)") - else: - console.print(" [1] Set corresponding env var (exit and set)") - console.print(" [2] Enter API Key now") - console.print(" [3] Set up later with /provider after startup") - - -def ensure_provider_key(config: Config, agent) -> bool: - """If the active provider has no resolvable key, prompt the user. - - Built-in providers persist to the global config slot; extension providers - persist an encrypted token into custom-extensions.toml. Returns True when - the key is available (or the user opts to set it later), False should not - happen because missing/empty input exits the process. - """ - if getattr(config, "_embedded_mode", False): - return True - - profiles = build_profiles_from_config(config) - if profiles and profiles[0].api_key: - return True - - ext = find_provider(config.provider) - targets = all_key_targets(config) - key_info = targets.get(config.provider) - if ext is None and key_info is None: - # Neither a built-in nor a loaded extension: this directory cannot - # build that provider, so collecting a key is a dead end — and the - # "_API_KEY" env var we would suggest is read by nothing. - console.print( - f"\n[yellow]Configured provider '{config.provider}' is not " - "available here (no built-in or loaded extension by that " - "name), so an API key alone will not make it work.[/yellow]", - ) - console.print( - "[dim]Starting anyway; run /provider to pick an available " - "provider.[/dim]", - ) - return True - - if key_info: - env_name = key_info.get("env") or "" - else: - env_name = ext.api_key_env or "" - - _print_no_key_prompt(config, env_name) - choice = input("\nChoose [1/2/3]: ").strip() - - if choice == "1": - console.print("[dim]Set the env var, then restart:[/dim]") - if env_name: - console.print(f"[dim] export {env_name}=sk-xxx[/dim]") - sys.exit(0) - elif choice == "2": - api_key = _prompt_input("API Key: ", secret=True) - if not api_key: - console.print("[red]No API Key entered; exiting[/red]") - sys.exit(0) - if ext is not None: - if _set_extension_provider_token( - ext, - config, - direct_value=api_key, - ): - agent.provider = get_provider_chain(config) - return True - sys.exit(1) - key_field = f"{config.provider}_api_key" - if hasattr(config, key_field): - setattr(config, key_field, api_key) - else: - config.api_key = api_key - config.save_global() - console.print("[green]API Key saved[/green]") - agent.provider = get_provider_chain(config) - return True - elif choice == "3": - console.print( - "[dim]Hint: run /provider after startup to set API Key[/dim]\n", - ) - return True - else: - console.print("[red]API Key not set; exiting[/red]") - sys.exit(0) - - -def _set_extension_cap_token(cap_key: str, direct_value: str = "") -> None: - """Encrypt + persist a token into the [[capabilities]] block of - custom-extensions.toml. If `direct_value` is empty, prompts (no echo). - Picks the toml file the capability came from; defaults to global.""" - from pathlib import Path - - from dashscope.acli.extensions import ( - GLOBAL_EXTENSIONS_FILE, - apply_extensions, - auth_env_name, - encrypt_for_toml, - find_capability, - set_capability_secret, - ) - - cap = find_capability(cap_key) - if cap is None: - console.print( - f"[red]{cap_key} is not a registered " - f"extension capability[/red]", - ) - return - if ( - cap.auth - and cap.auth.startswith("apikey-header:") - or cap.auth.startswith("bearer:$") - or cap.api_key_env - ): - env_hint = cap.api_key_env or auth_env_name(cap.auth) - else: - console.print( - f"[yellow]{cap_key} current auth config " - f"({cap.auth or 'none'}) does not need a token[/yellow]", - ) - return - - secret = direct_value or _prompt_input( - f" {cap_key} credential (value of env {env_hint}, " - f"hidden input): ", - secret=True, - ) - if not secret: - console.print("[dim]Cancelled[/dim]") - return - - enc = encrypt_for_toml(secret) - target = Path(cap.source) if cap.source else GLOBAL_EXTENSIONS_FILE - if not set_capability_secret(target, cap_key, api_key_enc=enc): - console.print( - f"[red]Write failed: {cap_key} block " - f"not found in {target}[/red]", - ) - return - apply_extensions(PROVIDER_MODELS) - console.print(f"[green]✓ Saved encrypted to {target}[/green]") - console.print( - f"[dim]Runtime priority: env var {env_hint} " - f"> encrypted token[/dim]", - ) - - -def _set_extension_provider_token( - ext_prov, - config: Config, - direct_value: str = "", -) -> bool: - """Persist an extension provider API key as _api_key in - ~/.acli/config.toml, just like the built-in providers. If `direct_value` - is empty, prompts (no echo). - - Returns True on success, False on cancellation or write failure - (an error message is already printed on failure). - """ - secret = direct_value or _prompt_input( - f" {ext_prov.name} API Key (hidden input, saved encrypted): ", - secret=True, - ) - if not secret: - console.print("[dim]Cancelled[/dim]") - return False - - # Save to the provider's dynamic slot, i.e. _api_key. - old_provider = config.provider - try: - config.provider = ext_prov.name - config.api_key = secret - finally: - config.provider = old_provider - config.save_global() - env_hint = ext_prov.api_key_env or "(none)" - console.print( - f"[green]✓ {ext_prov.name}_api_key saved encrypted " - f"to ~/.acli/config.toml[/green]", - ) - console.print( - f"[dim]Runtime priority: env var {env_hint} " - f"> encrypted token[/dim]", - ) - return True - - -def _maybe_prompt_extension_token(cap_key: str, config=None) -> None: - """Called from /capability enable. Detects when an extension capability - needs a token (auth references env var) but neither env nor encrypted - storage is providing one — and offers to capture it inline. Skips the - prompt when a built-in provider already holds a key for the same env - var (reused at registration time via runtime_key).""" - import os - - from dashscope.acli.extensions import ( - auth_env_name, - find_capability, - provider_key_for_env, - ) - - cap = find_capability(cap_key) - if cap is None: - return # built-in capability - env_name = cap.api_key_env or auth_env_name(cap.auth) - if not env_name: - return # no token needed (auth=none or unset) - if os.environ.get(env_name) or cap.api_key_enc: - return # already covered - if provider_key_for_env(config, env_name, KEY_TARGETS): - # Same secret already stored for the matching built-in provider - # (e.g. tongyi ↔ DASHSCOPE_API_KEY) — no second prompt needed. - return - console.print( - f"[yellow]{cap_key} missing credential[/yellow] " - f"[dim](env {env_name} unset; no encrypted key in " - f"toml either)[/dim]", - ) - yn = ( - input("Enter it now? (saved encrypted, reused later) [Y/n]: ") - .strip() - .lower() - ) - if yn in ("", "y", "yes"): - _set_extension_cap_token(cap_key) diff --git a/dashscope/acli/cli/handlers_misc.py b/dashscope/acli/cli/handlers_misc.py deleted file mode 100644 index 5407f199..00000000 --- a/dashscope/acli/cli/handlers_misc.py +++ /dev/null @@ -1,328 +0,0 @@ -# -*- coding: utf-8 -*- -"""Miscellaneous command handlers (trust, history, report).""" -# pylint: disable=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 - -console = Console() - - -def _handle_trust_command(cmd: str, agent: Agent) -> None: - """Inspect / mutate the Executor's turn-scoped trust cache.""" - parts = cmd.strip().split() - ex = agent.executor - sub = parts[1] if len(parts) > 1 else "" - - if not sub or sub == "list": - allow, commands, deny = ex.trust_snapshot() - if allow: - console.print("[bold green]Trusted tools this turn:[/bold green]") - for name in sorted(allow): - console.print(f" ✓ {escape(name)}") - if commands: - console.print( - "[bold green]Trusted commands this turn:[/bold green]", - ) - for name in sorted(commands): - console.print(f" ✓ {escape(name)}") - if deny: - console.print("[bold red]Denied tools this turn:[/bold red]") - for name in sorted(deny): - console.print(f" ✗ {escape(name)}") - if not allow and not commands and not deny: - console.print( - "[dim]No trust/deny cache this turn " - "(auto-cleared when the turn ends)[/dim]", - ) - console.print( - "\n[dim]Usage:\n" - " /trust — list cache\n" - " /trust clear — clear now (no need to wait for " - "the turn to end)\n" - " /trust allow — pre-trust a whole tool for this turn\n" - " /trust deny — pre-deny a tool for this turn\n" - " (pressing [s]top in the prompt aborts " - "this turn; nothing is added to the deny cache)\n" - " \\[a]lways at a run_command prompt trusts that one command " - "only,\n" - " never the whole shell — /trust allow run_command does.[/dim]", - ) - return - - if sub == "clear": - allow, commands, deny = ex.trust_snapshot() - ex.clear_session_trust() - console.print( - f"[dim]Cleared {len(allow) + len(commands) + len(deny)} " - "cache entries[/dim]", - ) - return - - if sub in ("allow", "deny") and len(parts) >= 3: - tool = parts[2] - if sub == "allow": - ex.trust_tool(tool) - console.print( - f"[green]✓ {escape(tool)} trusted for this turn[/green]", - ) - if tool == "run_command": - console.print( - "[dim yellow] this pre-approves every shell command " - "for the turn; unlike \\[a]lways at the prompt, which " - "is scoped to one command[/dim yellow]", - ) - else: - ex.deny_tool(tool) - console.print(f"[red]✗ {escape(tool)} denied for this turn[/red]") - return - - console.print( - "[dim]Usage: /trust [list|clear|allow |deny ][/dim]", - ) - - -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 ( - clear_history, - export_history, - list_history, - stats, - ) - - parts = cmd.strip().split() - if len(parts) < 2: - s = stats() - if not s or s["count"] == 0: - console.print("[dim]No conversation history yet[/dim]") - else: - console.print("[bold]Conversation history stats:[/bold]") - console.print(f" Total conversations: {s['count']}") - console.print(f" Total turns: {s['total_turns']}") - entries = list_history(limit=5) - if entries: - console.print("\n[bold]Last 5 conversations:[/bold]") - for e in entries: - console.print( - f" - {e.get('summary', '')} " - f"({e.get('created_at', '')})", - ) - console.print( - "\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]", - ) - return - - sub = parts[1].lower() - - if sub == "stats": - s = stats() - if not s or s["count"] == 0: - console.print("[dim]No conversation history yet[/dim]") - else: - console.print("[bold]Conversation history stats:[/bold]") - console.print(f" Total conversations: {s['count']}") - console.print(f" Total turns: {s['total_turns']}") - return - - if sub == "list": - try: - limit = int(parts[2]) if len(parts) >= 3 else 10 - except ValueError: - console.print( - "[red]Usage: /history list [n] " - "(n must be an integer)[/red]", - ) - return - entries = list_history(limit=limit) - if not entries: - console.print("[dim]No conversation history yet[/dim]") - else: - console.print(f"[bold]Last {len(entries)} conversations:[/bold]") - for i, e in enumerate(entries, 1): - console.print(f" {i}. {e.get('summary', '')}") - console.print( - f" {e.get('created_at', '')} | " - f"{e.get('turns', 0)} turns", - ) - 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" - if "--format" in parts: - idx = parts.index("--format") - if idx + 1 < len(parts): - f = parts[idx + 1].lower() - fmt = "markdown" if f in ("md", "markdown") else f - elif output_path.endswith(".json"): - fmt = "json" - elif output_path.endswith((".md", ".markdown")): - fmt = "markdown" - try: - resolved = export_history(output_path, fmt=fmt) - console.print( - f"[green]✓ History exported to: {resolved} " - f"(format: {fmt})[/green]", - ) - except Exception as e: - console.print(f"[red]Export failed: {e}[/red]") - return - - if sub == "clear": - count = clear_history() - console.print(f"[green]✓ Cleared {count} history records[/green]") - return - - usage = "[dim]Usage: /history [stats|list|search|export|clear][/dim]" - console.print(usage) - - -def _handle_report_command(agent: Agent) -> None: - """Generate and display a performance report.""" - from dashscope.acli.memory.trace import generate_report - - report = generate_report(agent.trace_logger) - if not report: - console.print("[dim]Not enough trace data to generate a report[/dim]") - return - - console.print("[bold]Performance report:[/bold]") - console.print(f" Total LLM calls: {report['total_llm_calls']}") - console.print(f" Total tool calls: {report['total_tool_calls']}") - console.print(f" Tool success rate: {report['tool_success_rate']:.1%}") - console.print(f" Avg response time: {report['avg_response_time']:.2f}s") - - if report.get("top_tools"): - console.print("\n[bold]Top 5 tools:[/bold]") - for name, count in report["top_tools"][:5]: - console.print(f" {name}: {count}") diff --git a/dashscope/acli/cli/handlers_profile.py b/dashscope/acli/cli/handlers_profile.py deleted file mode 100644 index 00a9bf53..00000000 --- a/dashscope/acli/cli/handlers_profile.py +++ /dev/null @@ -1,272 +0,0 @@ -# -*- coding: utf-8 -*- -"""Profile and memory command handlers.""" -# pylint: disable=too-many-return-statements,too-many-branches -# pylint: disable=too-many-statements - -from __future__ import annotations - -from rich.console import Console - -from dashscope.acli.config import Config - -console = Console() - -# Shared memory client (set by _run_loop / _run_tui_mode) -_memory_client = None - - -def set_memory_client(client) -> None: - """Set the shared memory client instance.""" - global _memory_client - _memory_client = client - - -async def _handle_profile_command(cmd: str, config: Config): - """Handle /profile commands (async).""" - global _memory_client - parts = cmd.strip().split(maxsplit=2) - - if not _memory_client: - if not config.memory_enabled: - console.print( - "[dim]User profile is disabled. Set " - "memory_enabled = true in config.toml to enable[/dim]", - ) - return - from dashscope.acli.platforms import get_memory_provider - - _memory_client = get_memory_provider(config) - if not _memory_client: - console.print( - "[red]No API Key or username configured; " - "user profile unavailable[/red]", - ) - return - - if len(parts) == 1: - console.print( - f"[bold]User Profile[/bold]: " - f"{'enabled' if config.memory_enabled else 'disabled'}", - ) - if config.user_name: - console.print(f"[dim]User: {config.user_name}[/dim]") - if config.memory_user_id: - console.print(f"[dim]Memory ID: {config.memory_user_id}[/dim]") - if config.memory_library_id: - console.print(f"[dim]Library: {config.memory_library_id}[/dim]") - console.print( - "\n[dim]Usage:\n" - " /profile — show status\n" - " /profile list — list entries\n" - " /profile search — search profile\n" - " /profile add — add info\n" - " /profile remove — remove an entry\n" - " /profile clear — clear profile[/dim]\n" - "\nNote: personal info mentioned in chat (tech stack, " - "preferences, etc.) is extracted and saved automatically.", - ) - return - - subcmd = parts[1] - - if subcmd == "list": - try: - nodes = await _memory_client.list(page_size=20) - if not nodes: - console.print("[dim]No profile entries yet[/dim]") - else: - console.print( - f"[bold]User Profile[/bold] ({len(nodes)} entries):", - ) - for i, n in enumerate(nodes, 1): - time_str = n.updated_at or n.created_at - console.print(f" {i}. {n.content}") - console.print( - f" [dim]{time_str} | id: {n.id[:8]}...[/dim]", - ) - except Exception as e: - console.print(f"[red]Failed to load profile: {e}[/red]") - - elif subcmd == "search": - query = parts[2] if len(parts) > 2 else "" - if not query: - console.print("[dim]Usage: /profile search [/dim]") - return - try: - nodes = await _memory_client.search(query, top_k=5, min_score=0.3) - if not nodes: - console.print("[dim]No matching info found[/dim]") - else: - console.print( - f"[bold]Search results[/bold] ({len(nodes)} entries):", - ) - for i, n in enumerate(nodes, 1): - console.print( - f" {i}. {n.content} " - f"[dim](score: {n.score:.2f})[/dim]", - ) - except Exception as e: - console.print(f"[red]Search failed: {e}[/red]") - - elif subcmd == "add": - content = parts[2] if len(parts) > 2 else "" - if not content: - console.print("[dim]Usage: /profile add [/dim]") - return - try: - nodes = await _memory_client.add([], custom_content=content) - if nodes: - console.print( - f"[green]Saved {len(nodes)} profile entries[/green]", - ) - else: - console.print("[green]Submitted[/green]") - except Exception as e: - console.print(f"[red]Save failed: {e}[/red]") - - elif subcmd in ("remove", "rm"): - idx_str = parts[2] if len(parts) > 2 else "" - if not idx_str: - console.print("[red]Usage: /profile remove [/red]") - return - try: - idx = int(idx_str) - 1 - nodes = await _memory_client.list(page_size=100) - if idx < 0 or idx >= len(nodes): - console.print(f"[red]Invalid number: {idx_str}[/red]") - return - target = nodes[idx] - await _memory_client.delete(target.id) - console.print(f"[green]✓ Entry removed ({idx_str})[/green]") - except ValueError: - console.print("[red]Please enter a numeric index[/red]") - except Exception as e: - console.print(f"[red]Delete failed: {e}[/red]") - - elif subcmd == "clear": - try: - nodes = await _memory_client.list(page_size=100) - if not nodes: - console.print("[dim]No profile entries to clear[/dim]") - return - count = 0 - for n in nodes: - try: - await _memory_client.delete(n.id) - count += 1 - except Exception: - pass - console.print(f"[green]Cleared {count} profile entries[/green]") - except Exception as e: - console.print(f"[red]Clear failed: {e}[/red]") - - else: - console.print( - "[dim]Usage:\n" - " /profile — show status\n" - " /profile list — list entries\n" - " /profile search — search profile\n" - " /profile add — add info\n" - " /profile remove — remove an entry\n" - " /profile clear — clear profile[/dim]", - ) - - -async def _handle_memory_command(cmd: str): - """Handle /memory commands for conversation history.""" - from dashscope.acli.platforms.local import history - - parts = cmd.strip().split(maxsplit=2) - - if len(parts) == 1: - # Show status - stats = history.stats() - console.print( - f"[bold]Chat history[/bold]: {stats['count']} records, " - f"{stats['total_turns']} turns", - ) - console.print( - "\n[dim]Usage:\n" - " /memory — show stats\n" - " /memory list [n] — show last n entries (default 20)\n" - " /memory search — search history\n" - " /memory remove — remove a record (ID or list index)\n" - " /memory clear — clear all history[/dim]\n" - "\nNote: a summary is stored after each conversation for " - "cross-session context recall.", - ) - return - - subcmd = parts[1] - - if subcmd == "list": - limit = int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else 20 - entries = history.list_history(limit) - if not entries: - console.print("[dim]No conversation history yet[/dim]") - else: - console.print(f"[bold]Last {len(entries)} conversations[/bold]:") - for i, entry in enumerate(entries, 1): - created = entry.get("created_at", "")[:16] # truncate ISO - turns = entry.get("turns", 0) - summary = entry.get("summary", "") - console.print( - f" {i}. [dim]{created}[/dim] ({turns} turns) {summary}", - ) - console.print(f" [dim]id: {entry['id']}[/dim]") - - elif subcmd == "search": - query = parts[2] if len(parts) > 2 else "" - if not query: - console.print("[dim]Usage: /memory search [/dim]") - return - results = history.search_history(query) - if not results: - console.print("[dim]No matching history found[/dim]") - else: - console.print( - f"[bold]Search results[/bold] ({len(results)} entries):", - ) - for i, entry in enumerate(results, 1): - created = entry.get("created_at", "")[:16] - console.print( - f" {i}. [dim]{created}[/dim] {entry['summary']}", - ) - - elif subcmd in ("remove", "rm"): - entry_id = parts[2] if len(parts) > 2 else "" - if not entry_id: - console.print("[dim]Usage: /memory remove [/dim]") - return - if history.delete_history(entry_id): - console.print(f"[green]Deleted record {entry_id}[/green]") - return - # Fallback: treat as 1-based index from /memory list - try: - idx = int(entry_id) - 1 - entries = history.list_history(20) - if 0 <= idx < len(entries): - target = entries[idx] - if history.delete_history(target["id"]): - console.print( - f"[green]Deleted record #{entry_id}: " - f"{target.get('summary', '')[:40]}[/green]", - ) - return - except ValueError: - pass - console.print(f"[red]Record {entry_id} not found[/red]") - - elif subcmd == "clear": - count = history.clear_history() - console.print(f"[green]Cleared {count} history records[/green]") - - else: - console.print( - "[dim]Usage:\n" - " /memory — show stats\n" - " /memory list [n] — show last n entries\n" - " /memory search — search history\n" - " /memory remove — remove a record (ID or list index)\n" - " /memory clear — clear all history[/dim]", - ) diff --git a/dashscope/acli/cli/handlers_provider.py b/dashscope/acli/cli/handlers_provider.py deleted file mode 100644 index ae9f588b..00000000 --- a/dashscope/acli/cli/handlers_provider.py +++ /dev/null @@ -1,357 +0,0 @@ -# -*- coding: utf-8 -*- -"""Provider configuration wizard (/provider). - -Q&A-style, modeled on /setup: one linear flow — -provider → key → model → protocol — with Enter accepting the default at -every step. The former arg-style subcommands (/provider use|model|protocol| -key) were removed — /provider always opens the wizard. -""" -# pylint: disable=too-many-branches,too-many-statements - -from __future__ import annotations - -from rich.console import Console - -from dashscope.acli.agent import Agent -from dashscope.acli.cli.startup import _print_provider_debug -from dashscope.acli.config import ( - PROVIDER_MODELS, - PROVIDERS, - Config, - normalize_model_name, - register_custom_model, -) -from dashscope.acli.extensions import find_provider -from dashscope.acli.providers import get_provider_chain - -console = Console() - - -def _read_choice(prompt: str, default: str = "") -> str: - """Read a menu choice; EOF/Ctrl-C accepts the default.""" - try: - return input(prompt).strip() or default - except (EOFError, KeyboardInterrupt): - console.print() - return default - - -def _read_secret(prompt: str) -> str: - """Read a secret (no echo); EOF/Ctrl-C means empty.""" - try: - import getpass - - return getpass.getpass(prompt).strip() - except (EOFError, KeyboardInterrupt): - console.print() - return "" - - -def _numbered_pick( - label: str, - options: list[str], - default: str, - custom_hint: str = "", -) -> str: - """Show a numbered list and read a choice (number or name). - - Empty input keeps `default`. Free-form names are returned as-is so - callers can accept unlisted values (e.g. custom model names). - """ - if options: - console.print(f"[bold]{label}:[/bold]") - for i, name in enumerate(options, 1): - marker = "[green]✓[/green]" if name == default else " " - console.print(f" [{i}] {marker} {name}") - if custom_hint: - console.print(f"[dim]{custom_hint}[/dim]") - hint = f" [{default}]" if default else "" - raw = _read_choice(f"Choose (number or name){hint}: ", default=default) - if raw.isdigit() and options and 1 <= int(raw) <= len(options): - raw = options[int(raw) - 1] - return raw - - -def _rebuild_provider(agent: Agent, config: Config) -> bool: - """Rebuild the provider chain after a config change; sync agent names.""" - try: - agent.provider = get_provider_chain(config) - except Exception as e: - console.print(f"[red]Switch failed: {e}[/red]") - console.print( - "[dim]If an API Key is missing, run /provider again[/dim]", - ) - return False - agent.provider_name = config.provider - agent.model_name = config.model - _print_provider_debug(agent.provider) - return True - - -def _warn_missing_ext_key(config: Config) -> None: - """Warn when the active extension provider has no resolvable key — - otherwise the chain would silently fall back on auth failure.""" - ext = find_provider(config.provider) - if ( - ext is None - or config.provider in PROVIDERS - or ext.resolve_api_key(config) - ): - return - env_hint = ext.api_key_env or "(none)" - console.print( - f"[yellow]⚠️ {config.provider} is missing an API Key " - f"(env {env_hint} unset; no encrypted key in toml either)" - f"[/yellow]", - ) - - -def _wizard_key_step(config: Config) -> None: - """Step 2: API key — always shown; Enter keeps the current key / skips.""" - from dashscope.acli.cli.handlers_key import ( - _set_extension_provider_token, - all_key_targets, - ) - - info = all_key_targets(config).get(config.provider) - if info is None: - return - if info.get("no_auth"): - console.print( - f"[dim]{config.provider} needs no API Key (auth = false)[/dim]", - ) - return - - existing = getattr(config, info["field"], "") or "" - if not existing and info.get("extension"): - # May still resolve from the toml-encrypted key or an env var. - ext = find_provider(config.provider) - if ext is not None: - existing = ext.resolve_api_key(config) or "" - - if existing: - hint = f" [set …{existing[-4:]}, Enter to keep]" - elif info.get("env"): - hint = f" ({info['env']}) [Enter to skip]" - else: - hint = " [Enter to skip]" - value = _read_secret(f"\n{config.provider} API Key{hint}: ") - if not value: - if not existing: - env_hint = f"or env var {info['env']} " if info.get("env") else "" - console.print( - f"[dim]Skipped {config.provider} key; set it later " - f"via /provider {env_hint}[/dim]", - ) - return - - if info.get("extension"): - ext = find_provider(config.provider) - if ext is not None: - _set_extension_provider_token(ext, config, direct_value=value) - return - setattr(config, info["field"], value) - config.save_global() - console.print(f"[green]✓ {config.provider} API Key saved[/green]") - - -def _provider_wizard(agent: Agent, config: Config) -> bool: - """Bare /provider — linear Q&A: provider → key → model → protocol.""" - from dashscope.acli.extensions import current as ext_current - - console.print( - f"[dim]Current: {config.provider}/{config.model} " - f"protocol={config.protocol}[/dim]\n", - ) - - loaded = ext_current() - for err in loaded.errors: - console.print(f"[yellow]custom-extensions.toml: {err}[/yellow]") - - # 1) Provider — Enter keeps the current one, but only when it is - # loadable. A persisted extension provider whose custom-extensions.toml - # is not present here is absent from `names`, so offering it as the - # default turns a bare Enter into "Unknown provider; cancelled". - names = list(PROVIDER_MODELS) + [ - p.name for p in loaded.providers if p.name not in PROVIDER_MODELS - ] - current = config.provider if config.provider in names else "" - if config.provider and not current: - console.print( - f"[yellow]Configured provider '{config.provider}' is not " - "available here (no built-in or loaded extension by that " - "name), so Enter cannot keep it — pick one below.[/yellow]", - ) - provider = _numbered_pick( - "Available providers", - names, - current, - custom_hint=( - "Need one that is not listed? /dev provider add registers it " - "in custom-extensions.toml" - ), - ) - if not provider: - console.print("[dim]No provider chosen; cancelled[/dim]") - return True - if provider not in names: - console.print(f"[red]Unknown provider: {provider}; cancelled[/red]") - return True - - ext = find_provider(provider) - if provider != config.provider: - config.provider = provider - config.base_url = "" # old provider's endpoint override doesn't apply - if ext is not None: - ext_models = ext.resolved_models() - config.model = ext.default_model or ( - ext_models[0] if ext_models else config.model - ) - config.protocol = ext.resolved_protocol() - elif PROVIDER_MODELS.get(provider): - config.model = PROVIDER_MODELS[provider][0] - - # 2) API Key — always shown; Enter keeps the current key / skips. - _wizard_key_step(config) - _warn_missing_ext_key(config) - - # 3) Model — Enter keeps the provider default / current model. - models = PROVIDER_MODELS.get(config.provider, []) - if not models and ext is not None: - models = ext.resolved_models() - if models: - default_model = config.model if config.model in models else models[0] - else: - default_model = config.model - console.print( - f"[dim]{config.provider} has no preset model list; " - f"enter a model name directly[/dim]", - ) - model = _numbered_pick( - f"{config.provider} available models", - models, - default_model, - custom_hint=( - "Not listed? Enter a new model name to register it " - "(same as /dev model add)" - ), - ) - if model: - normalized = normalize_model_name(model) - if normalized != model.strip(): - console.print( - f"[dim]Model name normalized to {normalized} " - f"(API model IDs are case-sensitive)[/dim]", - ) - if ( - models - and normalized not in models - and config.provider in PROVIDER_MODELS - ): - register_custom_model(config, config.provider, normalized) - console.print( - f"[green]✓ Registered {config.provider}/{normalized}[/green]", - ) - config.model = normalized - - # 4) Protocol — for extension providers the default comes from the toml. - ext_proto = ext.resolved_protocol() if ext is not None else None - if ext_proto: - console.print( - f"[dim]{config.provider} toml declares protocol: " - f"{ext_proto}[/dim]", - ) - proto = _numbered_pick( - "Protocol", - ["openai", "anthropic"], - config.protocol, - ) - if proto in ("openai", "anthropic"): - if ext_proto and proto != ext_proto: - console.print( - f"[yellow]⚠️ {config.provider}'s toml declares " - f"protocol {ext_proto}; switching to {proto} may fail " - f"with 404[/yellow]", - ) - config.protocol = proto - elif proto: - console.print( - f"[yellow]Unknown protocol '{proto}'; " - f"keeping {config.protocol}[/yellow]", - ) - - config.save_global() - config.save_workspace() - if _rebuild_provider(agent, config): - console.print( - f"[green]✓ Switched: {config.provider}/{config.model} " - f"protocol={config.protocol}[/green]", - ) - return True - - -def handle_provider_command(cmd: str, agent: Agent, config: Config) -> bool: - """/provider always opens the interactive wizard. - - Trailing args from the retired subcommand form - (/provider use|model|protocol|key ...) are ignored. - """ - if cmd.split()[1:]: - console.print( - "[dim]/provider is now a Q&A wizard; args ignored[/dim]", - ) - return _provider_wizard(agent, config) - - -def handle_model_command(cmd: str, agent: Agent, config: Config) -> bool: - """/model [name] — switch the model inside the current provider. - - Users coming from other CLIs type /model reflexively; making them - rediscover it under the /provider wizard costs a turn. Provider, key - and protocol stay with /provider. - """ - parts = cmd.split(maxsplit=1) - if len(parts) == 1 or not parts[1].strip(): - console.print( - f"[cyan]{config.provider}/{config.model}[/cyan] " - f"protocol={config.protocol}", - ) - console.print( - "[dim]Usage: /model switches the model; " - "/provider covers provider/key/protocol.[/dim]", - ) - return True - - model = parts[1].strip() - normalized = normalize_model_name(model) - if normalized != model: - console.print( - f"[dim]Model name normalized to {normalized} " - f"(API model IDs are case-sensitive)[/dim]", - ) - - # Same rule as the wizard's model step: an unlisted name under a - # built-in provider is registered as a custom model; under an - # extension provider it is used as-is. - models = PROVIDER_MODELS.get(config.provider, []) - if not models: - ext = find_provider(config.provider) - if ext is not None: - models = ext.resolved_models() - if ( - models - and normalized not in models - and config.provider in PROVIDER_MODELS - ): - register_custom_model(config, config.provider, normalized) - console.print( - f"[green]✓ Registered {config.provider}/{normalized}[/green]", - ) - config.model = normalized - config.save_global() - config.save_workspace() - if _rebuild_provider(agent, config): - console.print( - f"[green]✓ Switched: {config.provider}/{config.model}[/green]", - ) - return True diff --git a/dashscope/acli/cli/handlers_session.py b/dashscope/acli/cli/handlers_session.py deleted file mode 100644 index e879987c..00000000 --- a/dashscope/acli/cli/handlers_session.py +++ /dev/null @@ -1,213 +0,0 @@ -# -*- coding: utf-8 -*- -"""Session management command handlers.""" -# pylint: disable=too-many-branches,too-many-statements,unused-argument -# pylint: disable=too-many-return-statements - -from __future__ import annotations - -from rich.console import Console - -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 fork [src] — fork a session (default src: " - "current)\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.""" - from dashscope.acli.session import get_session_manager - - session_mgr = get_session_manager() - parts = cmd.strip().split(maxsplit=2) - - if len(parts) == 1: - # Show current topic - current = session_mgr.get_current_topic() - console.print(f"[bold]Current session[/bold]: {current}") - console.print(_SESSION_USAGE) - return - - subcmd = parts[1] - - if subcmd == "new": - topic = parts[2] if len(parts) > 2 else "default" - # Archive current session first - if agent.session_path and agent.messages: - agent.save_session() - - if session_mgr.create_topic(topic): - # Reset agent and switch to new topic - agent.reset() - agent.session_path = session_mgr.get_history_path(topic) - console.print( - f"[green]Created and switched to new session: " - f"{topic}[/green]", - ) - else: - console.print( - f"[yellow]Session '{topic}' already exists; use " - f"/session switch {topic} to switch[/yellow]", - ) - - elif subcmd == "list": - topics = session_mgr.list_topics() - if not topics: - console.print("[dim]No sessions yet[/dim]") - else: - current = session_mgr.get_current_topic() - console.print(f"[bold]Session list[/bold] ({len(topics)}):") - for meta in topics: - marker = " ← current" if meta.topic == current else "" - accessed = ( - meta.last_accessed[:16] if meta.last_accessed else "" - ) - console.print(f" • [cyan]{meta.topic}[/cyan]{marker}") - console.print( - f" [dim]{meta.message_count} messages, " - f"last accessed: {accessed}[/dim]", - ) - - elif subcmd == "switch": - topic = parts[2] if len(parts) > 2 else "" - if not topic: - console.print("[dim]Usage: /session switch [/dim]") - return - - if session_mgr.set_current_topic(topic): - # Save current session - if agent.session_path and agent.messages: - agent.save_session() - - # Reset and load new topic - agent.reset() - agent.session_path = session_mgr.get_history_path(topic) - restored = agent.load_session() - console.print(f"[green]Switched to session: {topic}[/green]") - if restored: - console.print( - f" [dim]Restored {restored} " f"history messages[/dim]", - ) - else: - console.print( - f"[red]Session '{topic}' does not exist; " - f"see /session list for available sessions[/red]", - ) - - elif subcmd == "rename": - if len(parts) < 3: - console.print("[dim]Usage: /session rename [/dim]") - return - old_new = parts[2].split(maxsplit=1) - if len(old_new) < 2: - console.print("[dim]Usage: /session rename [/dim]") - return - old_name, new_name = old_new - - was_current = session_mgr.get_current_topic() == old_name - if session_mgr.rename_topic(old_name, new_name): - # rename_topic already moved the current-topic pointer; also - # repoint the live agent's session file so history keeps - # appending to the renamed topic instead of the old path. - if was_current and agent.session_path: - agent.session_path = session_mgr.get_history_path(new_name) - console.print(f"[green]Renamed: {old_name} → {new_name}[/green]") - else: - console.print( - f"[red]Rename failed: '{old_name}' does not exist " - f"or '{new_name}' already exists[/red]", - ) - - elif subcmd == "fork": - if len(parts) < 3: - console.print( - "[dim]Usage: /session fork [src][/dim]", - ) - return - fork_args = parts[2].split(maxsplit=1) - dst = fork_args[0] - src = ( - fork_args[1] - if len(fork_args) > 1 - else (session_mgr.get_current_topic()) - ) - # Flush the live conversation when forking the active topic so - # the fork carries the latest messages. - if src == session_mgr.get_current_topic(): - if agent.session_path and agent.messages: - agent.save_session() - if session_mgr.fork_topic(src, dst): - console.print( - f"[green]Forked session: {src} → {dst}[/green]\n" - f" [dim]Switch with: /session switch {dst}[/dim]", - ) - else: - console.print( - f"[red]Fork failed: '{src}' does not exist " - f"or '{dst}' already exists[/red]", - ) - - elif subcmd == "remove": - topic = parts[2] if len(parts) > 2 else "" - if not topic: - console.print("[dim]Usage: /session remove [/dim]") - return - - if topic == "default": - console.print("[yellow]Cannot delete the default session[/yellow]") - return - - if session_mgr.delete_topic(topic): - console.print(f"[green]Removed session: {topic}[/green]") - 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(_SESSION_USAGE) diff --git a/dashscope/acli/cli/handlers_setup.py b/dashscope/acli/cli/handlers_setup.py deleted file mode 100644 index fbf49fa0..00000000 --- a/dashscope/acli/cli/handlers_setup.py +++ /dev/null @@ -1,311 +0,0 @@ -# -*- coding: utf-8 -*- -"""Workspace setup command handlers.""" - -from __future__ import annotations - -import re - -from rich.console import Console - -from dashscope.acli.cli.constants import ( - ALL_CAPABILITY_KEYS, - CAPABILITY_CATALOG, - KEY_TARGETS, -) -from dashscope.acli.config import PROVIDER_MODELS, Config, normalize_model_name -from dashscope.acli.utils.ids import stable_memory_user_id - -console = Console() - - -def _prompt_input(prompt: str, secret: bool = False) -> str: - """Read user input. When secret=True, characters are not echoed.""" - try: - if secret: - import getpass - - return getpass.getpass(prompt).strip() - return input(prompt).strip() - except (EOFError, KeyboardInterrupt): - return "" - - -def _setup_user_name(config: Config) -> None: - current_name = config.user_name or "" - name = input( - f"Username{f' [{current_name}]' if current_name else ''}: ", - ).strip() - if name: - config.user_name = name - elif not current_name: - config.user_name = "anonymous" - - -def _ensure_provider_key(config: Config) -> None: - """Prompt for the current provider's API key only if it's missing. - - Empty input means "skip for now"; the user can set it later via - /provider or the corresponding environment variable. - """ - key_info = KEY_TARGETS.get(config.provider) - if not key_info: - from dashscope.acli.cli.handlers_key import all_key_targets - - targets = all_key_targets(config) - key_info = targets.get(config.provider) - if not key_info: - return - field = key_info["field"] - if getattr(config, field, ""): - return - value = _prompt_input( - f"\n{config.provider} API Key ({key_info['env']}) [Enter to skip]: ", - secret=True, - ) - if value: - setattr(config, field, value) - else: - env_hint = ( - f"or env var {key_info['env']} " if key_info.get("env") else "" - ) - console.print( - f"[dim]Skipped {config.provider} key; set it later via " - f"/provider {env_hint}[/dim]", - ) - - -def _warn_unsatisfiable_capabilities(config: Config) -> None: - """For each enabled capability with missing required config, prompt - for each field — but treat empty input as 'defer'. The capability - stays in enabled_capabilities regardless; what's missing simply - means register_one_capability registers 0 tools at boot until the - user fills the creds later (via /provider or re-enable). - - User intent: keep asking (so it's easy to fill in one go), but make - 'skip with Enter' frictionless and non-destructive. - """ - from dashscope.acli.utils.sanitizer import is_secret_field - - caps_to_check = ( - config.enabled_capabilities - if config.enabled_capabilities is not None - else ALL_CAPABILITY_KEYS - ) - deferred: list[tuple[str, list[str]]] = [] - for cap_info in CAPABILITY_CATALOG: - if cap_info["key"] not in caps_to_check: - continue - missing_before = [ - f for f in cap_info["requires"] if not getattr(config, f, "") - ] - if not missing_before: - continue - console.print( - f"\n[yellow]{cap_info['key']} needs configuration[/yellow] " - f"[dim](Enter skips a field; the capability stays enabled " - f"and can be completed later via /setup)[/dim]:", - ) - for field in cap_info["requires"]: - if getattr(config, field, ""): - continue # already set - value = _prompt_input( - f" {field}: ", - secret=is_secret_field(field), - ) - if value: - setattr(config, field, value) - missing_after = [ - f for f in cap_info["requires"] if not getattr(config, f, "") - ] - if missing_after: - deferred.append((cap_info["key"], missing_after)) - - if deferred: - console.print( - "\n[yellow]These capabilities lack credentials; they stay " - "enabled but are not callable yet:[/yellow]", - ) - for cap_key, miss in deferred: - console.print( - f" [dim]·[/dim] [bold]{cap_key}[/bold] " - f"missing: {', '.join(miss)}", - ) - console.print( - "[dim]They take effect on the next launch once filled in; " - "or run /setup, then [bold]/capability enable [/bold] " - "to activate immediately.[/dim]", - ) - - -def _setup_finalize(config: Config, agent) -> None: - """Common tail: derive memory_user_id, persist, rebuild agent provider, - re-register tools based on new capabilities, print summary.""" - if not config.memory_user_id: - config.memory_user_id = stable_memory_user_id() - - config.save_global() - config.save_workspace() - - from dashscope.acli.providers import get_provider_chain - - agent.provider = get_provider_chain(config) - agent.provider_name = config.provider - agent.model_name = config.model - agent.user_name = config.user_name - - # Re-register platform tools based on new enabled_capabilities - from dashscope.acli.tools.platform import ( - _capability_tools, - register_platform_tools, - unregister_capability_tools, - ) - - # Clear all existing capability tools first - for cap_key in list(_capability_tools.keys()): - unregister_capability_tools(cap_key) - # Then register based on new config (same wiring as boot: repl.py passes - # connect_mcp_fn so the mcp_connect tool gets registered). - from dashscope.acli.cli.mcp import _connect_mcp - - register_platform_tools(config, connect_mcp_fn=_connect_mcp) - - console.print("\n[green]✓ Config saved to .acli/config.toml[/green]") - caps_display = ( - ALL_CAPABILITY_KEYS - if config.enabled_capabilities is None - else config.enabled_capabilities - ) - from dashscope.acli.tools.registry import registry - - tool_count = len(registry.list_tools()) - console.print(f"[dim] User: {config.user_name}[/dim]") - console.print(f"[dim] Model: {config.provider}/{config.model}[/dim]") - console.print( - f"[dim] Capabilities: " - f"{', '.join(caps_display) if caps_display else '(none)'}[/dim]", - ) - console.print(f"[dim] Tools: {tool_count} registered[/dim]") - - -# ---- preset 1: Alibaba/Bailian ---- - - -def _apply_preset_bailian(config: Config) -> None: - config.provider = "tongyi" - config.model = "qwen3.8-max" - config.enabled_capabilities = [ - "bailian.mcp", - "bailian.cli", - ] - _ensure_provider_key(config) - _warn_unsatisfiable_capabilities(config) - - -# ---- preset 2: China general ---- - - -def _apply_preset_china_common(config: Config) -> None: - config.provider = "tongyi" - config.model = "qwen-max" - config.enabled_capabilities = ["bailian.mcp"] - _ensure_provider_key(config) - _warn_unsatisfiable_capabilities(config) - - -# ---- preset 3: custom (former _handle_setup full interactive flow) ---- - - -async def _setup_preset_custom(config: Config) -> None: - # username already collected by _handle_setup before preset dispatch - from dashscope.acli.extensions import current - - ext = current() - ext_providers = {p.name: p.resolved_models() for p in ext.providers} - all_providers = list(PROVIDER_MODELS.keys()) + [ - p for p in ext_providers if p not in PROVIDER_MODELS - ] - console.print(f"\nAvailable providers: {', '.join(all_providers)}") - provider_input = input(f"Provider [{config.provider}]: ").strip() - if provider_input and provider_input in all_providers: - config.provider = provider_input - - models = PROVIDER_MODELS.get(config.provider, []) or ext_providers.get( - config.provider, - [], - ) - if models: - console.print(f"Available models: {', '.join(models)}") - # If current model isn't in this provider's list, suggest the first one - default_model = config.model if config.model in models else models[0] - else: - default_model = config.model - model_input = input(f"Model [{default_model}]: ").strip() - if model_input: - config.model = normalize_model_name(model_input) - elif default_model != config.model: - config.model = default_model - - _ensure_provider_key(config) - - console.print("\n[bold]Available platform capabilities:[/bold]") - for i, cap in enumerate(CAPABILITY_CATALOG, 1): - console.print(f" [{i}] {cap['key']:20s} — {cap['name']}") - - console.print( - "\n[bold]Enter the numbers of capabilities to enable[/bold] " - "[dim](separate multiple with commas or spaces; " - "Enter enables none)[/dim]", - ) - - selection = input("> ").strip() - if selection: - selected = [] - for part in re.split(r"[,\s]+", selection): - if part.isdigit(): - idx = int(part) - 1 - if 0 <= idx < len(CAPABILITY_CATALOG): - selected.append(CAPABILITY_CATALOG[idx]["key"]) - config.enabled_capabilities = selected - else: - config.enabled_capabilities = [] - - _warn_unsatisfiable_capabilities(config) - - -async def _handle_setup(config: Config, agent) -> None: - """Workspace setup — ask username first, then pick a preset - (default [1] Alibaba/Bailian), or take the full interactive flow.""" - console.print("[bold]Workspace setup[/bold]\n") - - # Username comes first — it's identity, not configuration. The preset - # then says "for user X, here are sensible defaults". - _setup_user_name(config) - - console.print("\nSelect a configuration mode:") - console.print( - " [cyan][1][/cyan] [bold]Alibaba/Bailian[/bold] (default) — " - "tongyi/qwen3.8-max + bailian.mcp/cli", - ) - console.print( - " [cyan][2][/cyan] China general — " - "tongyi/qwen-max + bailian.mcp", - ) - console.print( - " [cyan][3][/cyan] Custom — " - "pick each item (Provider / Model / Capabilities)", - ) - - choice = input("\nChoice [1]: ").strip() or "1" - if choice == "2": - _apply_preset_china_common(config) - elif choice == "3": - await _setup_preset_custom(config) - else: - if choice != "1": - console.print( - f"[dim]Unrecognized '{choice}'; " - f"using [1] Alibaba/Bailian[/dim]", - ) - _apply_preset_bailian(config) - - _setup_finalize(config, agent) diff --git a/dashscope/acli/cli/handlers_skill.py b/dashscope/acli/cli/handlers_skill.py deleted file mode 100644 index 033ec27c..00000000 --- a/dashscope/acli/cli/handlers_skill.py +++ /dev/null @@ -1,373 +0,0 @@ -# -*- coding: utf-8 -*- -"""Skill and cron command handlers.""" -# pylint: disable=too-many-return-statements,too-many-branches -# pylint: disable=too-many-statements,protected-access,consider-using-get - -from __future__ import annotations - -from rich.console import Console - -from dashscope.acli.agent import Agent -from dashscope.acli.config import Config - -console = Console() - - -async def _handle_skill_package_command(sub: str, args: list[str]): - """Handle /skill package lifecycle subcommands.""" - from dashscope.acli.skills import get_skill_manager - - manager = get_skill_manager() - - if sub == "list": - from dashscope.acli.cli.mcp import _mcp_clients - from dashscope.acli.skills import list_skills - - connected = set(_mcp_clients.keys()) - console.print(list_skills(connected)) - _print_skill_packages() - return None - - if sub == "reload": - manager.reload() - console.print("[green]✓ Skill packages reloaded[/green]") - _print_skill_packages() - return None - - if sub in ("enable", "disable"): - if not args: - console.print(f"[red]Usage: /skill {sub} [/red]") - return None - name = args[0] - if sub == "enable": - if manager.enable(name): - console.print( - f"[green]✓ Enabled skill package: {name}[/green]", - ) - else: - console.print(f"[red]Skill package not found: {name}[/red]") - else: - if manager.disable(name): - console.print( - f"[green]✓ Disabled skill package: {name}[/green]", - ) - else: - console.print(f"[red]Skill package not found: {name}[/red]") - return None - - def _strip_at(value: str) -> str: - return value[1:] if value.startswith("@") else value - - if sub == "install": - if not args: - console.print( - "[red]Usage: /skill install " - "[/red]", - ) - return None - source = _strip_at(args[0]) - try: - name = manager.install(source) - console.print(f"[green]✓ Installed skill package: {name}[/green]") - except Exception as e: - console.print(f"[red]Install failed: {e}[/red]") - return None - - if sub == "link": - if not args: - console.print("[red]Usage: /skill link [/red]") - return None - source_dir = _strip_at(args[0]) - try: - name = manager.link(source_dir) - console.print(f"[green]✓ Linked skill package: {name}[/green]") - except Exception as e: - console.print(f"[red]Link failed: {e}[/red]") - return None - - if sub == "update": - if args and args[0] == "--all": - results = manager.update_all() - ok = [n for n, err in results if err is None] - failed = [(n, err) for n, err in results if err is not None] - if ok: - console.print( - f"[green]✓ Updated {len(ok)} skill packages: " - f"{', '.join(ok)}[/green]", - ) - if failed: - console.print("[red]Update failed:[/red]") - for n, err in failed: - console.print(f" - {n}: {err}") - if not ok and not failed: - console.print("[dim]No skill packages to update[/dim]") - return None - if not args: - console.print( - "[red]Usage: /skill update or " - "/skill update --all[/red]", - ) - return None - name = args[0] - try: - manager.update(name) - console.print(f"[green]✓ Updated skill package: {name}[/green]") - except Exception as e: - console.print(f"[red]Update failed: {e}[/red]") - return None - - if sub == "uninstall": - if not args: - console.print("[red]Usage: /skill uninstall [/red]") - return None - name = args[0] - if manager.uninstall(name): - console.print( - f"[green]✓ Uninstalled skill package: {name}[/green]", - ) - else: - console.print(f"[red]Skill package not found: {name}[/red]") - return None - - if sub == "search": - query = args[0] if args else "" - search_results = manager.search(query) - if not search_results: - console.print("[dim]No matching skill packages found[/dim]") - return None - console.print( - f"\n[bold]Search results ({len(search_results)}):[/bold]", - ) - for r in search_results: - status = ( - "[green]installed[/green]" - if r["installed"] - else "[dim]not installed[/dim]" - ) - console.print(f" {r['name']} v{r['version']} {status}") - if r.get("description"): - console.print(f" {r['description']}") - return None - - if sub == "publish": - if not args: - console.print("[red]Usage: /skill publish [/red]") - return None - source_dir = _strip_at(args[0]) - try: - url = manager.publish(source_dir) - console.print(f"[green]✓ Published skill package: {url}[/green]") - except Exception as e: - console.print(f"[red]Publish failed: {e}[/red]") - return None - - console.print(f"[red]Unknown /skill subcommand: {sub}[/red]") - return None - - -def _print_skill_packages(): - """Display installed skill packages.""" - from dashscope.acli.skills import get_skill_manager - - manager = get_skill_manager() - packages = manager.list() - if not packages: - console.print("[dim]No skill packages installed[/dim]") - return - - console.print( - f"\n[bold]Installed skill packages ({len(packages)}):[/bold]", - ) - for pkg in packages: - status = ( - "[green]always-on[/green]" - if pkg["always_active"] - else "[dim]on-demand[/dim]" - ) - desc = f" — {pkg['description']}" if pkg.get("description") else "" - console.print(f" {pkg['name']} v{pkg['version']} {status}{desc}") - - -async def _handle_skill_command( - cmd: str, - config: Config, - agent: Agent | None = None, -) -> str | None: - """Handle /skill command. - - Returns rendered prompt to feed to agent, or None if handled - internally.""" - # Ensure skills are loaded every time - from dashscope.acli.skills.base import load_skill_files - - load_skill_files() - - parts = cmd.strip().split(maxsplit=1) - if len(parts) < 2 or parts[1].strip() == "": - # /skill — list all skills - from dashscope.acli.cli.mcp import _mcp_clients - from dashscope.acli.skills import list_skills - - connected = set(_mcp_clients.keys()) - console.print(list_skills(connected)) - _print_skill_packages() - return None - - rest = parts[1].split() - skill_name = rest[0] - args = rest[1:] - - # Skill package management subcommands - package_subs = { - "add": "install", - "remove": "uninstall", - } - if skill_name in package_subs: - skill_name = package_subs[skill_name] - if skill_name in ( - "list", - "reload", - "enable", - "disable", - "install", - "link", - "update", - "uninstall", - "search", - "publish", - ): - return await _handle_skill_package_command(skill_name, args) - - from dashscope.acli.cli.mcp import _connect_mcp, _mcp_clients - from dashscope.acli.skills import BUILTIN_SKILLS, render_skill - - skill = BUILTIN_SKILLS.get(skill_name) - if not skill: - console.print(f"[red]Unknown skill: {skill_name}[/red]") - console.print("[dim]List available skills: /skill[/dim]") - return None - - # Auto-connect required MCP service - if skill.mcp_service and skill.mcp_service not in _mcp_clients: - console.print( - f"[dim]Connecting to MCP service: " - f"{skill.mcp_service}...[/dim]", - ) - err = await _connect_mcp(skill.mcp_service, config) - if err: - console.print(f"[red]Connection failed: {err}[/red]") - return None - - if not args: - arg_hint = " ".join(f"<{a}>" for a in skill.arguments) - console.print(f"[dim]Usage: /skill {skill_name} {arg_hint}[/dim]") - return None - - rendered = render_skill(skill, args) - if not rendered: - arg_hint = " ".join(f"<{a}>" for a in skill.arguments) - console.print( - f"[red]Missing arguments. Usage: " - f"/skill {skill_name} {arg_hint}[/red]", - ) - return None - - if agent is not None: - agent.note_skill_use(skill_name) - return rendered - - -async def _handle_cron_command(cmd: str, config: Config, agent: Agent): - """Handle /cron commands (async).""" - import dashscope.acli.cli as _pkg - - if _pkg._scheduler is None: - from dashscope.acli.scheduler import Scheduler - - _pkg._scheduler = Scheduler(config, agent) - await _pkg._scheduler.load_and_start() - - _scheduler = _pkg._scheduler - - parts = cmd.strip().split(maxsplit=1) - subcmd = parts[1].strip() if len(parts) > 1 else "" - - if not subcmd or subcmd == "list": - _scheduler.print_jobs() - return - - if subcmd.startswith("add"): - args_str = subcmd[3:].strip() - if not args_str: - console.print( - "[dim]Usage: /cron add every 5m skill weather " - "Hangzhou[/dim]", - ) - console.print( - "[dim] /cron add at 14:30 skill search AI news[/dim]", - ) - console.print( - '[dim] /cron add cron "0 9 * * 1-5" skill search ' - 'morning news condition "curl -sf http://health"[/dim]', - ) - return - from dashscope.acli.scheduler import parse_cron_add - - try: - schedule, skills, condition, subagent = parse_cron_add(args_str) - except ValueError as e: - console.print(f"[red]{e}[/red]") - return - from dashscope.acli.skills import BUILTIN_SKILLS - - for inv in skills: - if inv.name not in BUILTIN_SKILLS: - console.print(f"[red]Unknown skill: {inv.name}[/red]") - console.print("[dim]List available skills: /skill[/dim]") - return - job = _scheduler.add_job(schedule, skills, condition, subagent) - console.print(f"[green]✓ Scheduled job created:[/green] {job.id}") - console.print(f" Schedule: {schedule}") - console.print(f" Skills: {', '.join(s.name for s in skills)}") - if condition: - console.print(f" Condition: {condition}") - if schedule.kind == "at": - console.print( - " [dim](one-shot job; auto-disabled after run)[/dim]", - ) - return - - if subcmd.startswith("remove"): - job_id = subcmd[6:].strip() - if not job_id: - console.print("[red]Usage: /cron remove [/red]") - return - if _scheduler.remove_job(job_id): - console.print(f"[green]✓ Removed scheduled job: {job_id}[/green]") - else: - console.print(f"[red]Scheduled job not found: {job_id}[/red]") - return - - if subcmd.startswith("pause"): - job_id = subcmd[5:].strip() - if not job_id: - console.print("[red]Usage: /cron pause [/red]") - return - if _scheduler.pause_job(job_id): - console.print(f"[green]✓ Paused scheduled job: {job_id}[/green]") - else: - console.print(f"[red]Scheduled job not found: {job_id}[/red]") - return - - if subcmd.startswith("resume"): - job_id = subcmd[6:].strip() - if not job_id: - console.print("[red]Usage: /cron resume [/red]") - return - if _scheduler.resume_job(job_id): - console.print(f"[green]✓ Resumed scheduled job: {job_id}[/green]") - else: - console.print(f"[red]Scheduled job not found: {job_id}[/red]") - return - - console.print(f"[red]Unknown /cron subcommand: {subcmd}[/red]") diff --git a/dashscope/acli/cli/mcp.py b/dashscope/acli/cli/mcp.py deleted file mode 100644 index 845465c2..00000000 --- a/dashscope/acli/cli/mcp.py +++ /dev/null @@ -1,179 +0,0 @@ -# -*- coding: utf-8 -*- -"""MCP (Model Context Protocol) client management for acli CLI.""" - -from __future__ import annotations - -from rich.console import Console -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 - -console = Console() - -# Active MCP clients - shared state -_mcp_clients: dict[str, MCPClient | StdioMCPClient] = {} - - -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 | 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) - - if not await client.initialize(): - error = client.last_error - await client.close() - return error or "Initialization failed" - - tools = await client.list_tools() - for tool_info in tools: - tool_name = f"mcp_{service}_{tool_info['name']}" - description = tool_info.get("description", "") - input_schema = tool_info.get( - "inputSchema", - {"type": "object", "properties": {}}, - ) - - # Create closure for tool call - _tool_original_name = tool_info["name"] - _client = client - - async def _call(_cn=_tool_original_name, _cl=_client, **kwargs) -> str: - return await _cl.call_tool(_cn, kwargs) - - registry.register_mcp_tool( - name=tool_name, - description=f"[MCP:{service}] {description}", - parameters=input_schema, - call_fn=_call, - ) - - # Also discover prompts/skills (optional, server may not support) - await client.list_prompts() - - _mcp_clients[service] = client - return "" - - -async def _disconnect_mcp(service: str): - """Disconnect an MCP service and unregister its tools.""" - client = _mcp_clients.pop(service, None) - if not client: - return - for tool_info in client.tools: - registry.unregister(f"mcp_{service}_{tool_info['name']}") - await client.close() - - -async def _handle_mcp_command(cmd: str, config: Config): - """Handle /mcp commands (async).""" - parts = cmd.strip().split() - if len(parts) == 1: - # /mcp — list connected services - if not _mcp_clients: - console.print("[dim]No MCP services connected[/dim]") - console.print( - "[dim]Use /mcp list to see available services, " - "/mcp add to add one[/dim]", - ) - else: - console.print("[bold]Connected MCP services:[/bold]") - for svc, client in _mcp_clients.items(): - tool_count = len(client.tools) - console.print(f" {svc} — {tool_count} tools") - for t in client.tools: - console.print( - f" • {t['name']}: {t.get('description', '')[:60]}", - ) - elif parts[1] == "add" and len(parts) >= 3: - service = parts[2] - url = parts[3] if len(parts) > 3 else "" - with Status( - f"[dim]Connecting to MCP service {service}...[/dim]", - console=console, - spinner="aesthetic", - ): - error = await _connect_mcp(service, config, url=url) - if not error: - client = _mcp_clients[service] - console.print( - f"[green]Connected to {service}, found " - f"{len(client.tools)} tools[/green]", - ) - if not any(m.service == service for m in config.mcp_servers): - config.mcp_servers.append( - MCPServerConfig(service=service, url=url), - ) - config.save_workspace() - else: - console.print(f"[red]Failed to connect {service}: {error}[/red]") - elif parts[1] == "remove" and len(parts) >= 3: - service = parts[2] - await _disconnect_mcp(service) - config.mcp_servers = [ - m for m in config.mcp_servers if m.service != service - ] - config.save_workspace() - console.print(f"[dim]Removed {service}[/dim]") - elif parts[1] == "list": - console.print(list_known_services()) - else: - console.print( - "[dim]Usage:\n" - " /mcp — list connected services\n" - " /mcp list — show Bailian available services\n" - " /mcp add — add an MCP service\n" - " /mcp remove — remove an MCP service\n" - "Stdio servers: add [[mcp_servers]] in config.toml with " - 'transport = "stdio", command and args[/dim]', - ) - - -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, - server=mcp_cfg, - ) - if not error: - client = _mcp_clients[mcp_cfg.service] - summary = f"{len(client.tools)} tools" - if client.prompts: - summary += f", {len(client.prompts)} skills" - console.print( - f"[dim]MCP: {mcp_cfg.service} connected ({summary})[/dim]", - ) - else: - console.print( - f"[yellow]MCP: {mcp_cfg.service} connection failed - " - f"{error}[/yellow]", - ) diff --git a/dashscope/acli/cli/multimodal.py b/dashscope/acli/cli/multimodal.py deleted file mode 100644 index 94c6f346..00000000 --- a/dashscope/acli/cli/multimodal.py +++ /dev/null @@ -1,279 +0,0 @@ -# -*- coding: utf-8 -*- -"""Multimodal content handling: @-references, images, audio.""" - -# pylint: disable=too-many-return-statements,too-many-statements - -from __future__ import annotations - -import base64 -import os -import re -from pathlib import Path - -from dashscope.acli.cli.constants import ( - _AT_AUDIO_MAX_BYTES, - _AT_FILE_MAX_CHARS, - _AT_FILE_PATTERN, - _AUDIO_MIME, - _IMAGE_MIME, - _MEDIA_SENTINEL_RE, -) - - -def _expand_at_references(text: str) -> tuple[str, list[str], list[dict]]: - """Replace @ tokens with file contents. - - Returns ``(expanded_text, image_data_urls, audio_clips)``. Text files - become fenced code blocks; image files become sentinels - ``<<__ACLI_IMG_N__>>``; audio files become sentinels - ``<<__ACLI_AUDIO_N__>>``. The caller splices the returned data into - multimodal content blocks. - """ - images: list[str] = [] - audio_clips: list[dict] = [] - - _AT_DIR_MAX_CHARS = 50000 - _BINARY_EXTS = { - ".pyc", - ".pyo", - ".so", - ".dylib", - ".dll", - ".exe", - ".bin", - ".zip", - ".tar", - ".gz", - ".bz2", - ".xz", - ".7z", - ".rar", - ".pdf", - ".doc", - ".docx", - ".xls", - ".xlsx", - ".png", - ".jpg", - ".jpeg", - ".gif", - ".webp", - ".bmp", - ".ico", - ".mp4", - ".avi", - ".mov", - ".mkv", - ".woff", - ".woff2", - ".ttf", - ".eot", - ".otf", - } - - lang_map = { - ".py": "python", - ".js": "javascript", - ".ts": "typescript", - ".sh": "bash", - ".bash": "bash", - ".zsh": "bash", - ".toml": "toml", - ".yaml": "yaml", - ".yml": "yaml", - ".json": "json", - ".md": "markdown", - ".rs": "rust", - ".go": "go", - ".java": "java", - ".cpp": "cpp", - ".c": "c", - ".html": "html", - ".css": "css", - ".sql": "sql", - } - - def _expand_directory(dirpath: Path, raw: str) -> str: - import subprocess as _sp - - try: - result = _sp.run( - [ - "git", - "ls-files", - "--cached", - "--others", - "--exclude-standard", - ], - capture_output=True, - text=True, - cwd=str(dirpath), - timeout=5, - check=False, - ) - if result.returncode == 0: - files = [ - dirpath / f - for f in result.stdout.strip().splitlines() - if f - ] - else: - raise FileNotFoundError - except (FileNotFoundError, _sp.TimeoutExpired): - files = [] - for root, dirs, fnames in os.walk(str(dirpath)): - dirs[:] = [d for d in dirs if not d.startswith(".")] - for fn in fnames: - files.append(Path(root) / fn) - - total = 0 - blocks: list[str] = [] - file_count = 0 - for fp in sorted(files): - if fp.suffix.lower() in _BINARY_EXTS: - continue - if not fp.is_file(): - continue - try: - content = fp.read_text(encoding="utf-8", errors="replace") - except OSError: - continue - if total + len(content) > _AT_DIR_MAX_CHARS: - content = content[: _AT_DIR_MAX_CHARS - total] - rel = ( - fp.relative_to(dirpath) - if fp.is_relative_to(dirpath) - else fp - ) - lang = lang_map.get(fp.suffix.lower(), "") - blocks.append( - f"\n--- @{raw}{rel} ---\n```{lang}\n" - f"{content}\n... (truncated)\n```\n", - ) - file_count += 1 - break - rel = fp.relative_to(dirpath) if fp.is_relative_to(dirpath) else fp - lang = lang_map.get(fp.suffix.lower(), "") - blocks.append( - f"\n--- @{raw}{rel} ---\n```{lang}\n{content}\n```\n", - ) - total += len(content) - file_count += 1 - - if not blocks: - return f"[@{raw} directory is empty or has no text files]" - header = f"\n--- @{raw} ({file_count} files, {total} chars) ---\n" - return header + "".join(blocks) - - def _replace(match: re.Match) -> str: - raw = match.group(1) - p = Path(raw).expanduser() - if p.is_dir(): - return _expand_directory(p, raw) - if not p.is_file(): - return match.group(0) # leave @raw untouched - - suffix = p.suffix.lower() - mime = _IMAGE_MIME.get(suffix) - if mime: - from dashscope.acli.utils.images import image_to_data_url - - try: - url = image_to_data_url(p) - except ValueError as e: - return f"[@{raw} {e}]" - except OSError as e: - return f"[@{raw} read failed: {e}]" - images.append(url) - return f"<<__ACLI_IMG_{len(images) - 1}__>>" - - audio_info = _AUDIO_MIME.get(suffix) - if audio_info: - mime, fmt = audio_info - try: - data = p.read_bytes() - except OSError as e: - return f"[@{raw} read failed: {e}]" - if len(data) > _AT_AUDIO_MAX_BYTES: - return ( - f"[@{raw} audio too large ({len(data)//1024} KB > " - f"{_AT_AUDIO_MAX_BYTES//1024} KB); ignored]" - ) - audio_clips.append( - { - "mime": mime, - "format": fmt, - "data": base64.b64encode(data).decode(), - }, - ) - return f"<<__ACLI_AUDIO_{len(audio_clips) - 1}__>>" - - if suffix in _BINARY_EXTS: - return f"[@{raw} binary file, skipped]" - - try: - content = p.read_text(encoding="utf-8", errors="replace") - except OSError as e: - return f"[@{raw} read failed: {e}]" - truncated = "" - if len(content) > _AT_FILE_MAX_CHARS: - content = content[:_AT_FILE_MAX_CHARS] - truncated = ( - f"\n... (truncated; original > {_AT_FILE_MAX_CHARS} chars)" - ) - lang = lang_map.get(suffix, "") - return f"\n--- @{raw} ---\n```{lang}\n{content}{truncated}\n```\n" - - expanded = _AT_FILE_PATTERN.sub(_replace, text) - return expanded, images, audio_clips - - -def _to_multimodal_content( - text: str, - images: list[str], - audio_clips: list[dict] | None = None, -) -> str | list[dict]: - """Convert (text-with-sentinels, image-data-urls, audio-clips) into either - a plain string (no media) or an OpenAI-style content-block list.""" - audio_clips = audio_clips or [] - if not images and not audio_clips: - return text - blocks: list[dict] = [] - parts = _MEDIA_SENTINEL_RE.split(text) - # split yields [text0, kind0, idx0, text1, kind1, idx1, ..., textN]; - # segments starting at index 0 with step 3 are text, then kind, then idx. - for i, part in enumerate(parts): - if i % 3 == 0: - if part.strip(): - blocks.append({"type": "text", "text": part}) - elif i % 3 == 2: - kind = parts[i - 1] - try: - idx = int(part) - except ValueError: - continue - if kind == "IMG": - try: - blocks.append( - { - "type": "image_url", - "image_url": {"url": images[idx]}, - }, - ) - except IndexError: - continue - elif kind == "AUDIO": - try: - clip = audio_clips[idx] - blocks.append( - { - "type": "input_audio", - "input_audio": { - "data": clip["data"], - "format": clip["format"], - }, - }, - ) - except IndexError: - continue - return blocks or text diff --git a/dashscope/acli/cli/repl.py b/dashscope/acli/cli/repl.py deleted file mode 100644 index f824dc22..00000000 --- a/dashscope/acli/cli/repl.py +++ /dev/null @@ -1,617 +0,0 @@ -# -*- coding: utf-8 -*- -"""REPL main loop.""" -# pylint: disable=protected-access,too-many-branches,too-many-statements - -from __future__ import annotations - -import os -import sys - -from prompt_toolkit import PromptSession -from prompt_toolkit.filters import has_completions -from prompt_toolkit.key_binding import KeyBindings -from prompt_toolkit.patch_stdout import patch_stdout -from rich.console import Console -from rich.panel import Panel - -from dashscope.acli.agent import Agent -from dashscope.acli.cli.completer import ( - AcliCompleter, - SafeFileHistory, - _HintProcessor, -) -from dashscope.acli.cli.dispatch import ( - _handle_skill_continue, - _handle_slash_command, - dispatch_async_command, -) -from dashscope.acli.cli.handlers_capability import sync_extensions_into_catalog -from dashscope.acli.cli.handlers_key import ensure_provider_key -from dashscope.acli.cli.handlers_profile import set_memory_client -from dashscope.acli.cli.handlers_setup import _handle_setup -from dashscope.acli.cli.mcp import ( - _connect_mcp, - _disconnect_mcp, - _init_mcp_servers, - _mcp_clients, -) -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.config import ( - PROVIDER_MODELS, - WORKSPACE_CONFIG_FILE, - WORKSPACE_DIR, - Config, - is_audio_model, - is_vision_model, -) -from dashscope.acli.dev import _apply_custom_models -from dashscope.acli.executor import Executor -from dashscope.acli.providers import get_provider, get_provider_chain -from dashscope.acli.session import get_session_manager -from dashscope.acli.skills import list_known_services -from dashscope.acli.utils import UserAbortedTurn, UserSupplement - -console = Console() - - -def _reset_terminal_modes() -> None: - # A force-killed TUI leaves mouse-reporting modes on, which makes iTerm2 - # capture the scroll wheel so the scrollback region can't be scrolled. - if sys.stdout.isatty(): - sys.stdout.write("\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l") - sys.stdout.flush() - - -async def _run_loop(config: Config): - _reset_terminal_modes() - _apply_custom_models(config) - # Re-arm audit redaction from persisted config (restart must not drop it). - from dashscope.acli.audit import configure_audit_logger - - configure_audit_logger(config) - from dashscope.acli.debuglog import configure_debug_log - - configure_debug_log(config) - from dashscope.acli.permission import configure_permission_policy - - configure_permission_policy() - # Layer-1 extensions: load custom-extensions.toml entries into - # PROVIDER_MODELS and stash the spec for get_provider / tool registry. - from dashscope.acli.extensions import apply_extensions - - _ext = apply_extensions(PROVIDER_MODELS) - if _ext.errors: - for err in _ext.errors: - console.print( - f"[yellow]Extension load warning:[/yellow] " - f"[dim]{err}[/dim]", - ) - sync_extensions_into_catalog(_ext) - # _load_plugins() — deprecated; plugins load via the hooks mechanism - provider = get_provider_chain(config) - executor = Executor( - auto_approve=config.auto_approve, - confirm_mode=config.confirm_mode, - ) - - # Initialize memory - # Memory client managed by handlers_profile - memory = None - if config.memory_enabled: - from dashscope.acli.platforms import get_memory_provider - - memory = get_memory_provider(config) - if memory: - set_memory_client(memory) - - # user_name is display-only; no longer derived from memory_user_id. - user_name = config.user_name - session_manager = get_session_manager() - session_path = ( - session_manager.get_history_path() if config.session_persist else None - ) - # Closure captures `config` — when /capability toggles mutate - # enabled_capabilities, the next turn's system prompt sees the new state. - # Shared hook bus for agent lifecycle and skill packages. - from dashscope.acli.hooks import create_hook_bus - from dashscope.acli.tools.platform import disabled_capabilities_hint - - hook_bus = create_hook_bus() - - agent = Agent( - provider=provider, - executor=executor, - max_turns=config.max_turns, - memory=memory, - user_name=user_name, - provider_name=config.provider, - model_name=config.model, - session_path=session_path, - disabled_caps_provider=lambda: disabled_capabilities_hint(config), - directives_provider=lambda: config.user_directives, - system_prompt=_compose_system_prompt( - getattr(config, "_embedded_system_prompt", None) - or _load_system_prompt(), - ), - hook_bus=hook_bus, - ) - - # 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, - ) - - set_subagent_parent(agent) - set_subagent_config(config) - set_delegate_parent(agent) - set_delegate_config(config) - - # Register platform tools (memory, kb, data, prompt, search, context, - # mcp, subagent, delegate) - from dashscope.acli.tools.platform import register_platform_tools - - register_platform_tools(config, connect_mcp_fn=_connect_mcp) - - # Load skill packages (project .acli/skills// and global - # ~/.acli/skills//). - from dashscope.acli.skills import get_skill_manager - - skill_manager = get_skill_manager() - skill_manager._registry_url = getattr(config, "skill_registry", "") - skill_manager._global = False - skill_manager.load(hook_bus=hook_bus) - - # Register session tools (switch model/provider, capability, mcp - # management) - from dashscope.acli.tools.session import register_session_tools - - register_session_tools( - config, - get_agent=lambda: agent, - get_provider_fn=get_provider, - connect_mcp_fn=_connect_mcp, - disconnect_mcp_fn=_disconnect_mcp, - list_mcp_services_fn=list_known_services, - get_mcp_clients_fn=lambda: _mcp_clients, - ) - - from dashscope.acli.tools.vision import register_vision_tools - - register_vision_tools(config, get_provider_fn=get_provider) - - from dashscope.acli.tools.evolution import register_evolution_tools - - register_evolution_tools(get_agent=lambda: agent) - - from dashscope.acli.tools.skills import register_skill_tools - - register_skill_tools(get_agent=lambda: agent) - - WORKSPACE_DIR.mkdir(parents=True, exist_ok=True) - - _embedded_name = getattr(config, "_embedded_app_name", None) - if _embedded_name: - from dashscope.acli import __version__ - - console.print( - f"[bold green]{_embedded_name}[/bold green] " - f"[dim]v{__version__} ({config.provider}/{config.model})[/dim]", - ) - console.print(f"[dim]Workspace: {WORKSPACE_DIR}[/dim]") - # Show SDK index if available - sdk_index = getattr(config, "_embedded_sdk_index", None) - if sdk_index: - console.print(f"[dim]SDK Index: {', '.join(sdk_index)}[/dim]") - console.print("[dim]Type /help for commands, /exit to quit[/dim]\n") - else: - _print_banner(config) - - # Restore the prior conversation if any - try: - restored = agent.load_session() - if restored: - console.print( - f" [dim]Restored {restored} history messages " - f"({session_path})[/dim]\n", - ) - else: - console.print( - f" [dim]No history messages ({session_path})[/dim]\n", - ) - except Exception: - console.print( - f" [dim]History load failed ({session_path})[/dim]\n", - ) - - # First-run: auto-trigger /setup if workspace config doesn't exist - # Skip if essential config (provider/model/API key) is already set globally - if not WORKSPACE_CONFIG_FILE.exists(): - has_api_key = bool(config.api_key) - using_defaults = ( - config.provider == "tongyi" and config.model == "qwen3.8-max" - ) - if not has_api_key or using_defaults: - await _handle_setup(config, agent) - - # Check API key after banner is shown - ensure_provider_key(config, agent) - - history_file = session_manager.get_input_history_path() - # Context-aware: reads live config + extension registry on each keystroke - # so /model lists models for the current provider, /capability enable - # only shows currently-disabled caps, /dev provider rm only shows - # user-added providers, etc. - _completer = AcliCompleter(config) - - _kb = KeyBindings() - - def _selected_is_dir(buf) -> bool: - """True if the highlighted completion inserts a path ending in `/` - — used to decide whether Tab/Enter should drill in or submit.""" - state = buf.complete_state - return bool( - state - and state.current_completion - and state.current_completion.text.endswith("/"), - ) - - @_kb.add("enter", filter=has_completions) - def _accept_completion(event): - buf = event.app.current_buffer - if buf.complete_state and buf.complete_state.current_completion: - is_dir = _selected_is_dir(buf) - buf.apply_completion(buf.complete_state.current_completion) - if is_dir: - buf.start_completion(select_first=False) - return - buf.validate_and_handle() - - # Tab with menu open: accept + drill into next level. - @_kb.add("tab", filter=has_completions) - def _tab_completion(event): - buf = event.app.current_buffer - if buf.complete_state and buf.complete_state.current_completion: - is_dir = _selected_is_dir(buf) - buf.apply_completion(buf.complete_state.current_completion) - if not is_dir: - buf.insert_text(" ") - buf.start_completion(select_first=False) - - # Tab without menu: open completion. If only one match, apply + drill in. - @_kb.add("tab", filter=~has_completions) - def _tab_start_completion(event): - buf = event.app.current_buffer - buf.start_completion(select_first=False) - if buf.complete_state: - if len(buf.complete_state.completions) == 1: - buf.go_to_completion(0) - buf.apply_completion(buf.complete_state.current_completion) - buf.insert_text(" ") - buf.start_completion(select_first=False) - - # Right arrow: apply selected completion and drill into next-level menu. - @_kb.add("right", filter=has_completions) - def _right_drill_in(event): - buf = event.app.current_buffer - if buf.complete_state and buf.complete_state.current_completion: - is_dir = _selected_is_dir(buf) - buf.apply_completion(buf.complete_state.current_completion) - if not is_dir: - buf.insert_text(" ") - buf.start_completion(select_first=False) - - # Left arrow: go back one level (remove last token) and re-show menu. - @_kb.add("left", filter=has_completions) - def _left_go_back(event): - buf = event.app.current_buffer - buf.cancel_completion() - text = buf.text - # Remove trailing space + last token to go back one level - stripped = text.rstrip() - if " " in stripped: - prev = stripped[: stripped.rfind(" ") + 1] - buf.text = prev - buf.cursor_position = len(prev) - buf.start_completion(select_first=False) - else: - buf.text = "" - buf.cursor_position = 0 - - # Plain Enter (no active completion) = submit. With multiline=True this - # overrides prompt_toolkit's default of inserting a newline. - @_kb.add("enter", filter=~has_completions) - def _submit(event): - event.current_buffer.validate_and_handle() - - # Multi-line input: Esc+Enter / Ctrl+J / Alt+Enter all insert newline. - # Useful for typing multi-line prompts by hand. - @_kb.add("escape", "enter") - def _esc_enter(event): - event.current_buffer.insert_text("\n") - - @_kb.add("c-j") - def _ctrl_j(event): - event.current_buffer.insert_text("\n") - - @_kb.add("c-t") - def _voice_input_key(event): - event.app.exit(result="__VOICE_INPUT__") - - # Bracketed paste: terminals signal start/end of paste so prompt_toolkit - # treats the whole block as data — newlines inside the paste become - # literal newlines in the buffer instead of submitting. Default is on; - # we just need multiline=True for newlines to survive. - from prompt_toolkit.styles import Style as PTStyle - - session: PromptSession = PromptSession( - history=SafeFileHistory(str(history_file)), - completer=_completer, - complete_while_typing=True, - key_bindings=_kb, - multiline=True, - enable_open_in_editor=True, - input_processors=[_HintProcessor()], - style=PTStyle.from_dict({"hint": "#888888 italic"}), - ) - - # Connect MCP servers from config - if config.mcp_servers: - await _init_mcp_servers(config) - - # Initialize cron scheduler - import dashscope.acli.cli as _pkg - from dashscope.acli.scheduler import Scheduler - - _pkg._scheduler = Scheduler(config, agent) - await _pkg._scheduler.load_and_start() - - # --- CLI confirm callback: use prompt_toolkit to avoid double-Enter --- - # Rich's Prompt.ask() uses blocking input(), which conflicts with - # prompt_toolkit's terminal raw mode — the first Enter gets swallowed. - # Using the same PromptSession for confirmations fixes this. - async def _cli_confirm_callback( - tool_def, - arguments: dict, - is_dangerous: bool, - ) -> str: - from dashscope.acli.utils.text import truncate_value - - title = "⚠️ Dangerous operation" if is_dangerous else "Confirm" - border_style = "red bold" if is_dangerous else "yellow" - args_display = "\n".join( - f" {k}: {truncate_value(v)}" for k, v in arguments.items() - ) - content = f"Tool: {tool_def.name}\nArgs:\n{args_display}" - console.print(Panel(content, title=title, border_style=border_style)) - - if is_dangerous: - prompt_sym = "Execute? [y]es / [n]o [y]: " - else: - prompt_sym = ( - "Execute? [y]es / [n]o / [u]pdate (add info, re-plan) " - "/ [a]lways / [s]top [y]: " - ) - - try: - with patch_stdout(): - raw = await session.prompt_async(prompt_sym) - except (KeyboardInterrupt, EOFError): - return "n" - - choice = raw.strip().lower() or "y" - if is_dangerous: - # Dangerous ops accept only y/n, matching the sync path - # (no always-trust grant). - if choice not in ("y", "n"): - choice = "n" - elif choice not in ("y", "n", "u", "a", "s"): - choice = "y" - if choice == "u": - try: - with patch_stdout(): - supplement = await session.prompt_async( - "[yellow]Enter supplementary info:[/yellow] ", - ) - except (KeyboardInterrupt, EOFError): - supplement = "" - if not supplement.strip(): - raise UserAbortedTurn("No supplementary info provided") - raise UserSupplement(supplement.strip()) - return choice - - agent.executor._confirm_callback = _cli_confirm_callback - - import time as _time - - _last_ctrl_c = 0.0 - - while True: - try: - with patch_stdout(): - _prompt_sym = ( - getattr(config, "_embedded_prompt_symbol", None) - or "acli> " - ) - user_input = await session.prompt_async(_prompt_sym) - _last_ctrl_c = 0.0 - except KeyboardInterrupt: - now = _time.time() - if now - _last_ctrl_c < 2.0: - console.print("\n[dim]Bye![/dim]") - break - _last_ctrl_c = now - console.print("[dim]Press Ctrl+C again to exit[/dim]") - continue - except EOFError: - console.print("\n[dim]Bye![/dim]") - break - user_input = user_input.strip() - - # Process pending cron prompts (non-subagent mode) - if _pkg._scheduler: - pending = _pkg._scheduler.get_pending_prompts() - if pending: - for prompt in pending: - console.print("\n[cyan][CRON → main chat][/cyan]") - await _stream_response(agent, config, prompt) - console.print() - continue - - # Voice input via Option+V keybinding - if user_input == "__VOICE_INPUT__": - from dashscope.acli.ui.voice import voice_input as _voice_input - - text = await _voice_input( - config.tongyi_api_key, - model=config.asr_model, - silence_threshold=config.voice_silence_threshold, - silence_duration=config.voice_silence_duration, - max_recording_seconds=config.voice_max_seconds, - ) - if text: - user_input = text - else: - continue - - if not user_input: - if ( - agent.messages - and agent.messages[-1].get("role") == "assistant" - ): - user_input = "1" - else: - continue - - if user_input.startswith("!"): - shell_cmd = user_input[1:].strip() - if shell_cmd: - import subprocess as _sp - - env = os.environ.copy() - env["ACLI_CLI"] = "1" - proc = _sp.run(shell_cmd, shell=True, env=env, check=False) - if proc.returncode != 0: - print(f"(exit code {proc.returncode})") - continue - - if user_input.startswith("/"): - # Do NOT add patch_stdout here: with no prompt app running it - # replaces every ESC in the output with '?', garbling slash - # commands' Rich output. - try: - result = _handle_slash_command(user_input, agent, config) - except Exception as e: - # A buggy handler must not kill the session loop. - console.print(f"[red]Command error: {e}[/red]") - continue - if result == "voice": - from dashscope.acli.ui.voice import voice_input as _voice_input - - text = await _voice_input( - config.tongyi_api_key, - model=config.asr_model, - silence_threshold=config.voice_silence_threshold, - silence_duration=config.voice_silence_duration, - max_recording_seconds=config.voice_max_seconds, - ) - if text: - user_input = text - else: - continue - elif result == "async": - await dispatch_async_command(user_input, config, agent) - continue - elif result == "compress": - await _do_compress(agent) - continue - elif result == "summarize": - await _do_summarize(agent) - continue - elif result == "skill": - skill_result = await _handle_skill_continue( - user_input, - config, - agent, - ) - if skill_result: - user_input = skill_result - else: - continue - elif result: - continue - else: - # Not a registered command: the text goes to the model (an - # agent can legitimately answer slash-command questions), - # but say so — a typo'd command must not fail silently. - console.print( - f"[dim]Unknown command " - f"{user_input.split()[0]}; sending it to the model " - f"instead (/help lists commands).[/dim]", - ) - - # Expand @path tokens to file contents before sending to the agent. - # Done here (post slash-command handling) so slash commands themselves - # aren't affected — /key foo@bar still sees the literal "foo@bar". - expanded_text, images, audio_clips = _expand_at_references(user_input) - if images and not is_vision_model(config.model): - console.print( - f"[yellow]Model {config.model} does not support images; " - f"{len(images)} image(s) ignored. Switch to a vision model " - f"(e.g. qwen-vl-max) and retry.[/yellow]", - ) - images = [] - if audio_clips and not is_audio_model(config.model): - console.print( - f"[yellow]Model {config.model} does not support audio; " - f"{len(audio_clips)} audio clip(s) ignored. Switch to an " - f"audio model (e.g. qwen-omni-turbo) and retry.[/yellow]", - ) - audio_clips = [] - user_input = _to_multimodal_content(expanded_text, images, audio_clips) - - messages_before = len(agent.messages) - try: - console.print() - await _stream_response(agent, config, user_input) - console.print() - except KeyboardInterrupt: - console.print("\n[dim]Interrupted[/dim]") - except Exception as e: - console.print(f"\n[red]Error: {e}[/red]\n") - - # Auto-summarize long tasks (>= 6 new messages = user + assistant - # + tool calls) - messages_added = len(agent.messages) - messages_before - if messages_added >= 6: - console.print( - f"[dim]Long task detected ({messages_added} messages), " - f"auto-summarizing...[/dim]", - ) - await _do_summarize(agent, silent=False) - - # Shutdown scheduler on exit - if _pkg._scheduler: - await _pkg._scheduler.shutdown() diff --git a/dashscope/acli/cli/runners.py b/dashscope/acli/cli/runners.py deleted file mode 100644 index 23eac005..00000000 --- a/dashscope/acli/cli/runners.py +++ /dev/null @@ -1,567 +0,0 @@ -# -*- coding: utf-8 -*- -"""Runner functions for different execution modes.""" -# pylint: disable=protected-access,too-many-branches,too-many-statements - -from __future__ import annotations - -import json -import os -import sys -import threading -from collections.abc import Callable -from pathlib import Path - -from rich.console import Console - -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.config import ( - PROVIDER_MODELS, - Config, - is_audio_model, - is_vision_model, -) -from dashscope.acli.dev import _apply_custom_models -from dashscope.acli.executor import Executor -from dashscope.acli.providers import get_provider, get_provider_chain -from dashscope.acli.session import get_session_manager -from dashscope.acli.skills import list_known_services - -console = Console() - -# How often a harness-observed run re-persists its usage totals. -_USAGE_FLUSH_SEC = 15.0 - - -def _write_usage_file(path: str, executor: Executor) -> None: - """Persist this run's token totals to ``path`` for an external harness. - - A file rather than stdout because the harness that sets - ``ACLI_USAGE_FILE`` (Terminal-Bench) drives acli through tmux, and a - 30-minute run scrolls its final lines out of the capturable pane. - - Flushed periodically rather than once at the end for the same audience: - the runs that most need measuring are the ones killed by an in-container - ``timeout``, which never reach a final write. - """ - stats = executor.get_stats() - usage = stats["token_usage"] - payload = { - "input_tokens": usage["input_tokens"], - "output_tokens": usage["output_tokens"], - "total_tokens": usage["total_tokens"], - "cached_tokens": usage["cached_tokens"], - "api_calls": stats["api_calls"], - "tool_calls": stats["total_tool_calls"], - "duration_sec": round(stats["session_duration"], 3), - } - target = Path(path) - try: - # Replaced rather than written in place: the harness reads this from - # the host while acli is still running, and keeps reading it after the - # trial has been abandoned, so a torn write costs the whole trial's - # numbers rather than one tick's. - tmp = target.with_name(target.name + ".tmp") - tmp.write_text(json.dumps(payload), encoding="utf-8") - os.replace(tmp, target) - except OSError: - # Observability must never be the reason a run fails. - pass - - -def _start_usage_flusher(path: str, executor: Executor) -> Callable[[], None]: - """Re-persist usage from a daemon thread; return its stop callable. - - A thread, not a write keyed off the output stream or an asyncio timer: - the runs worth measuring are wedged inside ``run_stream``, where no chunk - arrives to trigger a write and a blocked event loop starves a timer just - as thoroughly. The last flush before the wedge is what survives a later - SIGKILL, so ``duration_sec`` also records how long the process stayed - alive after it went quiet. - """ - stop = threading.Event() - - def _flush_loop() -> None: - while not stop.wait(_USAGE_FLUSH_SEC): - try: - _write_usage_file(path, executor) - except Exception: # pylint: disable=broad-except - # get_stats() copies dicts the main thread is mutating, so a - # tick can race. Losing it is fine; killing the run is not. - pass - - thread = threading.Thread(target=_flush_loop, daemon=True) - thread.start() - - def _stop() -> None: - stop.set() - # Joined, not just signalled: the caller writes once more afterwards, - # and two writers sharing one temp path would clobber each other. - thread.join(timeout=5.0) - - return _stop - - -async def _run_oneshot(config: Config, prompt: str): - """Non-interactive single-shot mode: run one prompt and exit.""" - _apply_custom_models(config) - # Re-arm audit redaction from persisted config (restart must not drop it). - from dashscope.acli.audit import configure_audit_logger - - configure_audit_logger(config) - from dashscope.acli.debuglog import configure_debug_log - - configure_debug_log(config) - from dashscope.acli.permission import configure_permission_policy - - configure_permission_policy() - from dashscope.acli.extensions import apply_extensions - - apply_extensions(PROVIDER_MODELS) - - if not config.api_key: - from dashscope.acli.cli.handlers_key import all_key_targets - - targets = all_key_targets(config) - key_info = targets.get(config.provider) - env_hint = ( - key_info.get("env") - if key_info - else f"{config.provider.upper()}_API_KEY" - ) - print(f"Error: no API Key found for {config.provider}") - print(f" 1) Set env var: export {env_hint}=sk-xxx") - print(" 2) Set after launch: /provider") - print(" 3) Interactive setup: /setup") - sys.exit(1) - - provider = get_provider_chain(config) - executor = Executor( - auto_approve=config.auto_approve, - confirm_mode=config.confirm_mode, - ) - - # Memory and the shared hook bus must exist before the Agent does (same - # wiring as cli/repl.py): without them oneshot had no memory provider at - # all, so every memory- or skill-shaped capability scored a constant zero - # in non-interactive runs. - memory = None - if config.memory_enabled: - from dashscope.acli.cli.handlers_profile import set_memory_client - from dashscope.acli.platforms import get_memory_provider - - memory = get_memory_provider(config) - if memory: - set_memory_client(memory) - - from dashscope.acli.hooks import create_hook_bus - - hook_bus = create_hook_bus() - - agent = Agent( - provider=provider, - executor=executor, - max_turns=config.max_turns, - memory=memory, - provider_name=config.provider, - model_name=config.model, - hook_bus=hook_bus, - oneshot=True, - ) - - # 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, - ) - - set_subagent_parent(agent) - set_subagent_config(config) - set_delegate_parent(agent) - set_delegate_config(config) - - from dashscope.acli.cli.mcp import ( - _connect_mcp, - _disconnect_mcp, - _mcp_clients, - ) - from dashscope.acli.tools.platform import register_platform_tools - - register_platform_tools(config, connect_mcp_fn=_connect_mcp) - - # The four registrations below mirror the interactive/TUI paths - # (cli/repl.py, _run_tui_mode). Oneshot used to skip them, which left - # skill packages unloaded and the session/skill/evolution tools - # unregistered in exactly the mode benchmarks run in. - from dashscope.acli.skills import get_skill_manager - - skill_manager = get_skill_manager() - skill_manager._registry_url = getattr(config, "skill_registry", "") - skill_manager._global = False - skill_manager.load(hook_bus=hook_bus) - - from dashscope.acli.tools.session import register_session_tools - - register_session_tools( - config, - get_agent=lambda: agent, - get_provider_fn=get_provider, - connect_mcp_fn=_connect_mcp, - disconnect_mcp_fn=_disconnect_mcp, - list_mcp_services_fn=list_known_services, - get_mcp_clients_fn=lambda: _mcp_clients, - ) - - # Vision too: without it, oneshot (every bench run 071c–071k) offered - # no view_image at all — the prompt named a tool the model could not - # see — and read_file/run_command auto-perceive had no context. - from dashscope.acli.tools.vision import register_vision_tools - - register_vision_tools(config, get_provider_fn=get_provider) - - from dashscope.acli.tools.evolution import register_evolution_tools - - register_evolution_tools(get_agent=lambda: agent) - - from dashscope.acli.tools.skills import register_skill_tools - - register_skill_tools(get_agent=lambda: agent) - - expanded, images, audio_clips = _expand_at_references(prompt) - if images and not is_vision_model(config.model): - console.print( - f"[yellow]Model {config.model} does not support images; " - f"{len(images)} image(s) ignored.[/yellow]", - ) - images = [] - if audio_clips and not is_audio_model(config.model): - console.print( - f"[yellow]Model {config.model} does not support audio; " - f"{len(audio_clips)} audio clip(s) ignored.[/yellow]", - ) - audio_clips = [] - agent_input = _to_multimodal_content(expanded, images, audio_clips) - - usage_path = os.environ.get("ACLI_USAGE_FILE", "") - stop_flusher = None - if usage_path: - # Written before the first chunk as well: a run that wedges immediately - # still has to leave proof that ACLI_USAGE_FILE reached it. - _write_usage_file(usage_path, executor) - stop_flusher = _start_usage_flusher(usage_path, executor) - failure: Exception | None = None - try: - async for chunk in agent.run_stream(agent_input): - sys.stdout.write(chunk) - sys.stdout.flush() - except Exception as e: # pylint: disable=broad-except - # Anything the retry layers did not absorb. Reported and signalled - # through the exit code rather than left to escape asyncio.run as a - # traceback: the caller cannot tell a stack dump from output, and a - # harness scoring this run needs the two separated. KeyboardInterrupt - # and SystemExit stay BaseException, so neither is caught here. - failure = e - finally: - # Stopped before the trailing write so the two cannot interleave. - if stop_flusher is not None: - stop_flusher() - sys.stdout.write("\n") - if usage_path: - _write_usage_file(usage_path, executor) - if failure is not None: - from dashscope.acli.providers.hardening import account_error_hint - - print(f"Error: {failure}", file=sys.stderr) - hint = account_error_hint(failure) - if hint: - print(hint, file=sys.stderr) - sys.exit(1) - - -def _run_dry_run(config: Config): - """Preview configuration without starting the agent. - - Shows what would be loaded: provider, model, skills, MCP services, - tools, capabilities, and overall readiness status. - """ - from rich.table import Table - - console.print("\n[bold cyan]acli --dry-run[/bold cyan] Config preview\n") - - # 1. Provider & Model - provider_table = Table( - title="Provider config", - show_header=False, - box=None, - ) - provider_table.add_column("Key", style="cyan") - provider_table.add_column("Value", style="green") - provider_table.add_row("Provider", config.provider) - provider_table.add_row("Model", config.model) - provider_table.add_row("Protocol", config.protocol or "openai") - provider_table.add_row( - "API Key", - "✓ configured" if config.api_key else "✗ not configured", - ) - if config.base_url: - provider_table.add_row("Base URL", config.base_url) - console.print(provider_table) - console.print() - - # 2. Skills - from dashscope.acli.skills.base import BUILTIN_SKILLS, load_skill_files - - load_skill_files() - skills = list(BUILTIN_SKILLS.values()) - if skills: - skill_table = Table( - title=f"Loaded Skills ({len(skills)})", - show_header=True, - ) - skill_table.add_column("Name", style="cyan") - skill_table.add_column("Description", style="dim") - for skill in skills: - skill_table.add_row(skill.name, skill.description or "") - console.print(skill_table) - else: - console.print("[yellow]Skills:[/yellow] none\n") - console.print() - - # 3. MCP Services - mcp_servers = getattr(config, "mcp_servers", None) or [] - if mcp_servers: - mcp_table = Table( - title=f"MCP Services ({len(mcp_servers)})", - show_header=True, - ) - mcp_table.add_column("Name", style="cyan") - mcp_table.add_column("URL", style="dim") - mcp_table.add_column("Status", style="green") - for svc in mcp_servers: - if isinstance(svc, dict): - name, url = svc.get("service", "?"), svc.get("url", "?") - else: - name, url = svc.service, svc.url - mcp_table.add_row(name or "?", url or "?", "pending") - console.print(mcp_table) - else: - console.print("[yellow]MCP Services:[/yellow] none\n") - console.print() - - # 4. Tools - from dashscope.acli.tools.registry import registry - - tools = registry.list_tools() - tool_table = Table( - title=f"Available Tools ({len(tools)})", - show_header=True, - ) - tool_table.add_column("Name", style="cyan") - tool_table.add_column("Description", style="dim") - for tool in tools[:20]: # Show first 20 tools - tool_table.add_row(tool.name, (tool.description or "")[:60]) - if len(tools) > 20: - tool_table.add_row("...", f"({len(tools) - 20} more tools)") - console.print(tool_table) - console.print() - - # 5. Capabilities - caps_table = Table(title="Capability status", show_header=False, box=None) - caps_table.add_column("Capability", style="cyan") - caps_table.add_column("Status", style="green") - caps_table.add_row( - "Memory", - ( - "✓ enabled" - if getattr(config, "memory_enabled", False) - else "✗ disabled" - ), - ) - caps_table.add_row( - "Session Persist", - "✓ enabled" if config.session_persist else "✗ disabled", - ) - caps_table.add_row( - "Auto Approve", - "✓ enabled" if config.auto_approve else "✗ disabled", - ) - console.print(caps_table) - console.print() - - # 6. Readiness assessment - issues = [] - if not config.api_key: - issues.append("API Key not configured") - if not tools: - issues.append("No tools available") - - if issues: - console.print("[bold red]⚠ Readiness: Warning[/bold red]") - for issue in issues: - console.print(f" • {issue}") - else: - console.print("[bold green]✓ Readiness: Ready[/bold green]") - - console.print( - "\n[dim]This is a config preview; the agent was not started. " - "Remove --dry-run to launch normally.[/dim]\n", - ) - - -def _run_tui_mode(config: Config): - """Run AgenticCLI with Textual TUI (fixed input at bottom).""" - _apply_custom_models(config) - # Re-arm audit redaction from persisted config (restart must not drop it). - from dashscope.acli.audit import configure_audit_logger - - configure_audit_logger(config) - from dashscope.acli.debuglog import configure_debug_log - - configure_debug_log(config) - from dashscope.acli.permission import configure_permission_policy - - configure_permission_policy() - from dashscope.acli.extensions import apply_extensions - - _ext = apply_extensions(PROVIDER_MODELS) - from dashscope.acli.cli.handlers_capability import ( - sync_extensions_into_catalog, - ) - - sync_extensions_into_catalog(_ext) - - # CLI mode uses the full provider chain so temporary failures can fall back - # to configured alternatives instead of dying immediately. - provider = get_provider_chain(config) - executor = Executor( - auto_approve=config.auto_approve, - confirm_mode=config.confirm_mode, - ) - - 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 - - # Initialize memory only when enabled (same as CLI mode) - memory = None - if config.memory_enabled: - memory = get_memory_provider(config) - hook_bus = create_hook_bus() - - session_manager = get_session_manager() - session_path = ( - session_manager.get_history_path() if config.session_persist else None - ) - - # user_name is display-only; no longer derived from memory_user_id. - user_name = config.user_name - - agent = Agent( - provider=provider, - executor=executor, - max_turns=config.max_turns, - memory=memory, - user_name=user_name, - provider_name=config.provider, - model_name=config.model, - session_path=session_path, - disabled_caps_provider=lambda: disabled_capabilities_hint(config), - directives_provider=lambda: config.user_directives, - system_prompt=_compose_system_prompt( - getattr(config, "_embedded_system_prompt", None) - or _load_system_prompt(), - ), - hook_bus=hook_bus, - ) - - set_subagent_parent(agent) - set_subagent_config(config) - set_delegate_parent(agent) - set_delegate_config(config) - - from dashscope.acli.cli.mcp import _connect_mcp - from dashscope.acli.tools.platform import register_platform_tools - - register_platform_tools(config, connect_mcp_fn=_connect_mcp) - - from dashscope.acli.skills import get_skill_manager - - skill_manager = get_skill_manager() - skill_manager._registry_url = getattr(config, "skill_registry", "") - skill_manager._global = False - skill_manager.load(hook_bus=hook_bus) - - from dashscope.acli.cli.mcp import _disconnect_mcp, _mcp_clients - from dashscope.acli.tools.session import register_session_tools - - register_session_tools( - config, - get_agent=lambda: agent, - get_provider_fn=get_provider, - connect_mcp_fn=_connect_mcp, - disconnect_mcp_fn=_disconnect_mcp, - list_mcp_services_fn=list_known_services, - get_mcp_clients_fn=lambda: _mcp_clients, - ) - - from dashscope.acli.tools.vision import register_vision_tools - - register_vision_tools(config, get_provider_fn=get_provider) - - from dashscope.acli.tools.evolution import register_evolution_tools - - register_evolution_tools(get_agent=lambda: agent) - - from dashscope.acli.tools.skills import register_skill_tools - - register_skill_tools(get_agent=lambda: agent) - - # Check API key before launching TUI - ensure_provider_key(config, agent) - - try: - from dashscope.acli.ui.tui import run_tui - except ImportError: - console.print( - "[yellow]TUI requires the textual dependency: " - "pip install textual (or use CLI mode: --cli)[/yellow]", - ) - return - - # Run Textual TUI - input_history_path = ( - session_manager.get_input_history_path() - if config.session_persist - else None - ) - run_tui(config, agent, input_history_path=input_history_path) diff --git a/dashscope/acli/cli/startup.py b/dashscope/acli/cli/startup.py deleted file mode 100644 index 6989517a..00000000 --- a/dashscope/acli/cli/startup.py +++ /dev/null @@ -1,197 +0,0 @@ -# -*- coding: utf-8 -*- -"""Startup functions: banner, system prompt loading, provider debug.""" - -from __future__ import annotations - -from rich.console import Console - -from dashscope.acli import __version__ -from dashscope.acli.cli.constants import ALL_CAPABILITY_KEYS -from dashscope.acli.config import Config -from dashscope.acli.tools.registry import registry -from dashscope.acli.utils import mask_secret - -console = Console() - - -def _load_system_prompt() -> str | None: - """Load system prompt from .acli/system-prompt.md (workspace then global). - - Project-specific instructions (.acli/rules.jsonl, .cursorrules, etc.) - are discovered separately by Agent.__init__ and passed to the prompt - pipeline for proper stable/ephemeral separation. - """ - # 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): - path = d / "system-prompt.md" - if path.is_file(): - try: - base = path.read_text(encoding="utf-8").strip() or None - except OSError: - pass - break - - return base - - -def _strip_frontmatter(text: str) -> str: - if text.startswith("---"): - end = text.find("---", 3) - if end > 0: - return text[end + 3 :].strip() - return text.strip() - - -def _load_references() -> str | None: - """Load .acli/references/*.md (global then workspace) as always-on context. - - 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, WORKSPACE_DIR - - parts: dict[str, str] = {} - for d in (CONFIG_DIR, WORKSPACE_DIR): - ref_dir = d / "references" - if not ref_dir.is_dir(): - continue - for path in sorted(ref_dir.glob("*.md")): - try: - body = _strip_frontmatter(path.read_text(encoding="utf-8")) - except OSError: - continue - if body: - parts[ - path.name - ] = body # workspace overrides global by file name - return "\n\n---\n\n".join(parts.values()) if parts else None - - -def _compose_system_prompt(base: str | None) -> str | None: - """Append .acli/references/ content to the resolved base prompt. - - Applied at every consumption site so embedded prompts (which bypass - _load_system_prompt) still get references. - """ - refs = _load_references() - if not refs: - return base - section = f"## References (auto-loaded)\n\n{refs}" - return f"{base}\n\n---\n\n{section}" if base else section - - -def _print_banner(config: Config | None = None) -> None: - from dashscope.acli.config import WORKSPACE_DIR - - logo = ( - " _ _ _ ____ _ ___\n" - " / \\ __ _ ___ _ __ | |_(_) ___ / ___| | |_ _|\n" - " / _ \\ / _` |/ _ \\ '_ \\| __| |/ __| | | | | |\n" - " / ___ \\ (_| | __/ | | | |_| | (__| |___| |___ | |\n" - " /_/ \\_\\__, |\\___|_| |_|\\__|_|\\___|\\____|_____|___|\n" - " |___/" - ) - console.print(f"[bold cyan]{logo}[/bold cyan]", highlight=False) - console.print( - f" [bold]AgenticCLI[/bold] [dim]v{__version__}[/dim] " - f"— Drive everything with natural language", - ) - console.print(f" [dim]Workspace: {WORKSPACE_DIR}[/dim]\n") - - # ── Startup info: provider / model / user / capabilities / tools ── - if config is not None: - user_display = config.user_name or "(not set)" - console.print( - f" [bold]Provider:[/bold] [cyan]{config.provider}[/cyan] " - f"[bold]Model:[/bold] [cyan]{config.model}[/cyan] " - f"[bold]User:[/bold] [cyan]{user_display}[/cyan]", - ) - - # API Key status (current provider) - from dashscope.acli.cli.handlers_key import all_key_targets - - targets = all_key_targets(config) - key_info = targets.get(config.provider) - if key_info: - key_val = getattr(config, key_info["field"], "") - if key_val: - console.print( - f" [bold]API Key:[/bold] " - f"[green]✓ {mask_secret(key_val)}[/green] " - f"[dim]({key_info['env']})[/dim]", - ) - else: - console.print( - f" [bold]API Key:[/bold] [red]✗ not set[/red] " - f"[dim]({key_info['env']}, set via /provider)[/dim]", - ) - - # Enabled capabilities - caps_display = ( - ALL_CAPABILITY_KEYS - if config.enabled_capabilities is None - else config.enabled_capabilities - ) - caps_str = ", ".join(caps_display) if caps_display else "none" - console.print(f" [bold]Caps:[/bold] [dim]{caps_str}[/dim]") - - # Registered tool count - tool_count = ( - len(registry.list_tools()) - if hasattr(registry, "list_tools") - else "?" - ) - console.print( - f" [bold]Tools:[/bold] [dim]{tool_count} registered[/dim]", - ) - - # SDK knowledge base (embedded mode only) - sdk_index = getattr(config, "_embedded_sdk_index", None) - if sdk_index: - console.print( - f" [bold]SDK:[/bold] [green]✓[/green] " - f"[dim]{', '.join(sdk_index)}[/dim]", - ) - - # Scenario doc link (embedded mode only) - guide_url = getattr(config, "_embedded_guide_url", "") - if guide_url: - console.print(f" [bold]Guide:[/bold] [dim]{guide_url}[/dim]") - - console.print() - - console.print(" [dim]Session: /help /clear /exit[/dim]") - console.print( - " [dim]Config: /setup /capability /subagents /provider /trust " - "/rule[/dim]", - ) - console.print(" [dim]Capabilities: /profile /mcp /skill[/dim]") - console.print( - " [dim]Dev: /dev model|provider|capability (runtime) " - "/dev platform|tool|skill (extension guides)[/dim]", - ) - console.print( - " [dim]Input: Enter to submit / Ctrl+J for newline / " - "multi-line paste preserved / @file expands content / " - "/v voice input[/dim]", - ) - console.print() - - -def _print_provider_debug(provider): - """Print provider endpoint and key info for debugging.""" - if hasattr(provider, "client") and hasattr(provider.client, "base_url"): - # OpenAIProvider - base_url = str(provider.client.base_url) - key = provider.client.api_key or "" - console.print(f"[dim] endpoint: {base_url}[/dim]") - console.print(f"[dim] api_key: {mask_secret(key)}[/dim]") - elif hasattr(provider, "api_key"): - # TongyiProvider - key = provider.api_key or "" - endpoint = getattr(provider, "base_url", "dashscope SDK") - console.print(f"[dim] endpoint: {endpoint}[/dim]") - console.print(f"[dim] api_key: {mask_secret(key)}[/dim]") diff --git a/dashscope/acli/cli/streaming.py b/dashscope/acli/cli/streaming.py deleted file mode 100644 index 28f6a5a6..00000000 --- a/dashscope/acli/cli/streaming.py +++ /dev/null @@ -1,360 +0,0 @@ -# -*- coding: utf-8 -*- -"""Streaming output and conversation management functions.""" -# pylint: disable=too-many-branches,too-many-statements - -from __future__ import annotations - -import asyncio -import sys - -from rich.console import Console -from rich.syntax import Syntax -from rich.text import Text - -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, - truncate, -) - -console = Console() - - -def _render_tool_trail(line: str) -> None: - """Render a `[tool_name] → result` trail line. For write_file (and any - other diff-bearing tools), split out the unified diff section and - render it with Syntax("diff") so additions/removals are color-coded. - - Trail format: `[write_file] → Wrote file: path (N chars)` then - `\\n--- diff ---\\n`. - """ - if "[write_file]" in line and "--- diff ---" in line: - head, _, diff_body = line.partition("--- diff ---") - console.print(Text(head.rstrip(), style="dim cyan")) - diff_body = diff_body.strip("\n") - if diff_body: - console.print( - Syntax( - diff_body, - "diff", - theme="ansi_dark", - background_color="default", - ), - ) - return - console.print(Text(line, style="dim cyan")) - - -async def _do_compress(agent: Agent) -> None: - """Compress the current conversation into a short summary.""" - old_count = len(agent.messages) - old_chars = sum(len(str(m.get("content", ""))) for m in agent.messages) - console.print("[dim]Compressing conversation context...[/dim]") - conversation_dump = "" - for m in agent.messages: - conversation_dump += ( - f"[{m.get('role', '?')}]: {message_text_for_compress(m)}\n" - ) - compress_messages = [ - { - "role": "system", - "content": ( - "Compress the following conversation into a brief " - "summary, keeping key decisions, code changes, file " - "paths, and user preferences. Output the summary " - "directly, without explanation." - ), - }, - {"role": "user", "content": conversation_dump}, - ] - try: - resp = await agent.provider.chat(compress_messages, tools=[]) - summary = resp.content if hasattr(resp, "content") else str(resp) - agent.messages = [ - { - "role": "user", - "content": ( - "Below is a summary of the earlier conversation " - "(generated by compression, for background only; " - "model names, providers, and other config it " - "mentions may be outdated — the current config " - "is governed by the system prompt):\n" - f"{summary}" - ), - }, - { - "role": "assistant", - "content": ( - "Got it — I have the earlier conversation " - "context. Please continue." - ), - }, - ] - agent.save_session() - new_chars = sum(len(str(m.get("content", ""))) for m in agent.messages) - console.print( - f"[green]Compressed: {old_count} messages → 2, " - f"{old_chars} chars → {new_chars} chars[/green]", - ) - except Exception as e: - console.print(f"[red]Compression failed: {e}[/red]") - - -async def _do_summarize( - agent: Agent, - messages_to_summarize: list | None = None, - silent: bool = False, -) -> dict | None: - """Summarize a task and record to experience tracker. - - Args: - agent: The agent instance. - messages_to_summarize: Messages to summarize. If None, use - agent.messages. - silent: If True, suppress console output (for auto-trigger). - - Returns: - Parsed summary dict or None on failure. - """ - import json as _json - - tracker = getattr(agent, "experience_tracker", None) - if not tracker: - if not silent: - console.print( - "[yellow]Experience tracker not initialized[/yellow]", - ) - return None - - msgs = ( - messages_to_summarize - if messages_to_summarize is not None - else agent.messages - ) - if len(msgs) < 2: - if not silent: - console.print( - "[yellow]Not enough conversation to summarize[/yellow]", - ) - return None - - if not silent: - console.print("[dim]Summarizing current task...[/dim]") - - conversation_dump = "" - tools_used: list[str] = [] - for m in msgs: - role = m.get("role", "?") - content = m.get("content", "") - if isinstance(content, list): - content = " ".join( - c.get("text", "") for c in content if isinstance(c, dict) - ) - conversation_dump += f"[{role}]: {content}\n" - if role == "assistant" and "tool_calls" in m: - for tc in m["tool_calls"]: - if "function" in tc: - name = tc["function"]["name"] - if name not in tools_used: - tools_used.append(name) - - summarize_messages = [ - { - "role": "system", - "content": ( - "You are a task summarizer. Based on the conversation, " - "output a summary in this JSON format (output JSON " - "only, no explanation):\n" - '{"task": "one-line description of the completed task", ' - '"steps": ["key step 1", "key step 2"], ' - '"lesson": "lessons learned (if any)", ' - '"outcome": "success or partial"}' - ), - }, - {"role": "user", "content": conversation_dump}, - ] - - try: - resp = await agent.provider.chat(summarize_messages, tools=[]) - raw = resp.content if hasattr(resp, "content") else str(resp) - - parsed = None - try: - start = raw.find("{") - end = raw.rfind("}") + 1 - if 0 <= start < end: - parsed = _json.loads(raw[start:end]) - except Exception: - parsed = None - - task = (parsed or {}).get("task", "") or raw.split("\n", 1)[0][:100] - lesson = (parsed or {}).get("lesson", "") - steps = (parsed or {}).get("steps", []) - outcome = (parsed or {}).get("outcome", "success") - if outcome not in ("success", "partial", "failure"): - outcome = "success" - - if lesson and steps: - lesson = f"Steps: {'; '.join(steps)}\nLesson: {lesson}" - elif steps: - lesson = f"Steps: {'; '.join(steps)}" - - tracker.record_experience( - # Flatten newlines and mark truncation: the task is usually - # the raw prompt, and a mid-word cut with embedded newlines - # breaks the numbered "Past Lessons" list in the system prompt. - task_summary=truncate(" ".join(task.split()), 200), - tools_used=tools_used, - outcome=outcome, - lesson=lesson, - ) - - if not silent: - console.print( - "\n[bold green]✓ Task summary recorded[/bold green]", - ) - console.print(f" [bold]Task:[/bold] {task}") - if steps: - console.print(" [bold]Steps:[/bold]") - for s in steps: - console.print(f" - {s}") - if lesson and not steps: - console.print(f" [bold]Lesson:[/bold] {lesson}") - console.print() - - return parsed or { - "task": task, - "steps": steps, - "lesson": lesson, - "outcome": outcome, - } - except Exception as e: - if not silent: - console.print(f"[red]Summarization failed: {e}[/red]") - return None - - -async def _stream_response(agent: Agent, config: Config, user_input): - """Stream agent response with real-time output and tool call indicators.""" - buffer = "" - first_chunk = True - spinner_active = True - spinner = AsyncSpinner("Thinking...") - await spinner.__aenter__() - - # Install SIGINT handler so Ctrl+C aborts the turn instead of - # killing the process. - import signal - - loop = asyncio.get_running_loop() - aborted = False - - def _sigint_handler(): - nonlocal aborted - aborted = True - - try: - loop.add_signal_handler(signal.SIGINT, _sigint_handler) - except NotImplementedError: - pass - - # Initialize streaming TTS feeder if enabled - tts_feeder = None - if config.tts_enabled: - try: - from dashscope.acli.ui.tts import StreamingTTSFeeder, is_available - - ok, _err = is_available() - if ok: - tts_feeder = StreamingTTSFeeder( - api_key=config.tongyi_api_key, - model=config.tts_model, - voice=config.tts_voice, - speech_rate=config.tts_speed, - ) - tts_feeder.start() - except Exception: - tts_feeder = None - - try: - async for chunk in agent.run_stream(user_input): - if aborted: - console.print("\n[yellow]Interrupted[/yellow]") - break - - # Stop spinner on first chunk OR empty chunk (tool execution - # signal) - if chunk == "" and spinner_active: - await spinner.__aexit__(None, None, None) - spinner_active = False - if first_chunk: - if spinner_active: - await spinner.__aexit__(None, None, None) - spinner_active = False - first_chunk = False - - if chunk.startswith("\n[") and "] →" in chunk: - if buffer: - sys.stdout.write("\n") - sys.stdout.flush() - buffer = "" - _render_tool_trail(chunk.strip()) - else: - buffer += chunk - sys.stdout.write(chunk) - sys.stdout.flush() - # Feed chunk to streaming TTS - if tts_feeder: - tts_feeder.feed(chunk) - except UserAbortedTurn: - console.print("\n[dim]Turn aborted[/dim]") - except Exception as e: - console.print(f"\n[red]Error: {e}[/red]") - from dashscope.acli.providers.hardening import account_error_hint - - hint = account_error_hint(e) - if hint: - console.print(f"[yellow]{hint}[/yellow]") - finally: - # Restore default SIGINT handler - try: - loop.remove_signal_handler(signal.SIGINT) - except (NotImplementedError, RuntimeError): - pass - if spinner_active: - await spinner.__aexit__(None, None, None) - if buffer: - sys.stdout.write("\n") - sys.stdout.flush() - agent.last_output = buffer - # Stop the TTS feeder on ALL exit paths, otherwise its worker - # thread leaks one per turn (empty-buffer turns never reached - # finish()/cancel()). - if tts_feeder: - try: - if buffer.strip(): - # Flush remaining text to streaming TTS and wait - err = tts_feeder.finish() - if err: - console.print(f"[yellow]TTS: {err}[/yellow]") - else: - tts_feeder.cancel() - except Exception as e: - console.print(f"[yellow]TTS error: {e}[/yellow]") - finally: - thread = getattr(tts_feeder, "_worker_thread", None) - if thread is not None: - thread.join(timeout=5) - - # Surface any files produced by tool calls this turn. - try: - surface_deliverables(collect_deliverables(agent.messages)) - except Exception: - pass diff --git a/dashscope/acli/commands.py b/dashscope/acli/commands.py deleted file mode 100644 index cbacd2c1..00000000 --- a/dashscope/acli/commands.py +++ /dev/null @@ -1,237 +0,0 @@ -# -*- coding: utf-8 -*- -"""Shared command definitions and utilities used by both CLI and TUI. - -This module contains UI-agnostic pieces of slash-command handling so that -command behavior only needs to be updated in one place. -""" - -from __future__ import annotations - -import os -import subprocess as _sp - -# Single source of truth for the /help menu. CLI and TUI both render from -# this structure so that adding or renaming a command only requires one edit. -HELP_SECTIONS: list[tuple[str, list[tuple[str, str]]]] = [ - ( - "Session", - [ - ("/help", "Show help"), - ("/clear", "Clear conversation history"), - ("/info", "Show runtime info (provider, model, config)"), - ("/stats", "Show session stats (tool calls, model info)"), - ("/voice", "Voice input (on/off/model/silence/max/threshold)"), - ( - "/tts", - "Voice output (on/off/status/model/voice/speed/say/last)", - ), - ("/camera capture [file]", "Take a photo with the camera"), - ("/camera record [duration] [file]", "Record video (default 5s)"), - ("/copy", "Copy the last reply to the clipboard"), - ( - "/save [path]", - "Save the last reply to a file " - "(default acli_output_