From f69374d711ac91342f22f122e6e02bf5f59d1817 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Wed, 11 Nov 2026 19:13:24 +0800 Subject: [PATCH] feat(expert): unify public workflows and request attribution Standardize Expert commands and setup documentation, add DashScope request-source attribution across providers and voice/MCP paths, and prepare the 1.0.1 release metadata. --- .gitignore | 2 - CHANGELOG.md | 30 +- README.md | 29 +- README_zh.md | 27 +- RELEASE.md | 4 +- SECURITY.md | 2 +- migration-to-1.0.md | 8 +- scripts/release_check.py | 2 +- src/dashscope_agenticx/_version.py | 2 +- src/dashscope_agenticx/cli/__init__.py | 8 +- src/dashscope_agenticx/cli/completer.py | 8 +- src/dashscope_agenticx/cli/constants.py | 5 +- src/dashscope_agenticx/cli/dispatch.py | 8 +- .../cli/{examples.py => experts.py} | 120 +++--- src/dashscope_agenticx/cli/handlers_setup.py | 39 +- src/dashscope_agenticx/cli/startup.py | 2 +- src/dashscope_agenticx/config.py | 28 +- src/dashscope_agenticx/dashscope_headers.py | 44 ++ .../platforms/bailian/mcp.py | 3 + src/dashscope_agenticx/providers/__init__.py | 10 +- src/dashscope_agenticx/providers/anthropic.py | 6 + src/dashscope_agenticx/providers/openai.py | 11 +- src/dashscope_agenticx/providers/tongyi.py | 11 +- src/dashscope_agenticx/ui/tts.py | 2 + src/dashscope_agenticx/ui/tui.py | 12 +- src/dashscope_agenticx/ui/voice.py | 3 + tests/e2e/test_smoke_repl.py | 6 +- tests/ut/test_cli_edge.py | 48 ++- tests/ut/test_config.py | 12 +- tests/ut/test_dashscope_headers.py | 162 ++++++++ tests/ut/{test_examples.py => test_expert.py} | 392 +++++++++--------- tests/ut/test_packaging_contract.py | 4 +- tests/ut/test_setup_finalize.py | 49 +-- tests/ut/test_tts.py | 46 ++ tests/ut/test_tui_scroll.py | 6 +- tests/ut/test_ui_edge.py | 33 ++ user-guide.md | 68 ++- user-guide_zh.md | 64 ++- 38 files changed, 881 insertions(+), 435 deletions(-) rename src/dashscope_agenticx/cli/{examples.py => experts.py} (89%) create mode 100644 src/dashscope_agenticx/dashscope_headers.py create mode 100644 tests/ut/test_dashscope_headers.py rename tests/ut/{test_examples.py => test_expert.py} (80%) diff --git a/.gitignore b/.gitignore index b25ff8c..88712d7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,6 @@ build/ *.spec !agenticx.spec .claude/ -.acli/ -.agenticcli/ /.agenticx/ /keys.log traces/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4585495..e097ee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.1] - 2026-11-11 + +### Added + +- End-to-end App Catalog installation and verification quick starts, including + the separate Expert activation step and one-shot `agenticx -c` usage. +- DashScope and MaaS request attribution through + `x-dashscope-sdk-client` and `x-dashscope-sdk-session-id` across Native, + OpenAI-compatible, Anthropic-compatible, MCP, ASR, and TTS requests. + Third-party endpoints do not receive these headers. + +### Changed + +- Expert management now consistently uses `agenticx expert`, `/expert`, + `expert_repo`, and `expert_branch`; legacy Example command and configuration + names have been removed. +- English and Chinese guides now cover the current CLI, interaction, Skill, + Session, and Server commands and clarify App installation versus Expert + activation. + ## [1.0.0] - 2026-11-10 ### Added @@ -31,10 +51,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and `user-guide_zh.md`. - Public guides and migration documentation live at the repository root. Source distributions exclude the internal `md/` directory. -- Example downloads default to the public `dashscope/dashscope-agenticx-experts` +- Expert downloads default to the public `dashscope/dashscope-agenticx-experts` repository on branch `main`; explicit repository overrides remain supported. - App Catalog downloads read only `apps//`, with no fallback to legacy - top-level example directories or an interactive repository setup prompt. + top-level Expert directories or an interactive repository setup prompt. ### Fixed @@ -74,7 +94,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 do not match a known API-key pattern. - Credential assignments are redacted regardless of length, and complete or truncated multiline private-key blocks are removed from text payloads. -- Example download and restore reject path traversal, symbolic links, and +- Expert download and restore reject path traversal, symbolic links, and attempts to overwrite their managed backup directory. - PyCharm rolling selections preserve content offsets and flush pending wheel batches before mouse release. @@ -190,8 +210,8 @@ contracts. - Camera and browser tools register only when their optional dependencies are present. Skill evolution and directive auto-learning are off by default and require explicit experimental environment gates. -- The Example command is explicitly a teaching workflow; production profiles - use versioned Apps. The examples repository now exposes an `apps/` catalog +- The Expert command is explicitly a teaching workflow; production profiles + use versioned Apps. The Expert repository now exposes an `apps/` catalog schema for Basic Chat, DashScope SDK Expert, and Bailian Expert. - Session events now project messages, plan, goal, artifacts, and unified runtime/agent/session/run/turn identifiers; forks replay the same state. diff --git a/README.md b/README.md index a5b0ba9..7278bc2 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ Do not paste real API keys into tracked configuration or example files. ```bash agenticx --cli # Inline REPL with terminal scrollback agenticx --tui # Textual interface (requires the TUI extra) -agenticx "List the files in this directory and summarize the project" +agenticx -c "List the files in this directory and summarize the project" ``` Inside an interactive session: @@ -190,6 +190,33 @@ agenticx app verify agenticx app update ``` +For example, install and verify DashScope SDK Expert from the official App +Catalog: + +```bash +python -m pip install "dashscope-agenticx>=1.0.1,<2" +git clone https://github.com/dashscope/dashscope-agenticx-experts.git +cd dashscope-agenticx-experts +agenticx app install ./apps/dashscope-sdk-expert +agenticx app info dashscope-sdk-expert +agenticx app verify dashscope-sdk-expert +``` + +`app install` only installs and registers the versioned App under the current +workspace's `.agenticx/apps/`; it does not activate the App profile. After the +`cd` above, the cloned repository is that workspace. To run DashScope SDK Expert, +merge its profile into the workspace and start AgenticX: + +```bash +export DASHSCOPE_API_KEY="" +agenticx expert download dashscope-sdk-expert +agenticx +``` + +Expert downloads are not registered as Apps and cannot be managed with `app +list` or `app update`. `app update` reinstalls from the recorded source, so run +`git pull` in the clone before updating a locally cloned App. + The source tree includes `dashscope-sdk-expert` and `bailian-expert` bundles. Inspect the manifest and requested permissions before installation. A checksum proves content identity, not publisher authenticity; a local trust flag is not diff --git a/README_zh.md b/README_zh.md index b20d0fc..de736bc 100644 --- a/README_zh.md +++ b/README_zh.md @@ -98,7 +98,7 @@ agenticx ```bash agenticx --cli agenticx --tui -agenticx "列出当前目录的文件,并概述项目结构" +agenticx -c "列出当前目录的文件,并概述项目结构" ``` 交互会话示例: @@ -180,6 +180,31 @@ agenticx app verify agenticx app update ``` +例如,从官方 App Catalog 安装并校验 DashScope SDK Expert: + +```bash +python -m pip install "dashscope-agenticx>=1.0.1,<2" +git clone https://github.com/dashscope/dashscope-agenticx-experts.git +cd dashscope-agenticx-experts +agenticx app install ./apps/dashscope-sdk-expert +agenticx app info dashscope-sdk-expert +agenticx app verify dashscope-sdk-expert +``` + +`app install` 只将版本化 App 安装并登记到当前工作区的 `.agenticx/apps/`, +不会启用 App Profile。上述 `cd` 之后,克隆仓库就是当前工作区。如需实际运行 +DashScope SDK Expert,将 Profile 合并到工作区后启动: + +```bash +export DASHSCOPE_API_KEY="" +agenticx expert download dashscope-sdk-expert +agenticx +``` + +Expert 下载不会登记为 App,不能用 `app list` 或 `app update` 管理。 +`app update` 从已记录的来源重新安装;更新本地克隆的 App 前,需先在克隆目录 +执行 `git pull`。 + 源码提供 `dashscope-sdk-expert` 和 `bailian-expert` 两个 Bundle。 安装前检查 Manifest 和请求的权限。Checksum 只能验证内容身份,不能证明发布者 真实性;本地 trust 标记也不是经过验证的数字签名。 diff --git a/RELEASE.md b/RELEASE.md index 3f728df..dc0c378 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,6 +1,6 @@ # Release Process -The release version is `1.0.0`. Updating the version and pushing or merging +The release version is `1.0.1`. Updating the version and pushing or merging Git branches does not publish packages or create release tags. External publication requires the checks and explicit approval below. @@ -15,7 +15,7 @@ publication requires the checks and explicit approval below. metadata, and `agenticx --version` agree. 6. Only when external publication is explicitly approved, tag `v` and publish the `dashscope-agenticx` wheel/sdist plus checksums and SBOM. -7. Archive the migration guide and final benchmark/eval reports for `1.0.0`. +7. Archive the migration guide and final benchmark/eval reports for `1.0.1`. Main-branch pushes and pull requests run unit tests and pre-commit checks. Before pushing a release tag, confirm the tagged source passed those checks. diff --git a/SECURITY.md b/SECURITY.md index a4dfbb0..8171960 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Supported versions -Security fixes target the active `1.0.0` release line. +Security fixes target the active `1.0.1` release line. ## Reporting diff --git a/migration-to-1.0.md b/migration-to-1.0.md index 6a2efdd..ce7ccbf 100644 --- a/migration-to-1.0.md +++ b/migration-to-1.0.md @@ -1,7 +1,7 @@ -# Migrating to DashScope AgenticX 1.0.0 +# Migrating to DashScope AgenticX 1.0.1 This guide describes migration from legacy AgenticCLI versions to -DashScope AgenticX `1.0.0`. +DashScope AgenticX `1.0.1`. ## Current identities @@ -23,8 +23,8 @@ DashScope AgenticX `1.0.0`. Expert downloads default to `https://github.com/dashscope/dashscope-agenticx-experts.git` on branch `main`. -An explicit `examples_repo` setting or `--repo` flag can select another source. -The catalog uses only `apps//`; legacy top-level example directories +An explicit `expert_repo` setting or `--repo` flag can select another source. +The catalog uses only `apps//`; legacy top-level Expert directories are not supported. ## One-way directory migration diff --git a/scripts/release_check.py b/scripts/release_check.py index 9f7556d..deae1e3 100644 --- a/scripts/release_check.py +++ b/scripts/release_check.py @@ -25,7 +25,7 @@ def main() -> int: assert pyproject["project"]["scripts"] == { "agenticx": "dashscope_agenticx.cli:main", } - assert __version__ == "1.0.0" + assert __version__ == "1.0.1" assert (SRC / "dashscope_agenticx" / "py.typed").is_file() for legacy in ("agenticx", "agenticcli", "acli"): assert not (SRC / legacy).exists() diff --git a/src/dashscope_agenticx/_version.py b/src/dashscope_agenticx/_version.py index ba4b801..bb5739f 100644 --- a/src/dashscope_agenticx/_version.py +++ b/src/dashscope_agenticx/_version.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- """Single source of truth for package, CLI, SDK, and build versions.""" -__version__ = "1.0.0" +__version__ = "1.0.1" diff --git a/src/dashscope_agenticx/cli/__init__.py b/src/dashscope_agenticx/cli/__init__.py index e620e68..693cae4 100644 --- a/src/dashscope_agenticx/cli/__init__.py +++ b/src/dashscope_agenticx/cli/__init__.py @@ -54,8 +54,8 @@ _handle_slash_command, dispatch_async_command, ) -from dashscope_agenticx.cli.examples import ( # noqa: E402 - _handle_example_command, +from dashscope_agenticx.cli.experts import ( # noqa: E402 + _handle_expert_command, ) from dashscope_agenticx.cli.handlers_capability import ( # noqa: F401,E402 _cap_enabled, @@ -241,8 +241,8 @@ def main(): ) asyncio.run(_run_oneshot(config, prompt)) return - elif arg in ("expert", "experts", "example", "examples"): - _handle_example_command(sys.argv[2:]) + elif arg == "expert": + _handle_expert_command(sys.argv[2:]) return elif arg == "mcp-server": from dashscope_agenticx.mcp_server import main as _mcp_server_main diff --git a/src/dashscope_agenticx/cli/completer.py b/src/dashscope_agenticx/cli/completer.py index d799892..7136b09 100644 --- a/src/dashscope_agenticx/cli/completer.py +++ b/src/dashscope_agenticx/cli/completer.py @@ -22,7 +22,7 @@ _SUBCOMMANDS, _TOP_LEVEL_COMMANDS, CAPABILITY_CATALOG, - EXPERT_COMMAND_ALIASES, + EXPERT_COMMANDS, ) from dashscope_agenticx.config import Config from dashscope_agenticx.skills import BUILTIN_SKILLS, KNOWN_MCP_SERVICES @@ -222,13 +222,13 @@ def _slot_candidates(self, tokens: list[str], arg_index: int) -> list[str]: cmd = tokens[0] if ( - cmd in EXPERT_COMMAND_ALIASES + cmd in EXPERT_COMMANDS and arg_index == 2 and tokens[1] == "download" ): - from dashscope_agenticx.cli.examples import _KNOWN_EXAMPLES + from dashscope_agenticx.cli.experts import _KNOWN_EXPERTS - return list(_KNOWN_EXAMPLES) + return list(_KNOWN_EXPERTS) if cmd == "/skill" and arg_index == 1: return _SUBCOMMANDS["/skill"] + list(BUILTIN_SKILLS.keys()) diff --git a/src/dashscope_agenticx/cli/constants.py b/src/dashscope_agenticx/cli/constants.py index 8f651bb..3f6d9a1 100644 --- a/src/dashscope_agenticx/cli/constants.py +++ b/src/dashscope_agenticx/cli/constants.py @@ -184,7 +184,7 @@ _MEDIA_SENTINEL_RE = re.compile(r"<<__AGENTICX_(IMG|AUDIO)_(\d+)__>>") # Top-level slash commands -EXPERT_COMMAND_ALIASES = ("/expert", "/experts", "/example", "/examples") +EXPERT_COMMANDS = ("/expert",) _TOP_LEVEL_COMMANDS = [ "/help", @@ -232,9 +232,6 @@ # Static second-level subcommands _SUBCOMMANDS: dict[str, list[str]] = { "/expert": ["list", "download", "restore"], - "/experts": ["list", "download", "restore"], - "/example": ["list", "download", "restore"], - "/examples": ["list", "download", "restore"], "/capability": ["list", "enable", "disable", "reload", "config"], "/subagents": ["list", "reload", "enable", "disable", "config"], "/trust": ["list", "clear", "allow", "deny"], diff --git a/src/dashscope_agenticx/cli/dispatch.py b/src/dashscope_agenticx/cli/dispatch.py index bdda31c..2f4fe56 100644 --- a/src/dashscope_agenticx/cli/dispatch.py +++ b/src/dashscope_agenticx/cli/dispatch.py @@ -15,7 +15,7 @@ from dashscope_agenticx.cli.command_registry import ( registry as command_registry, ) -from dashscope_agenticx.cli.constants import EXPERT_COMMAND_ALIASES +from dashscope_agenticx.cli.constants import EXPERT_COMMANDS from dashscope_agenticx.cli.handlers_config import ( _handle_debug_command, _handle_directives_command, @@ -467,11 +467,11 @@ def _dispatch_slash_command( elif cmd.startswith("/directives"): _handle_directives_command(cmd, config) return True - elif cmd and cmd.split(maxsplit=1)[0] in EXPERT_COMMAND_ALIASES: - from dashscope_agenticx.cli.examples import _handle_example_command + elif cmd and cmd.split(maxsplit=1)[0] in EXPERT_COMMANDS: + from dashscope_agenticx.cli.experts import _handle_expert_command args = cmd.split()[1:] - _handle_example_command(args) + _handle_expert_command(args) return True elif cmd.startswith("/audit"): from dashscope_agenticx.audit import get_audit_logger diff --git a/src/dashscope_agenticx/cli/examples.py b/src/dashscope_agenticx/cli/experts.py similarity index 89% rename from src/dashscope_agenticx/cli/examples.py rename to src/dashscope_agenticx/cli/experts.py index 2ae598d..4d7b62c 100644 --- a/src/dashscope_agenticx/cli/examples.py +++ b/src/dashscope_agenticx/cli/experts.py @@ -16,12 +16,12 @@ console = Console() -_DEFAULT_EXAMPLES_REPO = ( +_DEFAULT_EXPERTS_REPO = ( "https://github.com/dashscope/dashscope-agenticx-experts.git" ) -_DEFAULT_EXAMPLES_BRANCH = "main" +_DEFAULT_EXPERT_BRANCH = "main" -_KNOWN_EXAMPLES = [ +_KNOWN_EXPERTS = [ "basic-chat", "dashscope-sdk-expert", "bailian-expert", @@ -34,7 +34,7 @@ def _selected_expert(workspace: Path | None = None) -> str: workspace = Path(workspace) if workspace is not None else WORKSPACE_DIR try: - marker = _safe_example_path( + marker = _safe_expert_path( workspace.parent, Path(workspace.name) / "expert.toml", ) @@ -42,13 +42,13 @@ def _selected_expert(workspace: Path | None = None) -> str: name = data.get("name", "") if not isinstance(name, str) or not name: return "" - _validate_example_name(name) + _validate_expert_name(name) return name except (OSError, ValueError): return "" -def _get_examples_dir() -> Path: +def _get_experts_dir() -> Path: """Return the canonical local App Catalog directory.""" project_root = Path(__file__).resolve().parent.parent.parent.parent return project_root / "apps" @@ -60,32 +60,32 @@ def _get_configured_repo() -> tuple[str, str]: cfg = Config.load() return ( - cfg.examples_repo or _DEFAULT_EXAMPLES_REPO, - cfg.examples_branch or _DEFAULT_EXAMPLES_BRANCH, + cfg.expert_repo or _DEFAULT_EXPERTS_REPO, + cfg.expert_branch or _DEFAULT_EXPERT_BRANCH, ) except Exception: - return _DEFAULT_EXAMPLES_REPO, _DEFAULT_EXAMPLES_BRANCH + return _DEFAULT_EXPERTS_REPO, _DEFAULT_EXPERT_BRANCH -def _list_examples() -> list[str]: - examples_dir = _get_examples_dir() - if not examples_dir.is_dir(): +def _list_experts() -> list[str]: + experts_dir = _get_experts_dir() + if not experts_dir.is_dir(): return [] return sorted( d.name - for d in examples_dir.iterdir() + for d in experts_dir.iterdir() if d.is_dir() and not d.name.startswith((".", "_")) ) -def _clone_example_to_temp( +def _clone_expert_to_temp( name: str, repo: str, branch: str, tmp: Path, ) -> Path: """Sparse-clone apps/ from the App Catalog into tmp.""" - _validate_example_name(name) + _validate_expert_name(name) try: subprocess.run( [ @@ -138,7 +138,7 @@ def _clone_example_to_temp( return src -def _list_remote_examples(repo: str, branch: str) -> list[str]: +def _list_remote_experts(repo: str, branch: str) -> list[str]: """List apps/ directories via a blob-less clone.""" with tempfile.TemporaryDirectory() as tmp: subprocess.run( @@ -168,8 +168,8 @@ def _list_remote_examples(repo: str, branch: str) -> list[str]: 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 AgenticX config (create or +def _save_expert_repo(repo: str) -> None: + """Write expert_repo into the global AgenticX config (create or replace).""" from dashscope_agenticx.config import CONFIG_DIR, CONFIG_FILE from dashscope_agenticx.utils.paths import atomic_write_text @@ -179,9 +179,9 @@ def _save_examples_repo(repo: str) -> None: 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) + line = f"expert_repo = {toml_str(repo)}" + if re.search(r"(?m)^expert_repo\s*=", text): + text = re.sub(r"(?m)^expert_repo\s*=.*$", line, text) else: if text and not text.endswith("\n"): text += "\n" @@ -189,12 +189,12 @@ def _save_examples_repo(repo: str) -> None: atomic_write_text(CONFIG_FILE, text) -def _validate_example_name(name: str) -> None: +def _validate_expert_name(name: str) -> None: if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", name): raise ValueError("expert name must be a single directory name") -def _safe_example_path(root: Path, relative: Path) -> Path: +def _safe_expert_path(root: Path, relative: Path) -> Path: if relative.is_absolute() or ".." in relative.parts: raise ValueError("expert path escapes the workspace") target = root @@ -207,7 +207,7 @@ def _safe_example_path(root: Path, relative: Path) -> Path: return target -def _example_files(src: Path) -> list[Path]: +def _expert_files(src: Path) -> list[Path]: """All copyable files under an expert dir (relative-safe, no junk).""" entries = sorted(src.rglob("*")) if src.is_symlink() or any(path.is_symlink() for path in entries): @@ -344,7 +344,7 @@ def _merge_extension_profile(source: Path, target: Path) -> str: return merged -def _copy_example_flat( +def _copy_expert_flat( src: Path, dst: Path, *, @@ -363,18 +363,18 @@ def _copy_example_flat( (single rolling backup, replaced on each download) so the last merge can be rolled back. """ - agenticx_dir = _safe_example_path(dst, Path(".agenticx")) - source_files = _example_files(src) + agenticx_dir = _safe_expert_path(dst, Path(".agenticx")) + source_files = _expert_files(src) _validate_expert_profile(src, source_files) - _safe_example_path(dst, Path(".agenticx/backup")) + _safe_expert_path(dst, Path(".agenticx/backup")) copies: list[ tuple[Path, Path] ] = [] # (source file, path relative to dst/.agenticx) merged_extensions: dict[Path, str] = {} if expert_name: - _validate_example_name(expert_name) - _safe_example_path(dst, Path(".agenticx/expert.toml")) - _safe_example_path(dst, Path(".agenticx/backup/expert.toml")) + _validate_expert_name(expert_name) + _safe_expert_path(dst, Path(".agenticx/expert.toml")) + _safe_expert_path(dst, Path(".agenticx/backup/expert.toml")) for p in source_files: rel = p.relative_to(src) if rel.parts[0] == ".agenticx": @@ -387,8 +387,8 @@ def _copy_example_flat( raise ValueError( "experts must not overwrite the backup directory", ) - _safe_example_path(dst, Path(".agenticx") / rel) - _safe_example_path(dst, Path(".agenticx/backup") / rel) + _safe_expert_path(dst, Path(".agenticx") / rel) + _safe_expert_path(dst, Path(".agenticx/backup") / rel) if preserve_configuration and rel == Path("config.toml"): continue if expert_name and rel == Path("expert.toml"): @@ -533,11 +533,11 @@ def _prune_empty_dirs(path: Path, stop: Path) -> None: path = path.parent -def _restore_example_backup(dst: Path) -> bool: +def _restore_expert_backup(dst: Path) -> bool: """Restore the single .agenticx/backup: originals copied back, added files removed.""" - backup = _safe_example_path(dst, Path(".agenticx/backup")) + backup = _safe_expert_path(dst, Path(".agenticx/backup")) if not backup.is_dir(): console.print( "[yellow]No restorable backup found " @@ -546,7 +546,7 @@ def _restore_example_backup(dst: Path) -> bool: return False removals = [] - manifest = _safe_example_path(dst, Path(".agenticx/backup/.manifest")) + manifest = _safe_expert_path(dst, Path(".agenticx/backup/.manifest")) if manifest.is_file(): for line in manifest.read_text(encoding="utf-8").splitlines(): rel = line.strip() @@ -555,8 +555,8 @@ def _restore_example_backup(dst: Path) -> bool: backup_rel = Path(rel).relative_to(".agenticx") if not backup_rel.parts or backup_rel.parts[0] == "backup": raise ValueError("invalid expert backup manifest") - target = _safe_example_path(dst, Path(rel)) - original = _safe_example_path( + target = _safe_expert_path(dst, Path(rel)) + original = _safe_expert_path( dst, Path(".agenticx/backup") / backup_rel, ) @@ -565,11 +565,11 @@ def _restore_example_backup(dst: Path) -> bool: removals.append(target) restores = [] - for source in _example_files(backup): + for source in _expert_files(backup): if source.name == ".manifest": continue relative = source.relative_to(backup) - target = _safe_example_path(dst, Path(".agenticx") / relative) + target = _safe_expert_path(dst, Path(".agenticx") / relative) if relative.parts[0] == "backup": raise ValueError("invalid expert backup contents") restores.append((source, target)) @@ -596,25 +596,25 @@ def _restore_example_backup(dst: Path) -> bool: return True -def _print_example_list(): - examples_dir = _get_examples_dir() - available = _list_examples() +def _print_expert_list(): + experts_dir = _get_experts_dir() + available = _list_experts() if not available: repo, branch = _get_configured_repo() if repo: try: - available = _list_remote_examples(repo, branch) + available = _list_remote_experts(repo, branch) except Exception: available = [] if not available: - available = _KNOWN_EXAMPLES + available = _KNOWN_EXPERTS console.print( "[dim]Experts are role templates. Use `agenticx app` for " "versioned installable applications.[/dim]", ) console.print("[bold]Available experts:[/bold]") for name in available: - readme = examples_dir / name / "README.md" + readme = experts_dir / name / "README.md" desc = "" if readme.exists(): first_line = readme.read_text(encoding="utf-8").split("\n", 1)[0] @@ -644,23 +644,23 @@ def _print_example_list(): ) -def _handle_example_command( +def _handle_expert_command( args, *, show_usage: bool = True, preserve_configuration: bool = False, ): """Handle `agenticx expert [list | download ]`.""" - examples_dir = _get_examples_dir() + experts_dir = _get_experts_dir() if not args: - _print_example_list() + _print_expert_list() return None sub = args[0] if sub in ("list", "--list", "-l"): - _print_example_list() + _print_expert_list() return None # restore [--target DIR] @@ -675,7 +675,7 @@ def _handle_example_command( if idx + 1 < len(args): target_dir = Path(args[idx + 1]) try: - _restore_example_backup(target_dir) + _restore_expert_backup(target_dir) except (ValueError, OSError) as error: console.print(f"[yellow]Error: {error}[/yellow]") return None @@ -686,12 +686,12 @@ def _handle_example_command( console.print( "[yellow]Error: download requires an expert name[/yellow]", ) - available = _list_examples() or _KNOWN_EXAMPLES + available = _list_experts() or _KNOWN_EXPERTS console.print(f"[dim]Available: {', '.join(available)}[/dim]") return None name = args[1] try: - _validate_example_name(name) + _validate_expert_name(name) except ValueError as error: console.print(f"[yellow]Error: {error}[/yellow]") return None @@ -711,10 +711,10 @@ def _handle_example_command( if idx + 1 < len(args): provided = args[idx + 1] - src = examples_dir / name + src = experts_dir / name if src.is_dir(): try: - return _copy_example_flat( + return _copy_expert_flat( src, target_dir, force=force, @@ -729,17 +729,17 @@ def _handle_example_command( repo, branch = _get_configured_repo() repo = provided or repo if provided: - _save_examples_repo(provided) + _save_expert_repo(provided) console.print( - "[dim]Saved examples_repo to AgenticX global config[/dim]", + "[dim]Saved expert_repo to AgenticX global config[/dim]", ) console.print( f"[dim]Downloading expert '{name}' from {repo}...[/dim]", ) try: with tempfile.TemporaryDirectory() as tmp: - fetched = _clone_example_to_temp(name, repo, branch, Path(tmp)) - return _copy_example_flat( + fetched = _clone_expert_to_temp(name, repo, branch, Path(tmp)) + return _copy_expert_flat( fetched, target_dir, force=force, @@ -751,7 +751,7 @@ def _handle_example_command( 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 " + "[dim]You can set expert_repo in " "~/.agenticx/config.toml[/dim]", ) return False diff --git a/src/dashscope_agenticx/cli/handlers_setup.py b/src/dashscope_agenticx/cli/handlers_setup.py index 17bbfed..8a3528b 100644 --- a/src/dashscope_agenticx/cli/handlers_setup.py +++ b/src/dashscope_agenticx/cli/handlers_setup.py @@ -154,26 +154,29 @@ def _setup_finalize(config: Config, agent, *, context=None) -> None: def _print_setup_expert(context): if context is not None: - from dashscope_agenticx.cli.examples import _KNOWN_EXAMPLES + from dashscope_agenticx.cli.experts import _KNOWN_EXPERTS - examples = [ + expert = [ result for result in context.results - if result.name in (*_KNOWN_EXAMPLES, "examples") + if result.name in (*_KNOWN_EXPERTS, "expert") ] - if examples: - example = examples[-1] + if expert: + selected = expert[-1] console.print( "[dim] Expert: skipped[/dim]" - if example.status == "skipped" - else f"[dim] Expert: {example.name} ({example.status})[/dim]", + if selected.status == "skipped" + else ( + f"[dim] Expert: {selected.name} " + f"({selected.status})[/dim]" + ), ) -def _setup_examples(*, context=None): - from dashscope_agenticx.cli.examples import ( - _KNOWN_EXAMPLES, - _handle_example_command, +def _setup_expert(*, context=None): + from dashscope_agenticx.cli.experts import ( + _KNOWN_EXPERTS, + _handle_expert_command, ) from dashscope_agenticx.cli.wizard import ( WizardContext, @@ -189,23 +192,23 @@ def _setup_examples(*, context=None): "existing provider definitions.[/dim]", ) console.print(" [0] Skip (default)") - for index, name in enumerate(_KNOWN_EXAMPLES, 1): + for index, name in enumerate(_KNOWN_EXPERTS, 1): console.print(f" [{index}] {name}") choice = read_choice("Expert [0]: ", "0") if choice == "0": - result = WizardResult("skipped", "examples") + result = WizardResult("skipped", "expert") context.results.append(result) return result - if choice in _KNOWN_EXAMPLES: + if choice in _KNOWN_EXPERTS: name = choice - elif choice.isdigit() and 1 <= int(choice) <= len(_KNOWN_EXAMPLES): - name = _KNOWN_EXAMPLES[int(choice) - 1] + elif choice.isdigit() and 1 <= int(choice) <= len(_KNOWN_EXPERTS): + name = _KNOWN_EXPERTS[int(choice) - 1] else: console.print( "[yellow]Choose a listed Expert or 0 to skip.[/yellow]", ) continue - copied = _handle_example_command( + copied = _handle_expert_command( ["download", name], show_usage=False, preserve_configuration=True, @@ -383,7 +386,7 @@ async def _run_setup(config: Config, agent): config.__dict__.clear() config.__dict__.update(previous) return result - _setup_examples(context=context) + _setup_expert(context=context) _hot_reload() _setup_finalize(config, agent, context=context) deferred = ( diff --git a/src/dashscope_agenticx/cli/startup.py b/src/dashscope_agenticx/cli/startup.py index 9d15ae8..496f240 100644 --- a/src/dashscope_agenticx/cli/startup.py +++ b/src/dashscope_agenticx/cli/startup.py @@ -115,7 +115,7 @@ def _print_banner(config: Config | None = None) -> None: # ── Startup info: provider / model / user / capabilities / tools ── if config is not None: - from dashscope_agenticx.cli.examples import _selected_expert + from dashscope_agenticx.cli.experts import _selected_expert user_display = config.user_name or "(not set)" console.print( diff --git a/src/dashscope_agenticx/config.py b/src/dashscope_agenticx/config.py index 0496c19..06416b8 100644 --- a/src/dashscope_agenticx/config.py +++ b/src/dashscope_agenticx/config.py @@ -520,8 +520,8 @@ class Config: fallback_providers: list[str] = field( default_factory=list, ) # Ordered fallback provider names - examples_repo: str = "" # Override the default public Expert catalog URL - examples_branch: str = "main" + expert_repo: str = "" # Override the default public Expert catalog URL + expert_branch: str = "main" def __setattr__(self, name: str, value) -> None: if isinstance(value, str) and ( @@ -725,10 +725,10 @@ def _load_global(self): self.tts_speed = float(data["tts_speed"]) except (ValueError, TypeError): pass - if "examples_repo" in data: - self.examples_repo = str(data["examples_repo"]) - if "examples_branch" in data: - self.examples_branch = str(data["examples_branch"]) + if "expert_repo" in data: + self.expert_repo = str(data["expert_repo"]) + if "expert_branch" in data: + self.expert_branch = str(data["expert_branch"]) def _load_workspace(self): self._load_workspace_from(WORKSPACE_CONFIG_FILE) @@ -920,10 +920,10 @@ def _load_workspace_from(self, path: Path): else: kwargs.pop("args", None) self.mcp_servers.append(MCPServerConfig(**kwargs)) - if "examples_repo" in data: - self.examples_repo = str(data["examples_repo"]) - if "examples_branch" in data: - self.examples_branch = str(data["examples_branch"]) + if "expert_repo" in data: + self.expert_repo = str(data["expert_repo"]) + if "expert_branch" in data: + self.expert_branch = str(data["expert_branch"]) if "thinking_provider" in data: self.thinking_provider = str(data["thinking_provider"]) if "thinking_model" in data: @@ -1088,10 +1088,10 @@ def _workspace_lines(self) -> list[str]: lines.append( f"voice_silence_threshold = {self.voice_silence_threshold}", ) - if self.examples_repo: - lines.append(f"examples_repo = {toml_str(self.examples_repo)}") - if self.examples_branch != "main": - lines.append(f"examples_branch = {toml_str(self.examples_branch)}") + if self.expert_repo: + lines.append(f"expert_repo = {toml_str(self.expert_repo)}") + if self.expert_branch != "main": + lines.append(f"expert_branch = {toml_str(self.expert_branch)}") if self.thinking_provider: lines.append( f"thinking_provider = {toml_str(self.thinking_provider)}", diff --git a/src/dashscope_agenticx/dashscope_headers.py b/src/dashscope_agenticx/dashscope_headers.py new file mode 100644 index 0000000..18a5c12 --- /dev/null +++ b/src/dashscope_agenticx/dashscope_headers.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import os +from urllib.parse import urlparse + +from dashscope_agenticx import SDK_SESSION_ID, __version__ + +_DASHSCOPE_HOSTS = { + "dashscope.aliyuncs.com", + "dashscope-intl.aliyuncs.com", +} + + +def is_dashscope_endpoint(base_url: str | None) -> bool: + if not base_url: + return False + parsed = urlparse(base_url if "://" in base_url else f"https://{base_url}") + host = (parsed.hostname or "").lower() + return host in _DASHSCOPE_HOSTS or host.endswith(".maas.aliyuncs.com") + + +def dashscope_sdk_headers( + module: str = "app", + *, + base_url: str | None = None, +) -> dict[str, str]: + if os.environ.get("DASHSCOPE_DISABLE_SDK_HEADERS"): + return {} + if base_url is not None and not is_dashscope_endpoint(base_url): + return {} + parts = ["dashscope-agenticx", __version__] + if module: + parts.append(module) + return { + "x-dashscope-sdk-client": "/".join(parts), + "x-dashscope-sdk-session-id": SDK_SESSION_ID, + } + + +def mark_dashscope_sdk_client() -> None: + from dashscope.legacy_1x.common.utils import set_sdk_client + + set_sdk_client("dashscope-agenticx") diff --git a/src/dashscope_agenticx/platforms/bailian/mcp.py b/src/dashscope_agenticx/platforms/bailian/mcp.py index 28839ed..9dba9d3 100644 --- a/src/dashscope_agenticx/platforms/bailian/mcp.py +++ b/src/dashscope_agenticx/platforms/bailian/mcp.py @@ -9,6 +9,8 @@ import httpx +from dashscope_agenticx.dashscope_headers import dashscope_sdk_headers + BAILIAN_MCP_BASE = "https://dashscope.aliyuncs.com/api/v1/mcps" MCP_PROTOCOL_VERSION = "2024-11-05" @@ -36,6 +38,7 @@ def __init__(self, service: str, api_key: str, url: str = ""): self._api_key = api_key self._headers = { "Authorization": f"Bearer {api_key}", + **dashscope_sdk_headers("mcp", base_url=self._base_url), } self._client: httpx.AsyncClient | None = None self._request_id = 0 diff --git a/src/dashscope_agenticx/providers/__init__.py b/src/dashscope_agenticx/providers/__init__.py index 5a2e21f..1933fa5 100644 --- a/src/dashscope_agenticx/providers/__init__.py +++ b/src/dashscope_agenticx/providers/__init__.py @@ -35,7 +35,7 @@ # Env var hints for the /key command and profile validation. NOT used for # dispatch — protocol routing is driven by profile.protocol (filled from -# the user's custom-extensions.toml, see the basic-chat example). +# the user's custom-extensions.toml, see the basic-chat Expert). _WARNED_UNSUPPORTED_RUNTIME_OPTIONS: set[tuple[str, str, str]] = set() @@ -96,6 +96,7 @@ def _create_provider(profile: ProviderProfile): model=model, api_key=api_key, base_url=base_url, + module=profile.module, ) if proto == "dashscope": return TongyiProvider( @@ -107,7 +108,12 @@ def _create_provider(profile: ProviderProfile): ) from dashscope_agenticx.providers.openai import OpenAIProvider - return OpenAIProvider(model=model, api_key=api_key, base_url=base_url) + return OpenAIProvider( + model=model, + api_key=api_key, + base_url=base_url, + module=profile.module, + ) def get_provider( diff --git a/src/dashscope_agenticx/providers/anthropic.py b/src/dashscope_agenticx/providers/anthropic.py index fd5fac8..54de9ff 100644 --- a/src/dashscope_agenticx/providers/anthropic.py +++ b/src/dashscope_agenticx/providers/anthropic.py @@ -7,6 +7,7 @@ import httpx +from dashscope_agenticx.dashscope_headers import dashscope_sdk_headers from dashscope_agenticx.providers.base import LLMChunk, LLMResponse, ToolCall try: @@ -57,6 +58,7 @@ def __init__( api_key: str | None = None, base_url: str | None = None, timeout: float = 120.0, + module: str = "app", ): if anthropic is None: raise ImportError( @@ -70,6 +72,10 @@ def __init__( api_key=api_key, base_url=base_url, timeout=timeout, + default_headers=dashscope_sdk_headers( + module, + base_url=base_url or "", + ), ) async def aclose(self) -> None: diff --git a/src/dashscope_agenticx/providers/openai.py b/src/dashscope_agenticx/providers/openai.py index 0d04584..b09e374 100644 --- a/src/dashscope_agenticx/providers/openai.py +++ b/src/dashscope_agenticx/providers/openai.py @@ -5,6 +5,7 @@ import json from typing import AsyncIterator +from dashscope_agenticx.dashscope_headers import dashscope_sdk_headers from dashscope_agenticx.providers.base import LLMChunk, LLMResponse, ToolCall try: @@ -19,6 +20,7 @@ def __init__( model: str = "gpt-4o", api_key: str | None = None, base_url: str | None = None, + module: str = "app", ): if AsyncOpenAI is None: raise ImportError( @@ -28,7 +30,14 @@ def __init__( from dashscope_agenticx.config import normalize_model_name self.model = normalize_model_name(model) - self.client = AsyncOpenAI(api_key=api_key, base_url=base_url) + self.client = AsyncOpenAI( + api_key=api_key, + base_url=base_url, + default_headers=dashscope_sdk_headers( + module, + base_url=base_url or "", + ), + ) async def aclose(self) -> None: await self.client.close() diff --git a/src/dashscope_agenticx/providers/tongyi.py b/src/dashscope_agenticx/providers/tongyi.py index b4167f3..d2dd77b 100644 --- a/src/dashscope_agenticx/providers/tongyi.py +++ b/src/dashscope_agenticx/providers/tongyi.py @@ -10,8 +10,8 @@ import httpx -from dashscope_agenticx import SDK_SESSION_ID, __version__ from dashscope_agenticx.config import DEFAULT_MODEL, normalize_model_name +from dashscope_agenticx.dashscope_headers import dashscope_sdk_headers from dashscope_agenticx.providers.base import LLMChunk, LLMResponse, ToolCall # Service root; the provider appends the native generation path. This is @@ -425,12 +425,9 @@ def _get_headers(self, stream: bool = False) -> dict: headers["Accept"] = "text/event-stream" headers["X-Accel-Buffering"] = "no" headers["X-DashScope-SSE"] = "enable" - if not os.environ.get("DASHSCOPE_DISABLE_SDK_HEADERS"): - parts = ["dashscope-agenticx", __version__] - if self.module: - parts.append(self.module) - headers["x-dashscope-sdk-client"] = "/".join(parts) - headers["x-dashscope-sdk-session-id"] = SDK_SESSION_ID + headers.update( + dashscope_sdk_headers(self.module, base_url=self.base_url), + ) return headers async def chat( diff --git a/src/dashscope_agenticx/ui/tts.py b/src/dashscope_agenticx/ui/tts.py index a1f2a3e..a2dcfae 100644 --- a/src/dashscope_agenticx/ui/tts.py +++ b/src/dashscope_agenticx/ui/tts.py @@ -19,6 +19,7 @@ from typing import Optional from dashscope_agenticx.config import normalize_model_name +from dashscope_agenticx.dashscope_headers import mark_dashscope_sdk_client # --------------------------------------------------------------------------- # Public constants @@ -281,6 +282,7 @@ def _synthesize_and_play(self, text: str) -> Optional[str]: # Ensure the API key is available to the dashscope SDK. # The SDK reads dashscope.api_key (module-level), not the env var # directly in all code paths, so we must set both. + mark_dashscope_sdk_client() import dashscope import numpy as np import sounddevice as sd diff --git a/src/dashscope_agenticx/ui/tui.py b/src/dashscope_agenticx/ui/tui.py index c5e8cd1..c8bf7d6 100644 --- a/src/dashscope_agenticx/ui/tui.py +++ b/src/dashscope_agenticx/ui/tui.py @@ -2549,7 +2549,7 @@ def _render_banner(self) -> None: ] # Provider, Model, User - from dashscope_agenticx.cli.examples import _selected_expert + from dashscope_agenticx.cli.experts import _selected_expert user_display = self.config.user_name or "(not set)" info_lines.append( @@ -2882,7 +2882,7 @@ def _handle_command(self, command: str, *, _echo: bool = True) -> None: """Route command to handler. ``_echo=False`` skips the prompt echo (used when draining queued commands — they were already echoed when first submitted).""" - from dashscope_agenticx.cli.constants import EXPERT_COMMAND_ALIASES + from dashscope_agenticx.cli.constants import EXPERT_COMMANDS output = self.query_one("#output", RichLog) self._clear_selection_actions() @@ -2979,7 +2979,7 @@ def _handle_command(self, command: str, *, _echo: bool = True) -> None: # Expert commands may prompt (repo url, overwrite confirm) and # runs blocking git clone + file copies is_interactive_expert = ( - command.split(maxsplit=1)[0] in EXPERT_COMMAND_ALIASES + command.split(maxsplit=1)[0] in EXPERT_COMMANDS ) if ( any(command.startswith(p) for p in interactive_prefixes) @@ -3200,7 +3200,7 @@ async def _handle_async_command(self, command: str) -> None: """Handle async slash commands (profile, prompt, data, kb, etc.) Redirects module consoles to RichLog, dispatches, then restores.""" import dashscope_agenticx.cli as cli_module - from dashscope_agenticx.cli.constants import EXPERT_COMMAND_ALIASES + from dashscope_agenticx.cli.constants import EXPERT_COMMANDS output = self.query_one("#output", RichLog) @@ -3212,9 +3212,9 @@ async def _handle_async_command(self, command: str) -> None: import dashscope_agenticx.cli.handlers_provider # noqa: F401 # Same lazy-import console capture issue for Expert commands. - expert_command = command.split(maxsplit=1)[0] in EXPERT_COMMAND_ALIASES + expert_command = command.split(maxsplit=1)[0] in EXPERT_COMMANDS if expert_command or command.startswith("/setup"): - import dashscope_agenticx.cli.examples # noqa: F401 + import dashscope_agenticx.cli.experts # noqa: F401 # Interactive commands call input() and must run in a thread executor # so they don't block the TUI event loop. diff --git a/src/dashscope_agenticx/ui/voice.py b/src/dashscope_agenticx/ui/voice.py index 354e2b0..d99bd19 100644 --- a/src/dashscope_agenticx/ui/voice.py +++ b/src/dashscope_agenticx/ui/voice.py @@ -12,6 +12,8 @@ import time from typing import Optional +from dashscope_agenticx.dashscope_headers import mark_dashscope_sdk_client + def is_available() -> tuple[bool, str]: """Check if voice input dependencies are installed.""" @@ -61,6 +63,7 @@ class _RealtimeASR: def __init__(self, api_key: str, model: str): os.environ.setdefault("DASHSCOPE_API_KEY", api_key) + mark_dashscope_sdk_client() from dashscope.audio.asr import Recognition, RecognitionCallback self._model = model diff --git a/tests/e2e/test_smoke_repl.py b/tests/e2e/test_smoke_repl.py index 950d4e6..9306679 100644 --- a/tests/e2e/test_smoke_repl.py +++ b/tests/e2e/test_smoke_repl.py @@ -50,7 +50,7 @@ def _run_agenticx( class TestReplSmoke: - def test_fresh_workspace_accepts_default_model_and_separate_examples( + def test_fresh_workspace_accepts_default_model_and_separate_expert( self, tmp_path, ): @@ -100,7 +100,7 @@ def test_fresh_workspace_accepts_default_model_and_separate_examples( ) assert (workspace / ".agenticx" / "config.toml").is_file() - def test_fresh_workspace_runs_setup_without_downloading_examples( + def test_fresh_workspace_runs_setup_without_downloading_expert( self, tmp_path, ): @@ -169,4 +169,4 @@ def test_version_flag_prints_agenticx_release(self, tmp_path): home.mkdir() proc = _run_agenticx(["--version"], "", home, tmp_path) assert proc.returncode == 0 - assert proc.stdout.strip() == "agenticx 1.0.0" + assert proc.stdout.strip() == "agenticx 1.0.1" diff --git a/tests/ut/test_cli_edge.py b/tests/ut/test_cli_edge.py index 272c46a..bf7b954 100644 --- a/tests/ut/test_cli_edge.py +++ b/tests/ut/test_cli_edge.py @@ -27,7 +27,7 @@ from dashscope_agenticx.cli.constants import ( ALL_CAPABILITY_KEYS, CAPABILITY_CATALOG, - EXPERT_COMMAND_ALIASES, + EXPERT_COMMANDS, ) from dashscope_agenticx.config import Config from dashscope_agenticx.executor import Executor @@ -1450,7 +1450,7 @@ def _doc(text): class TestExpertCommands: - @pytest.mark.parametrize("command", EXPERT_COMMAND_ALIASES) + @pytest.mark.parametrize("command", EXPERT_COMMANDS) def test_native_alias_routes_without_starting_agent( self, monkeypatch, @@ -1462,7 +1462,7 @@ def test_native_alias_routes_without_starting_agent( "argv", ["agenticx", command[1:], "download", "basic-chat"], ) - monkeypatch.setattr(cli_pkg, "_handle_example_command", handler) + monkeypatch.setattr(cli_pkg, "_handle_expert_command", handler) monkeypatch.setattr( Config, "load", @@ -1473,11 +1473,22 @@ def test_native_alias_routes_without_starting_agent( handler.assert_called_once_with(["download", "basic-chat"]) - @pytest.mark.parametrize("command", EXPERT_COMMAND_ALIASES) + @pytest.mark.parametrize("command", ["experts", "example", "examples"]) + def test_removed_native_aliases_are_rejected(self, monkeypatch, command): + monkeypatch.setattr( + sys, + "argv", + ["agenticx", command, "download", "basic-chat"], + ) + with pytest.raises(SystemExit) as exc_info: + cli_pkg.main() + assert exc_info.value.code == 2 + + @pytest.mark.parametrize("command", EXPERT_COMMANDS) def test_slash_alias_routes(self, monkeypatch, command): handler = MagicMock() monkeypatch.setattr( - "dashscope_agenticx.cli.examples._handle_example_command", + "dashscope_agenticx.cli.experts._handle_expert_command", handler, ) @@ -1491,16 +1502,24 @@ def test_slash_alias_routes(self, monkeypatch, command): ) handler.assert_called_once_with(["download", "basic-chat"]) - def test_similar_prefix_is_not_an_expert_command(self, monkeypatch): + @pytest.mark.parametrize( + "command", + ["/experts", "/example", "/examples", "/examples-extra"], + ) + def test_removed_alias_or_similar_prefix_is_not_an_expert_command( + self, + monkeypatch, + command, + ): handler = MagicMock() monkeypatch.setattr( - "dashscope_agenticx.cli.examples._handle_example_command", + "dashscope_agenticx.cli.experts._handle_expert_command", handler, ) assert ( dp._handle_slash_command( - "/examples-extra list", + f"{command} list", MagicMock(), Config(), ) @@ -1516,11 +1535,12 @@ def test_help_advertises_canonical_command_only(self, monkeypatch, capsys): native_help = capsys.readouterr().out assert "expert download" in native_help assert "expert restore" in native_help - assert "example download" not in native_help + for alias in ("experts", "example", "examples"): + assert f"{alias} download" not in native_help slash_help = render_help_text() assert "/expert download" in slash_help assert "/expert restore" in slash_help - for alias in EXPERT_COMMAND_ALIASES[1:]: + for alias in ("/experts", "/example", "/examples"): assert alias not in slash_help @@ -1530,9 +1550,9 @@ def test_top_level_commands(self): out = [c.text for c in comp.get_completions(_doc("/"), None)] assert "/help" in out and "/exit" in out assert "/expert" in out - assert not set(EXPERT_COMMAND_ALIASES[1:]) & set(out) + assert not {"/experts", "/example", "/examples"} & set(out) - @pytest.mark.parametrize("command", EXPERT_COMMAND_ALIASES) + @pytest.mark.parametrize("command", EXPERT_COMMANDS) def test_expert_submenus(self, command): comp = AcliCompleter(Config()) out = [ @@ -1541,14 +1561,14 @@ def test_expert_submenus(self, command): ] assert out == ["list", "download", "restore"] - @pytest.mark.parametrize("command", EXPERT_COMMAND_ALIASES) + @pytest.mark.parametrize("command", EXPERT_COMMANDS) def test_expert_download_name_completion_is_local( self, monkeypatch, command, ): monkeypatch.setattr( - "dashscope_agenticx.cli.examples._list_remote_examples", + "dashscope_agenticx.cli.experts._list_remote_experts", MagicMock( side_effect=AssertionError( "completion must not access network", diff --git a/tests/ut/test_config.py b/tests/ut/test_config.py index 3cb6904..3277b60 100644 --- a/tests/ut/test_config.py +++ b/tests/ut/test_config.py @@ -1001,8 +1001,8 @@ def test_scalars_survive_with_subagents_and_mcp_servers( cfg.thinking_provider = "anthropic" cfg.thinking_model = "claude-opus-4-20250514" cfg.loop_mode = "structured" - cfg.examples_repo = "https://example.com/repo.git" - cfg.examples_branch = "dev" + cfg.expert_repo = "https://example.com/repo.git" + cfg.expert_branch = "dev" cfg.save_workspace() # Array-of-tables headers must appear after every bare scalar key. @@ -1015,8 +1015,8 @@ def test_scalars_survive_with_subagents_and_mcp_servers( "thinking_provider", "thinking_model", "loop_mode", - "examples_repo", - "examples_branch", + "expert_repo", + "expert_branch", ): assert content.index(f"{scalar} =") < first_table @@ -1024,8 +1024,8 @@ def test_scalars_survive_with_subagents_and_mcp_servers( assert loaded.thinking_provider == "anthropic" assert loaded.thinking_model == "claude-opus-4-20250514" assert loaded.loop_mode == "structured" - assert loaded.examples_repo == "https://example.com/repo.git" - assert loaded.examples_branch == "dev" + assert loaded.expert_repo == "https://example.com/repo.git" + assert loaded.expert_branch == "dev" assert loaded.subagents["researcher"].model == "qwen-max" assert loaded.subagents["researcher"].temperature == 0.7 assert loaded.subagents["researcher"].max_turns == 10 diff --git a/tests/ut/test_dashscope_headers.py b/tests/ut/test_dashscope_headers.py new file mode 100644 index 0000000..28b8e0c --- /dev/null +++ b/tests/ut/test_dashscope_headers.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from dashscope_agenticx import SDK_SESSION_ID, __version__ +from dashscope_agenticx.dashscope_headers import ( + dashscope_sdk_headers, + is_dashscope_endpoint, +) +from dashscope_agenticx.providers import _create_provider +from dashscope_agenticx.providers.profile import ProviderProfile + + +@pytest.mark.parametrize( + "base_url", + [ + "https://dashscope.aliyuncs.com/compatible-mode/v1", + "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", + "https://workspace.cn-hangzhou.maas.aliyuncs.com/v1", + "dashscope.aliyuncs.com/compatible-mode/v1", + ], +) +def test_dashscope_endpoints_accepted(base_url): + assert is_dashscope_endpoint(base_url) + + +@pytest.mark.parametrize( + "base_url", + [ + "", + "https://api.openai.com/v1", + "https://dashscope.aliyuncs.com.example.com/v1", + "https://workspace.maas.aliyuncs.com.example.com/v1", + "https://evilmaas.aliyuncs.com/v1", + ], +) +def test_third_party_and_lookalike_endpoints_rejected(base_url): + assert not is_dashscope_endpoint(base_url) + + +def test_headers_include_agenticx_version_module_and_session(monkeypatch): + monkeypatch.delenv("DASHSCOPE_DISABLE_SDK_HEADERS", raising=False) + + headers = dashscope_sdk_headers( + "expert", + base_url="https://dashscope.aliyuncs.com", + ) + + assert headers == { + "x-dashscope-sdk-client": (f"dashscope-agenticx/{__version__}/expert"), + "x-dashscope-sdk-session-id": SDK_SESSION_ID, + } + + +def test_headers_can_be_disabled(monkeypatch): + monkeypatch.setenv("DASHSCOPE_DISABLE_SDK_HEADERS", "1") + assert dashscope_sdk_headers() == {} + + +def test_third_party_endpoint_receives_no_sdk_headers(monkeypatch): + monkeypatch.delenv("DASHSCOPE_DISABLE_SDK_HEADERS", raising=False) + assert dashscope_sdk_headers(base_url="https://api.openai.com/v1") == {} + + +@pytest.mark.parametrize("protocol", ["openai", "anthropic"]) +def test_provider_factory_propagates_module_and_headers(protocol, monkeypatch): + monkeypatch.delenv("DASHSCOPE_DISABLE_SDK_HEADERS", raising=False) + base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1" + profile = ProviderProfile( + name="tongyi-compatible", + provider="tongyi-compatible", + model="qwen-test", + api_key="sk-test", + base_url=base_url, + protocol=protocol, + module="expert", + ) + + if protocol == "anthropic": + fake_anthropic = MagicMock() + fake_anthropic.AsyncAnthropic = MagicMock() + patcher = patch( + "dashscope_agenticx.providers.anthropic.anthropic", + fake_anthropic, + ) + else: + fake_anthropic = None + patcher = patch("dashscope_agenticx.providers.openai.AsyncOpenAI") + + with patcher as client_class: + if fake_anthropic is not None: + client_class = fake_anthropic.AsyncAnthropic + _create_provider(profile) + + headers = client_class.call_args.kwargs["default_headers"] + assert headers["x-dashscope-sdk-client"] == ( + f"dashscope-agenticx/{__version__}/expert" + ) + assert headers["x-dashscope-sdk-session-id"] == SDK_SESSION_ID + + +@pytest.mark.parametrize("protocol", ["openai", "anthropic"]) +def test_compatible_providers_do_not_leak_headers_to_third_parties( + protocol, + monkeypatch, +): + monkeypatch.delenv("DASHSCOPE_DISABLE_SDK_HEADERS", raising=False) + profile = ProviderProfile( + name="third-party", + provider="third-party", + model="test-model", + api_key="sk-test", + base_url="https://api.example.com/v1", + protocol=protocol, + ) + + if protocol == "anthropic": + fake_anthropic = MagicMock() + fake_anthropic.AsyncAnthropic = MagicMock() + patcher = patch( + "dashscope_agenticx.providers.anthropic.anthropic", + fake_anthropic, + ) + else: + fake_anthropic = None + patcher = patch("dashscope_agenticx.providers.openai.AsyncOpenAI") + + with patcher as client_class: + if fake_anthropic is not None: + client_class = fake_anthropic.AsyncAnthropic + _create_provider(profile) + + assert client_class.call_args.kwargs["default_headers"] == {} + + +def test_mcp_marks_dashscope_requests(monkeypatch): + from dashscope_agenticx.platforms.bailian.mcp import MCPClient + + monkeypatch.delenv("DASHSCOPE_DISABLE_SDK_HEADERS", raising=False) + client = MCPClient("time", "sk-test") + + assert client._headers["x-dashscope-sdk-client"] == ( + f"dashscope-agenticx/{__version__}/mcp" + ) + assert client._headers["x-dashscope-sdk-session-id"] == SDK_SESSION_ID + + +def test_mcp_custom_third_party_endpoint_receives_no_sdk_headers(monkeypatch): + from dashscope_agenticx.platforms.bailian.mcp import MCPClient + + monkeypatch.delenv("DASHSCOPE_DISABLE_SDK_HEADERS", raising=False) + client = MCPClient( + "time", + "sk-test", + url="https://mcp.example.com/service", + ) + + assert "x-dashscope-sdk-client" not in client._headers + assert "x-dashscope-sdk-session-id" not in client._headers diff --git a/tests/ut/test_examples.py b/tests/ut/test_expert.py similarity index 80% rename from tests/ut/test_examples.py rename to tests/ut/test_expert.py index c84f74b..5a322c2 100644 --- a/tests/ut/test_examples.py +++ b/tests/ut/test_expert.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -"""Tests for dashscope_agenticx.cli.examples — _KNOWN_EXAMPLES, _get_examples_dir, -_list_examples, _print_example_list, _handle_example_command.""" +"""Tests for dashscope_agenticx.cli.experts — _KNOWN_EXPERTS, _get_experts_dir, +_list_experts, _print_expert_list, _handle_expert_command.""" from __future__ import annotations @@ -32,7 +32,7 @@ def test_expert_templates_cannot_install_executable_extensions( content, force, ): - from dashscope_agenticx.cli.examples import _copy_example_flat + from dashscope_agenticx.cli.experts import _copy_expert_flat source = tmp_path / "source" / ".agenticx" path = source / relative @@ -40,7 +40,7 @@ def test_expert_templates_cannot_install_executable_extensions( path.write_text(content) destination = tmp_path / "workspace" with pytest.raises(ValueError, match="templates cannot"): - _copy_example_flat(source.parent, destination, force=force) + _copy_expert_flat(source.parent, destination, force=force) assert not destination.exists() @@ -89,7 +89,7 @@ def test_setup_template_preserves_config_and_merges_declarations( ) (workspace_dir / "custom-extensions.toml").write_text(existing) - assert _copy_example_flat( + assert _copy_expert_flat( source, destination, force=True, @@ -150,7 +150,7 @@ def test_setup_template_invalid_extensions_do_not_modify_workspace( (workspace_dir / "system-prompt.md").write_text("Old role") with pytest.raises(ValueError): - _copy_example_flat( + _copy_expert_flat( source, destination, force=True, @@ -181,7 +181,7 @@ def test_setup_template_conflict_defaults_to_cancel( lambda prompt: prompts.append(prompt) or "", ) - assert not _copy_example_flat( + assert not _copy_expert_flat( source, destination, force=False, @@ -194,19 +194,19 @@ def test_setup_template_conflict_defaults_to_cancel( assert (workspace_dir / "system-prompt.md").read_text() == "Old role" -from dashscope_agenticx.cli.examples import ( - _DEFAULT_EXAMPLES_BRANCH, - _DEFAULT_EXAMPLES_REPO, - _KNOWN_EXAMPLES, - _get_examples_dir, +from dashscope_agenticx.cli.experts import ( + _DEFAULT_EXPERT_BRANCH, + _DEFAULT_EXPERTS_REPO, + _KNOWN_EXPERTS, + _get_experts_dir, _get_configured_repo, - _handle_example_command, - _clone_example_to_temp, - _copy_example_flat, - _list_examples, - _list_remote_examples, - _print_example_list, - _restore_example_backup, + _handle_expert_command, + _clone_expert_to_temp, + _copy_expert_flat, + _list_experts, + _list_remote_experts, + _print_expert_list, + _restore_expert_backup, _selected_expert, ) @@ -214,13 +214,13 @@ def test_setup_template_conflict_defaults_to_cancel( @pytest.fixture(autouse=True) def disable_remote_catalog_queries(monkeypatch): monkeypatch.setattr( - "dashscope_agenticx.cli.examples._list_remote_examples", + "dashscope_agenticx.cli.experts._list_remote_experts", lambda repo, branch: [], ) class TestExpertIdentity: - @pytest.mark.parametrize("name", _KNOWN_EXAMPLES) + @pytest.mark.parametrize("name", _KNOWN_EXPERTS) def test_explicit_name_overrides_bundled_identity(self, tmp_path, name): source = tmp_path / "template" / ".agenticx" source.mkdir(parents=True) @@ -228,7 +228,7 @@ def test_explicit_name_overrides_bundled_identity(self, tmp_path, name): (source / "system-prompt.md").write_text("Template role") destination = tmp_path / "workspace" - assert _copy_example_flat( + assert _copy_expert_flat( source.parent, destination, force=True, @@ -295,7 +295,7 @@ def test_cancelled_merge_preserves_identity(self, tmp_path, monkeypatch): monkeypatch.setattr("sys.stdin.isatty", lambda: True) monkeypatch.setattr("builtins.input", lambda prompt: "n") - assert not _copy_example_flat( + assert not _copy_expert_flat( source, destination, force=False, @@ -325,7 +325,7 @@ def test_download_rejects_symlinked_identity_paths( link.symlink_to(outside) with pytest.raises(ValueError, match="symbolic links"): - _copy_example_flat( + _copy_expert_flat( source, destination, force=True, @@ -349,14 +349,14 @@ def test_restore_restores_or_removes_identity(self, tmp_path, recorded): if recorded: (workspace / "expert.toml").write_text('name = "old-expert"\n') - assert _copy_example_flat( + assert _copy_expert_flat( source, destination, force=True, expert_name="new-expert", ) assert _selected_expert(workspace) == "new-expert" - assert _restore_example_backup(destination) + assert _restore_expert_backup(destination) assert _selected_expert(workspace) == ( "old-expert" if recorded else "" ) @@ -374,7 +374,7 @@ def test_invalid_profile_preserves_identity(self, tmp_path): (workspace / "expert.toml").write_text('name = "old-expert"\n') with pytest.raises(ValueError): - _copy_example_flat( + _copy_expert_flat( source, destination, force=True, @@ -386,21 +386,21 @@ def test_invalid_profile_preserves_identity(self, tmp_path): # --------------------------------------------------------------------------- -# _KNOWN_EXAMPLES +# _KNOWN_EXPERTS # --------------------------------------------------------------------------- -class TestKnownExamples: +class TestKnownExpert: def test_contains_all_apps(self): expected = { "basic-chat", "dashscope-sdk-expert", "bailian-expert", } - assert set(_KNOWN_EXAMPLES) == expected + assert set(_KNOWN_EXPERTS) == expected def test_has_three_entries(self): - assert len(_KNOWN_EXAMPLES) == 3 + assert len(_KNOWN_EXPERTS) == 3 @pytest.mark.parametrize( "name", @@ -410,96 +410,92 @@ def test_has_three_entries(self): "bailian-expert", ], ) - def test_each_example_present(self, name): - assert name in _KNOWN_EXAMPLES + def test_each_expert_present(self, name): + assert name in _KNOWN_EXPERTS def test_is_list_of_strings(self): - assert isinstance(_KNOWN_EXAMPLES, list) - assert all(isinstance(e, str) for e in _KNOWN_EXAMPLES) + assert isinstance(_KNOWN_EXPERTS, list) + assert all(isinstance(e, str) for e in _KNOWN_EXPERTS) def test_no_duplicates(self): - assert len(_KNOWN_EXAMPLES) == len(set(_KNOWN_EXAMPLES)) + assert len(_KNOWN_EXPERTS) == len(set(_KNOWN_EXPERTS)) # --------------------------------------------------------------------------- -# _get_examples_dir +# _get_experts_dir # --------------------------------------------------------------------------- -class TestGetExamplesDir: +class TestGetExpertDir: def test_returns_path(self): - result = _get_examples_dir() + result = _get_experts_dir() assert isinstance(result, Path) def test_returns_path_even_if_not_exists(self): """The function should always return a Path, even if the directory does not exist on disk.""" with patch.object(Path, "is_dir", return_value=False): - result = _get_examples_dir() + result = _get_experts_dir() assert isinstance(result, Path) def test_dev_mode_path_checked_first(self): """Local downloads use the canonical apps directory.""" fake_root = Path("/fake/project/root") - dev_examples = fake_root / "apps" + dev_expert = fake_root / "apps" - # Simulate: __file__ is at .../src/dashscope_agenticx/cli/examples.py + # Simulate: __file__ is at .../src/dashscope_agenticx/cli/expert.py # project_root = parent.parent.parent.parent with patch.object(Path, "resolve") as mock_resolve: mock_resolve.return_value = ( - fake_root - / "src" - / "dashscope_agenticx" - / "cli" - / "examples.py" + fake_root / "src" / "dashscope_agenticx" / "cli" / "expert.py" ) with patch.object(Path, "is_dir", return_value=True): - result = _get_examples_dir() - assert result == dev_examples + result = _get_experts_dir() + assert result == dev_expert # --------------------------------------------------------------------------- -# _list_examples +# _list_experts # --------------------------------------------------------------------------- -class TestListExamples: +class TestListExpert: def test_returns_list(self): - result = _list_examples() + result = _list_experts() assert isinstance(result, list) def test_returns_sorted_list(self): - result = _list_examples() + result = _list_experts() if len(result) > 1: assert result == sorted(result) def test_all_entries_are_strings(self): - result = _list_examples() + result = _list_experts() assert all(isinstance(e, str) for e in result) def test_excludes_hidden_directories(self, tmp_path): """Directories starting with . or _ should be excluded.""" - (tmp_path / "valid-example").mkdir() + (tmp_path / "valid-expert").mkdir() (tmp_path / ".hidden").mkdir() (tmp_path / "_private").mkdir() with patch( - "dashscope_agenticx.cli.examples._get_examples_dir", + "dashscope_agenticx.cli.experts._get_experts_dir", return_value=tmp_path, ): - result = _list_examples() - assert "valid-example" in result + result = _list_experts() + assert "valid-expert" in result assert ".hidden" not in result assert "_private" not in result def test_empty_when_dir_missing(self): - """When the examples directory does not exist, return empty list.""" + """When the expert directory does not exist, return empty list.""" nonexistent = Path("/nonexistent/path/that/should/not/exist") with patch( - "dashscope_agenticx.cli.examples._get_examples_dir", + "dashscope_agenticx.cli.experts._get_experts_dir", return_value=nonexistent, ): - result = _list_examples() + result = _list_experts() assert result == [] def test_only_directories_listed(self, tmp_path): @@ -508,91 +504,91 @@ def test_only_directories_listed(self, tmp_path): (tmp_path / "some-file.txt").write_text("not a dir") with patch( - "dashscope_agenticx.cli.examples._get_examples_dir", + "dashscope_agenticx.cli.experts._get_experts_dir", return_value=tmp_path, ): - result = _list_examples() + result = _list_experts() assert "real-dir" in result assert "some-file.txt" not in result # --------------------------------------------------------------------------- -# _print_example_list +# _print_expert_list # --------------------------------------------------------------------------- -class TestPrintExampleList: +class TestPrintExpertList: def test_does_not_raise(self): - """_print_example_list should run without raising.""" - _print_example_list() # prints to console; just verify no exception + """_print_expert_list should run without raising.""" + _print_expert_list() # prints to console; just verify no exception - def test_uses_known_examples_when_dir_empty(self, tmp_path): - """When _list_examples returns [], the fallback is _KNOWN_EXAMPLES.""" + def test_uses_known_expert_when_dir_empty(self, tmp_path): + """When _list_experts returns [], the fallback is _KNOWN_EXPERTS.""" with patch( - "dashscope_agenticx.cli.examples._get_examples_dir", + "dashscope_agenticx.cli.experts._get_experts_dir", return_value=tmp_path, ): - _print_example_list() # should not raise even with empty dir + _print_expert_list() # should not raise even with empty dir # --------------------------------------------------------------------------- -# _handle_example_command +# _handle_expert_command # --------------------------------------------------------------------------- -class TestHandleExampleCommand: +class TestHandleExpertCommand: def test_no_args_shows_list(self): """No arguments should trigger the list display (no exception).""" - _handle_example_command([]) + _handle_expert_command([]) def test_list_subcommand(self): """`list` subcommand should not raise.""" - _handle_example_command(["list"]) + _handle_expert_command(["list"]) def test_list_alias_dash_l(self): - _handle_example_command(["-l"]) + _handle_expert_command(["-l"]) def test_list_alias_dash_dash_list(self): - _handle_example_command(["--list"]) + _handle_expert_command(["--list"]) def test_download_without_name_does_not_raise(self): """`download` without a name should print an error, not crash.""" - _handle_example_command(["download"]) + _handle_expert_command(["download"]) def test_unknown_subcommand_does_not_raise(self): """Unknown subcommand should print a hint, not crash.""" - _handle_example_command(["bogus-subcommand"]) + _handle_expert_command(["bogus-subcommand"]) - def _make_example(self, tmp_path, name, files: dict[str, str]): - examples_dir = tmp_path / "examples" - ex_dir = examples_dir / name + def _make_expert(self, tmp_path, name, files: dict[str, str]): + experts_dir = tmp_path / "expert" + ex_dir = experts_dir / name for rel, content in files.items(): f = ex_dir / rel f.parent.mkdir(parents=True, exist_ok=True) f.write_text(content, encoding="utf-8") - return examples_dir + return experts_dir - def test_download_copies_local_example_flat(self, tmp_path, monkeypatch): - """Example files are merged into the target's .agenticx/ directory.""" - examples_dir = self._make_example( + def test_download_copies_local_expert_flat(self, tmp_path, monkeypatch): + """Expert files are merged into the target's .agenticx/ directory.""" + experts_dir = self._make_expert( tmp_path, "test-ex", - {"README.md": "# Test Example"}, + {"README.md": "# Test Expert"}, ) target_dir = tmp_path / "output" monkeypatch.setattr( - "dashscope_agenticx.cli.examples._get_examples_dir", - lambda: examples_dir, + "dashscope_agenticx.cli.experts._get_experts_dir", + lambda: experts_dir, ) - _handle_example_command( + _handle_expert_command( ["download", "test-ex", "--target", str(target_dir)], ) assert ( target_dir / ".agenticx" / "README.md" - ).read_text() == "# Test Example" + ).read_text() == "# Test Expert" assert not (target_dir / "test-ex").exists() assert _selected_expert(target_dir / ".agenticx") == "test-ex" assert not (tmp_path / ".agenticx/expert.toml").exists() @@ -605,7 +601,7 @@ def test_download_usage_hints( capsys, show_usage, ): - examples_dir = self._make_example( + experts_dir = self._make_expert( tmp_path, "basic-chat", {"README.md": "# New profile"}, @@ -615,16 +611,16 @@ def test_download_usage_hints( profile.parent.mkdir(parents=True) profile.write_text("# Original profile", encoding="utf-8") monkeypatch.setattr( - "dashscope_agenticx.cli.examples._get_examples_dir", - lambda: examples_dir, + "dashscope_agenticx.cli.experts._get_experts_dir", + lambda: experts_dir, ) monkeypatch.setattr( - "dashscope_agenticx.cli.examples.sys.stdin.isatty", + "dashscope_agenticx.cli.experts.sys.stdin.isatty", lambda: True, ) monkeypatch.setattr("builtins.input", lambda prompt: "") - _handle_example_command( + _handle_expert_command( ["download", "basic-chat", "--target", str(target_dir)], show_usage=show_usage, ) @@ -648,7 +644,7 @@ def test_setup_download_conflict_has_no_command_hint( monkeypatch, capsys, ): - examples_dir = self._make_example( + experts_dir = self._make_expert( tmp_path, "basic-chat", {"README.md": "# New profile"}, @@ -658,15 +654,15 @@ def test_setup_download_conflict_has_no_command_hint( profile.parent.mkdir(parents=True) profile.write_text("# Original profile", encoding="utf-8") monkeypatch.setattr( - "dashscope_agenticx.cli.examples._get_examples_dir", - lambda: examples_dir, + "dashscope_agenticx.cli.experts._get_experts_dir", + lambda: experts_dir, ) monkeypatch.setattr( - "dashscope_agenticx.cli.examples.sys.stdin.isatty", + "dashscope_agenticx.cli.experts.sys.stdin.isatty", lambda: False, ) - _handle_example_command( + _handle_expert_command( ["download", "basic-chat", "--target", str(target_dir)], show_usage=False, ) @@ -678,7 +674,7 @@ def test_setup_download_conflict_has_no_command_hint( def test_download_target_alias_short_flag(self, tmp_path, monkeypatch): """The -t short flag should work as an alias for --target.""" - examples_dir = self._make_example( + experts_dir = self._make_expert( tmp_path, "short-ex", {"file.txt": "data"}, @@ -686,11 +682,11 @@ def test_download_target_alias_short_flag(self, tmp_path, monkeypatch): target_dir = tmp_path / "out2" monkeypatch.setattr( - "dashscope_agenticx.cli.examples._get_examples_dir", - lambda: examples_dir, + "dashscope_agenticx.cli.experts._get_experts_dir", + lambda: experts_dir, ) - _handle_example_command( + _handle_expert_command( ["download", "short-ex", "-t", str(target_dir)], ) @@ -698,7 +694,7 @@ def test_download_target_alias_short_flag(self, tmp_path, monkeypatch): def test_download_skips_root_gitignore(self, tmp_path, monkeypatch): """The standalone-repo root .gitignore must not be merged.""" - examples_dir = self._make_example( + experts_dir = self._make_expert( tmp_path, "gi-ex", { @@ -710,11 +706,11 @@ def test_download_skips_root_gitignore(self, tmp_path, monkeypatch): target_dir = tmp_path / "out-gi" monkeypatch.setattr( - "dashscope_agenticx.cli.examples._get_examples_dir", - lambda: examples_dir, + "dashscope_agenticx.cli.experts._get_experts_dir", + lambda: experts_dir, ) - _handle_example_command( + _handle_expert_command( ["download", "gi-ex", "--target", str(target_dir)], ) @@ -725,7 +721,7 @@ def test_download_skips_root_gitignore(self, tmp_path, monkeypatch): def test_download_conflict_non_tty_aborts(self, tmp_path, monkeypatch): """Conflicts + non-interactive stdin → abort, nothing changes.""" - examples_dir = self._make_example( + experts_dir = self._make_expert( tmp_path, "dup-ex", {"file.txt": "new", "other.txt": "other"}, @@ -738,15 +734,15 @@ def test_download_conflict_non_tty_aborts(self, tmp_path, monkeypatch): ) monkeypatch.setattr( - "dashscope_agenticx.cli.examples._get_examples_dir", - lambda: examples_dir, + "dashscope_agenticx.cli.experts._get_experts_dir", + lambda: experts_dir, ) monkeypatch.setattr( "sys.stdin", type("S", (), {"isatty": lambda self: False})(), ) - _handle_example_command( + _handle_expert_command( ["download", "dup-ex", "--target", str(target_dir)], ) @@ -758,7 +754,7 @@ def test_download_conflict_non_tty_aborts(self, tmp_path, monkeypatch): def test_download_conflict_force_backs_up(self, tmp_path, monkeypatch): """--force overwrites conflicts but backs up the originals first.""" - examples_dir = self._make_example( + experts_dir = self._make_expert( tmp_path, "force-ex", {"file.txt": "new", "sub/nested.txt": "nested"}, @@ -775,11 +771,11 @@ def test_download_conflict_force_backs_up(self, tmp_path, monkeypatch): ) monkeypatch.setattr( - "dashscope_agenticx.cli.examples._get_examples_dir", - lambda: examples_dir, + "dashscope_agenticx.cli.experts._get_experts_dir", + lambda: experts_dir, ) - _handle_example_command( + _handle_expert_command( ["download", "force-ex", "--target", str(target_dir), "--force"], ) @@ -793,7 +789,7 @@ def test_download_conflict_force_backs_up(self, tmp_path, monkeypatch): def test_download_conflict_tty_confirm(self, tmp_path, monkeypatch): """TTY + user answers y → proceed with backup.""" - examples_dir = self._make_example( + experts_dir = self._make_expert( tmp_path, "tty-ex", {"file.txt": "new"}, @@ -806,8 +802,8 @@ def test_download_conflict_tty_confirm(self, tmp_path, monkeypatch): ) monkeypatch.setattr( - "dashscope_agenticx.cli.examples._get_examples_dir", - lambda: examples_dir, + "dashscope_agenticx.cli.experts._get_experts_dir", + lambda: experts_dir, ) monkeypatch.setattr( "sys.stdin", @@ -815,7 +811,7 @@ def test_download_conflict_tty_confirm(self, tmp_path, monkeypatch): ) monkeypatch.setattr("builtins.input", lambda prompt="": "y") - _handle_example_command( + _handle_expert_command( ["download", "tty-ex", "--target", str(target_dir)], ) @@ -830,7 +826,7 @@ def test_download_conflict_tty_enter_defaults_yes( monkeypatch, ): """TTY + empty answer (Enter) → default Yes, proceed with backup.""" - examples_dir = self._make_example( + experts_dir = self._make_expert( tmp_path, "enter-ex", {"file.txt": "new"}, @@ -843,8 +839,8 @@ def test_download_conflict_tty_enter_defaults_yes( ) monkeypatch.setattr( - "dashscope_agenticx.cli.examples._get_examples_dir", - lambda: examples_dir, + "dashscope_agenticx.cli.experts._get_experts_dir", + lambda: experts_dir, ) monkeypatch.setattr( "sys.stdin", @@ -852,7 +848,7 @@ def test_download_conflict_tty_enter_defaults_yes( ) monkeypatch.setattr("builtins.input", lambda prompt="": "") - _handle_example_command( + _handle_expert_command( ["download", "enter-ex", "--target", str(target_dir)], ) @@ -863,7 +859,7 @@ def test_download_conflict_tty_enter_defaults_yes( def test_download_conflict_tty_decline(self, tmp_path, monkeypatch): """TTY + user answers n → cancel, nothing changes.""" - examples_dir = self._make_example( + experts_dir = self._make_expert( tmp_path, "no-ex", {"file.txt": "new"}, @@ -876,8 +872,8 @@ def test_download_conflict_tty_decline(self, tmp_path, monkeypatch): ) monkeypatch.setattr( - "dashscope_agenticx.cli.examples._get_examples_dir", - lambda: examples_dir, + "dashscope_agenticx.cli.experts._get_experts_dir", + lambda: experts_dir, ) monkeypatch.setattr( "sys.stdin", @@ -885,7 +881,7 @@ def test_download_conflict_tty_decline(self, tmp_path, monkeypatch): ) monkeypatch.setattr("builtins.input", lambda prompt="": "n") - _handle_example_command( + _handle_expert_command( ["download", "no-ex", "--target", str(target_dir)], ) @@ -893,8 +889,8 @@ def test_download_conflict_tty_decline(self, tmp_path, monkeypatch): assert not (target_dir / ".agenticx" / "backup").exists() def test_download_skips_junk_files(self, tmp_path, monkeypatch): - """__pycache__/.git/.pyc under the example must not be copied.""" - examples_dir = self._make_example( + """__pycache__/.git/.pyc under the expert must not be copied.""" + experts_dir = self._make_expert( tmp_path, "junk-ex", { @@ -903,18 +899,18 @@ def test_download_skips_junk_files(self, tmp_path, monkeypatch): ".git/config": "junk", }, ) - (examples_dir / "junk-ex" / "mod.pyc").write_text( + (experts_dir / "junk-ex" / "mod.pyc").write_text( "junk", encoding="utf-8", ) target_dir = tmp_path / "out7" monkeypatch.setattr( - "dashscope_agenticx.cli.examples._get_examples_dir", - lambda: examples_dir, + "dashscope_agenticx.cli.experts._get_experts_dir", + lambda: experts_dir, ) - _handle_example_command( + _handle_expert_command( ["download", "junk-ex", "--target", str(target_dir)], ) @@ -925,12 +921,12 @@ def test_download_skips_junk_files(self, tmp_path, monkeypatch): class TestDownloadRepoGuidance: - def _no_local_example(self, tmp_path, monkeypatch): - """Patch away local examples and configured repo; return config paths.""" - empty = tmp_path / "examples" + def _no_local_expert(self, tmp_path, monkeypatch): + """Patch away local expert and configured repo; return config paths.""" + empty = tmp_path / "expert" empty.mkdir() monkeypatch.setattr( - "dashscope_agenticx.cli.examples._get_examples_dir", + "dashscope_agenticx.cli.experts._get_experts_dir", lambda: empty, ) @@ -955,7 +951,7 @@ def fake(name, repo, branch, tmp): return src monkeypatch.setattr( - "dashscope_agenticx.cli.examples._clone_example_to_temp", + "dashscope_agenticx.cli.experts._clone_expert_to_temp", fake, ) @@ -965,29 +961,29 @@ def test_default_public_repo_is_used_without_prompting( monkeypatch, capsys, ): - self._no_local_example(tmp_path, monkeypatch) + self._no_local_expert(tmp_path, monkeypatch) self._fake_clone(monkeypatch) monkeypatch.setattr( "sys.stdin", type("S", (), {"isatty": lambda self: False})(), ) - _handle_example_command( + _handle_expert_command( ["download", "ghost", "--target", str(tmp_path / "out")], ) assert (tmp_path / "out" / ".agenticx" / "README.md").read_text() == ( - "from " + _DEFAULT_EXAMPLES_REPO + "from " + _DEFAULT_EXPERTS_REPO ) assert _selected_expert(tmp_path / "out" / ".agenticx") == "ghost" - assert "Enter the examples" not in capsys.readouterr().out + assert "Enter the expert" not in capsys.readouterr().out def test_repo_flag_saved_and_used(self, tmp_path, monkeypatch): - _, cfg_file = self._no_local_example(tmp_path, monkeypatch) + _, cfg_file = self._no_local_expert(tmp_path, monkeypatch) self._fake_clone(monkeypatch) target_dir = tmp_path / "out" - _handle_example_command( + _handle_expert_command( [ "download", "remote-ex", @@ -1002,22 +998,22 @@ def test_repo_flag_saved_and_used(self, tmp_path, monkeypatch): "from http://git.example.com/a/b.git" ) content = cfg_file.read_text(encoding="utf-8") - assert 'examples_repo = "http://git.example.com/a/b.git"' in content + assert 'expert_repo = "http://git.example.com/a/b.git"' in content def test_repo_flag_replaces_existing_config_value( self, tmp_path, monkeypatch, ): - cfg_dir, cfg_file = self._no_local_example(tmp_path, monkeypatch) + cfg_dir, cfg_file = self._no_local_expert(tmp_path, monkeypatch) cfg_dir.mkdir(parents=True) cfg_file.write_text( - 'examples_repo = "http://old.git"\nexamples_branch = "dev"\n', + 'expert_repo = "http://old.git"\nexpert_branch = "dev"\n', encoding="utf-8", ) self._fake_clone(monkeypatch) - _handle_example_command( + _handle_expert_command( [ "download", "remote-ex", @@ -1029,15 +1025,15 @@ def test_repo_flag_replaces_existing_config_value( ) content = cfg_file.read_text(encoding="utf-8") - assert 'examples_repo = "http://new.git"' in content - assert 'examples_branch = "dev"' in content # other keys preserved + assert 'expert_repo = "http://new.git"' in content + assert 'expert_branch = "dev"' in content # other keys preserved def test_tty_uses_default_without_saving_an_override( self, tmp_path, monkeypatch, ): - _, cfg_file = self._no_local_example(tmp_path, monkeypatch) + _, cfg_file = self._no_local_expert(tmp_path, monkeypatch) self._fake_clone(monkeypatch) monkeypatch.setattr( "sys.stdin", @@ -1051,13 +1047,13 @@ def test_tty_uses_default_without_saving_an_override( ) target_dir = tmp_path / "out" - _handle_example_command( + _handle_expert_command( ["download", "remote-ex", "--target", str(target_dir)], ) assert ( target_dir / ".agenticx" / "README.md" - ).read_text() == "from " + _DEFAULT_EXAMPLES_REPO + ).read_text() == "from " + _DEFAULT_EXPERTS_REPO assert not cfg_file.exists() def test_configured_repo_overrides_the_public_default( @@ -1065,7 +1061,7 @@ def test_configured_repo_overrides_the_public_default( tmp_path, monkeypatch, ): - self._no_local_example(tmp_path, monkeypatch) + self._no_local_expert(tmp_path, monkeypatch) from dashscope_agenticx.config import Config monkeypatch.setattr( @@ -1073,8 +1069,8 @@ def test_configured_repo_overrides_the_public_default( "load", classmethod( lambda cls, **kwargs: cls( - examples_repo="https://git.example.com/catalog.git", - examples_branch="custom", + expert_repo="https://git.example.com/catalog.git", + expert_branch="custom", ), ), ) @@ -1085,13 +1081,13 @@ def test_configured_repo_overrides_the_public_default( # --------------------------------------------------------------------------- -# example restore +# expert restore # --------------------------------------------------------------------------- -class TestExampleRestore: +class TestExpertRestore: def test_restore_roundtrip_undoes_download(self, tmp_path, monkeypatch): - examples_dir = self._make_example( + experts_dir = self._make_expert( tmp_path, "ex", {"file.txt": "new", ".agenticx/skills/s.md": "skill"}, @@ -1104,15 +1100,15 @@ def test_restore_roundtrip_undoes_download(self, tmp_path, monkeypatch): ) monkeypatch.setattr( - "dashscope_agenticx.cli.examples._get_examples_dir", - lambda: examples_dir, + "dashscope_agenticx.cli.experts._get_experts_dir", + lambda: experts_dir, ) - _handle_example_command( + _handle_expert_command( ["download", "ex", "--target", str(target_dir), "--force"], ) assert (target_dir / ".agenticx" / "file.txt").read_text() == "new" - _handle_example_command(["restore", "--target", str(target_dir)]) + _handle_expert_command(["restore", "--target", str(target_dir)]) assert (target_dir / ".agenticx" / "file.txt").read_text() == "old" assert not ( @@ -1123,13 +1119,13 @@ def test_restore_roundtrip_undoes_download(self, tmp_path, monkeypatch): ).exists() # backup consumed def test_restore_without_backup_is_graceful(self, tmp_path, capsys): - _handle_example_command(["restore", "--target", str(tmp_path)]) + _handle_expert_command(["restore", "--target", str(tmp_path)]) assert "No restorable backup found" in capsys.readouterr().out def test_new_backup_replaces_previous(self, tmp_path, monkeypatch): """Single rolling backup: only the latest download can be rolled back.""" - examples_dir = self._make_example(tmp_path, "ex", {"f.txt": "v2"}) + experts_dir = self._make_expert(tmp_path, "ex", {"f.txt": "v2"}) target_dir = tmp_path / "proj" (target_dir / ".agenticx").mkdir(parents=True) (target_dir / ".agenticx" / "f.txt").write_text( @@ -1137,18 +1133,18 @@ def test_new_backup_replaces_previous(self, tmp_path, monkeypatch): encoding="utf-8", ) monkeypatch.setattr( - "dashscope_agenticx.cli.examples._get_examples_dir", - lambda: examples_dir, + "dashscope_agenticx.cli.experts._get_experts_dir", + lambda: experts_dir, ) - _handle_example_command( + _handle_expert_command( ["download", "ex", "--target", str(target_dir), "--force"], ) (target_dir / ".agenticx" / "f.txt").write_text( "v1", encoding="utf-8", ) - _handle_example_command( + _handle_expert_command( ["download", "ex", "--target", str(target_dir), "--force"], ) @@ -1156,19 +1152,19 @@ def test_new_backup_replaces_previous(self, tmp_path, monkeypatch): target_dir / ".agenticx" / "backup" / "f.txt" ).read_text() == "v1" - _handle_example_command(["restore", "--target", str(target_dir)]) + _handle_expert_command(["restore", "--target", str(target_dir)]) assert (target_dir / ".agenticx" / "f.txt").read_text() == "v1" assert not (target_dir / ".agenticx" / "backup").exists() - def _make_example(self, tmp_path, name, files): - examples_dir = tmp_path / "examples" - ex_dir = examples_dir / name + def _make_expert(self, tmp_path, name, files): + experts_dir = tmp_path / "expert" + ex_dir = experts_dir / name for rel, content in files.items(): f = ex_dir / rel f.parent.mkdir(parents=True, exist_ok=True) f.write_text(content, encoding="utf-8") - return examples_dir + return experts_dir # --------------------------------------------------------------------------- @@ -1196,38 +1192,38 @@ def run(arguments, **kwargs): ) monkeypatch.setattr( - "dashscope_agenticx.cli.examples.subprocess.run", + "dashscope_agenticx.cli.experts.subprocess.run", run, ) assert ( - _clone_example_to_temp( + _clone_expert_to_temp( "basic-chat", - _DEFAULT_EXAMPLES_REPO, + _DEFAULT_EXPERTS_REPO, "main", tmp_path, ) == app ) - assert calls[0][-3:] == ["--", _DEFAULT_EXAMPLES_REPO, str(tmp_path)] + assert calls[0][-3:] == ["--", _DEFAULT_EXPERTS_REPO, str(tmp_path)] assert calls[1][-2:] == ["--", "apps/basic-chat"] - def test_remote_clone_does_not_fall_back_to_top_level_example( + def test_remote_clone_does_not_fall_back_to_top_level_expert( self, tmp_path, monkeypatch, ): (tmp_path / "basic-chat").mkdir() monkeypatch.setattr( - "dashscope_agenticx.cli.examples.subprocess.run", + "dashscope_agenticx.cli.experts.subprocess.run", lambda arguments, **kwargs: subprocess.CompletedProcess( arguments, 0, ), ) with pytest.raises(FileNotFoundError): - _clone_example_to_temp( + _clone_expert_to_temp( "basic-chat", - _DEFAULT_EXAMPLES_REPO, + _DEFAULT_EXPERTS_REPO, "main", tmp_path, ) @@ -1245,10 +1241,10 @@ def run(arguments, **kwargs): return subprocess.CompletedProcess(arguments, 0, stdout=output) monkeypatch.setattr( - "dashscope_agenticx.cli.examples.subprocess.run", + "dashscope_agenticx.cli.experts.subprocess.run", run, ) - assert _list_remote_examples(_DEFAULT_EXAMPLES_REPO, "main") == [ + assert _list_remote_experts(_DEFAULT_EXPERTS_REPO, "main") == [ "basic-chat", "dashscope-sdk-expert", ] @@ -1273,14 +1269,14 @@ def test_download_rejects_invalid_directory_names( capsys, ): monkeypatch.setattr( - "dashscope_agenticx.cli.examples._get_examples_dir", + "dashscope_agenticx.cli.experts._get_experts_dir", lambda: tmp_path / "apps", ) monkeypatch.setattr( - "dashscope_agenticx.cli.examples._clone_example_to_temp", + "dashscope_agenticx.cli.experts._clone_expert_to_temp", lambda *arguments: pytest.fail("invalid names must not clone"), ) - _handle_example_command( + _handle_expert_command( ["download", name, "--target", str(tmp_path / "workspace")], ) assert "single directory name" in capsys.readouterr().out @@ -1304,7 +1300,7 @@ def test_source_symlink_is_rejected_without_copying(self, tmp_path): workspace = tmp_path / "workspace" with pytest.raises(ValueError, match="symbolic links"): - _copy_example_flat(source, workspace, force=True) + _copy_expert_flat(source, workspace, force=True) assert not (workspace / ".agenticx").exists() assert private.read_text() == "private-content" @@ -1321,19 +1317,19 @@ def test_destination_symlink_is_rejected_without_writing(self, tmp_path): self._link(profile / "config.toml", private) with pytest.raises(ValueError, match="symbolic links"): - _copy_example_flat(source, workspace, force=True) + _copy_expert_flat(source, workspace, force=True) assert private.read_text() == "private-content" assert not (profile / "backup").exists() - def test_example_cannot_supply_managed_backup_contents(self, tmp_path): + def test_expert_cannot_supply_managed_backup_contents(self, tmp_path): source = tmp_path / "app" backup = source / ".agenticx" / "backup" backup.mkdir(parents=True) (backup / ".manifest").write_text("bad", encoding="utf-8") with pytest.raises(ValueError, match="backup directory"): - _copy_example_flat(source, tmp_path / "workspace", force=True) + _copy_expert_flat(source, tmp_path / "workspace", force=True) assert not (tmp_path / "workspace" / ".agenticx").exists() @@ -1355,7 +1351,7 @@ def test_restore_preflights_all_manifest_paths_before_deleting( ) with pytest.raises(ValueError, match="escapes"): - _restore_example_backup(workspace) + _restore_expert_backup(workspace) assert valid.read_text() == "keep" assert private.read_text() == "private-content" @@ -1380,7 +1376,7 @@ def test_restore_rejects_symlinks_before_deleting_added_files( self._link(backup / "config.toml", private) with pytest.raises(ValueError, match="symbolic links"): - _restore_example_backup(workspace) + _restore_expert_backup(workspace) assert valid.read_text() == "keep" assert private.read_text() == "private-content" @@ -1389,12 +1385,12 @@ def test_restore_rejects_symlinks_before_deleting_added_files( class TestConstants: def test_default_repo_is_string(self): assert ( - _DEFAULT_EXAMPLES_REPO + _DEFAULT_EXPERTS_REPO == "https://github.com/dashscope/dashscope-agenticx-experts.git" ) def test_default_branch_is_string(self): - assert isinstance(_DEFAULT_EXAMPLES_BRANCH, str) + assert isinstance(_DEFAULT_EXPERT_BRANCH, str) def test_default_branch_is_main(self): - assert _DEFAULT_EXAMPLES_BRANCH == "main" + assert _DEFAULT_EXPERT_BRANCH == "main" diff --git a/tests/ut/test_packaging_contract.py b/tests/ut/test_packaging_contract.py index b842f98..702d025 100644 --- a/tests/ut/test_packaging_contract.py +++ b/tests/ut/test_packaging_contract.py @@ -101,7 +101,7 @@ def test_canonical_namespace_exposes_stable_sdk_contract(): import dashscope_agenticx from dashscope_agenticx import sdk - assert dashscope_agenticx.__version__ == "1.0.0" + assert dashscope_agenticx.__version__ == "1.0.1" assert sdk.RuntimeBuilder is not None assert sdk.AgentEventEnvelope is not None @@ -141,4 +141,4 @@ def test_offline_release_check_passes(): text=True, check=True, ) - assert "release contract ok: 1.0.0" in completed.stdout + assert "release contract ok: 1.0.1" in completed.stdout diff --git a/tests/ut/test_setup_finalize.py b/tests/ut/test_setup_finalize.py index 77f10e0..2dd8c30 100644 --- a/tests/ut/test_setup_finalize.py +++ b/tests/ut/test_setup_finalize.py @@ -10,7 +10,7 @@ from dashscope_agenticx.cli.handlers_setup import ( _ensure_provider_key, _handle_setup, - _setup_examples, + _setup_expert, _setup_finalize, _setup_preset_custom, ) @@ -108,7 +108,7 @@ async def test_setup_applies_template_to_owning_runtime( ) (package / "prompt.md").write_text("Template package instructions.") monkeypatch.setattr( - "dashscope_agenticx.cli.examples._get_examples_dir", + "dashscope_agenticx.cli.experts._get_experts_dir", lambda: tmp_path / "apps", ) monkeypatch.setattr("sys.stdin.isatty", lambda: True) @@ -361,7 +361,7 @@ def test_setup_summary_separates_pending_capabilities(monkeypatch, capsys): context = WizardContext( setup=True, pending_capabilities={"bailian.mcp"}, - results=[WizardResult("skipped", "examples")], + results=[WizardResult("skipped", "expert")], ) with platform_mod.bind_capability_tools({}): _setup_finalize(config, types.SimpleNamespace(), context=context) @@ -376,20 +376,20 @@ def test_setup_summary_separates_pending_capabilities(monkeypatch, capsys): @pytest.mark.parametrize("api_key", ["", "sk-default-secret"]) @pytest.mark.parametrize("choice", ["", "1"]) -@pytest.mark.parametrize("example", ["", "basic-chat"]) -async def test_default_setup_selects_examples_separately( +@pytest.mark.parametrize("expert", ["", "basic-chat"]) +async def test_default_setup_selects_expert_separately( monkeypatch, wizard_paths, capsys, api_key, choice, - example, + expert, ): from dashscope_agenticx.utils.toml import load_toml monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False) prompts = [] - answers = iter(["", choice, example]) + answers = iter(["", choice, expert]) def read_input(prompt): prompts.append(prompt) @@ -407,7 +407,7 @@ def download(args, **kwargs): return True monkeypatch.setattr( - "dashscope_agenticx.cli.examples._handle_example_command", + "dashscope_agenticx.cli.experts._handle_expert_command", download, ) monkeypatch.setattr("dashscope_agenticx.dev._hot_reload", lambda: None) @@ -436,11 +436,11 @@ def download(args, **kwargs): assert downloads == ( [ ( - ["download", example], + ["download", expert], {"show_usage": False, "preserve_configuration": True}, ), ] - if example + if expert else [] ) assert ( @@ -467,11 +467,8 @@ def download(args, **kwargs): ) assert "Provider: tongyi" in output and "Model: qwen3.8-flash" in output assert "Protocol: openai" in output and "API base URL:" in output - assert ( - "Experts are selected separately" in output - and "Examples: skip" not in output - ) - assert f"Expert: {example or 'skipped'}" in output + assert "Experts are selected separately" in output + assert f"Expert: {expert or 'skipped'}" in output assert ( "basic-chat" in output and "dashscope-sdk-expert" in output @@ -569,7 +566,7 @@ def spy_register(config, connect_mcp_fn=None): ("99", None), ], ) -def test_setup_examples_menu(monkeypatch, capsys, choice, expected): +def test_setup_expert_menu(monkeypatch, capsys, choice, expected): calls = [] answers = iter([choice, "0"]) monkeypatch.setattr("builtins.input", lambda prompt: next(answers)) @@ -579,10 +576,10 @@ def download(args, **kwargs): return True monkeypatch.setattr( - "dashscope_agenticx.cli.examples._handle_example_command", + "dashscope_agenticx.cli.experts._handle_expert_command", download, ) - result = _setup_examples() + result = _setup_expert() assert calls == ( [ ( @@ -602,7 +599,7 @@ def download(args, **kwargs): assert "agenticx expert" not in output -def test_setup_examples_enter_skips_without_download(monkeypatch, capsys): +def test_setup_expert_enter_skips_without_download(monkeypatch, capsys): from dashscope_agenticx.cli.wizard import WizardContext prompts = [] @@ -613,12 +610,12 @@ def read_input(prompt): monkeypatch.setattr("builtins.input", read_input) monkeypatch.setattr( - "dashscope_agenticx.cli.examples._handle_example_command", - lambda *args, **kwargs: pytest.fail("Default examples downloaded"), + "dashscope_agenticx.cli.experts._handle_expert_command", + lambda *args, **kwargs: pytest.fail("Default expert downloaded"), ) context = WizardContext(setup=True) - result = _setup_examples(context=context) - assert result.status == "skipped" and result.name == "examples" + result = _setup_expert(context=context) + assert result.status == "skipped" and result.name == "expert" assert context.results == [result] assert prompts == ["Expert [0]: "] assert "Experts (optional)" in capsys.readouterr().out @@ -662,8 +659,8 @@ def capabilities(cmd, cfg, **kwargs): ) monkeypatch.setattr( setup, - "_setup_examples", - lambda **kwargs: calls.append("examples"), + "_setup_expert", + lambda **kwargs: calls.append("expert"), ) monkeypatch.setattr( "dashscope_agenticx.dev._hot_reload", @@ -671,7 +668,7 @@ def capabilities(cmd, cfg, **kwargs): ) result = await _handle_setup(Config(), types.SimpleNamespace()) assert result.handled - assert calls == ["provider", "capabilities", "examples", "reload", "save"] + assert calls == ["provider", "capabilities", "expert", "reload", "save"] output = capsys.readouterr().out assert "China general" not in output and "[3]" not in output assert ( diff --git a/tests/ut/test_tts.py b/tests/ut/test_tts.py index a11c534..5544f4a 100644 --- a/tests/ut/test_tts.py +++ b/tests/ut/test_tts.py @@ -155,6 +155,52 @@ def test_set_speed_clamped(self, player): assert player.speech_rate == 1.25 +class TestTTSSDKClient: + def test_marks_sdk_client_before_synthesizer(self, monkeypatch, player): + events: list[str] = [] + + class FakeResultCallback: + pass + + class FakeAudioFormat: + WAV_16000HZ_MONO_16BIT = "wav" + + class FakeSpeechSynthesizer: + def __init__(self, **kwargs): + events.append("synthesizer") + self._callback = kwargs["callback"] + + def streaming_call(self, text): + self._callback.on_error("stop") + + def streaming_complete(self): + pass + + monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-test") + monkeypatch.setattr( + tts, + "mark_dashscope_sdk_client", + lambda: events.append("mark"), + ) + monkeypatch.setattr( + "dashscope.audio.tts_v2.AudioFormat", + FakeAudioFormat, + ) + monkeypatch.setattr( + "dashscope.audio.tts_v2.ResultCallback", + FakeResultCallback, + ) + monkeypatch.setattr( + "dashscope.audio.tts_v2.SpeechSynthesizer", + FakeSpeechSynthesizer, + ) + + error = player._synthesize_and_play("你好") + + assert error == "TTS synthesis failed: stop" + assert events == ["mark", "synthesizer"] + + class TestSpeakEarlyReturns: def test_missing_deps_returns_error(self, player, monkeypatch): monkeypatch.setattr(tts, "is_available", lambda: (False, "缺依赖")) diff --git a/tests/ut/test_tui_scroll.py b/tests/ut/test_tui_scroll.py index d00609e..a885ce6 100644 --- a/tests/ut/test_tui_scroll.py +++ b/tests/ut/test_tui_scroll.py @@ -1721,7 +1721,7 @@ async def test_banner_shows_recorded_identity( @pytest.mark.parametrize( "command", - ["/expert", "/experts", "/example", "/examples"], + ["/expert"], ) @pytest.mark.parametrize( "arguments", @@ -1733,7 +1733,7 @@ async def test_expert_commands_run_in_worker_and_capture_output( command, arguments, ): - import dashscope_agenticx.cli.examples as expert_handler + import dashscope_agenticx.cli.experts as expert_handler calls = [] worker_threads = [] @@ -1747,7 +1747,7 @@ def fake_handler(args): monkeypatch.setattr( expert_handler, - "_handle_example_command", + "_handle_expert_command", fake_handler, ) app = _make_app() diff --git a/tests/ut/test_ui_edge.py b/tests/ut/test_ui_edge.py index 87a0817..04352f1 100644 --- a/tests/ut/test_ui_edge.py +++ b/tests/ut/test_ui_edge.py @@ -78,6 +78,39 @@ async def fake_impl( assert seen["event"] is caller_event +class TestVoiceSDKClient: + def test_marks_sdk_client_before_recognition(self, monkeypatch): + events: list[str] = [] + + class FakeRecognitionCallback: + pass + + class FakeRecognition: + def __init__(self, **kwargs): + events.append("recognition") + + monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-test") + monkeypatch.setattr( + voice, + "mark_dashscope_sdk_client", + lambda: events.append("mark"), + ) + monkeypatch.setattr( + "dashscope.audio.asr.Recognition", + FakeRecognition, + ) + monkeypatch.setattr( + "dashscope.audio.asr.RecognitionCallback", + FakeRecognitionCallback, + ) + + asr = voice._RealtimeASR("sk-test", "paraformer-realtime-v2") + recognition = asr._make_recognition() + + assert isinstance(recognition, FakeRecognition) + assert events == ["mark", "recognition"] + + class TestVoiceDegradation: async def test_missing_deps_reports_error(self, monkeypatch): monkeypatch.setattr(voice, "is_available", lambda: (False, "缺依赖-x")) diff --git a/user-guide.md b/user-guide.md index dc3be6d..e80fbe1 100644 --- a/user-guide.md +++ b/user-guide.md @@ -3,7 +3,7 @@ [English](user-guide.md) | [中文](user-guide_zh.md) | [README](README.md) This document contains the detailed configuration, command, tool, and Runtime -reference moved out of the README. It describes DashScope AgenticX `1.0.0`. +reference moved out of the README. It describes DashScope AgenticX `1.0.1`. Start with the [quick start](README.md#quick-start), and review [security](SECURITY.md) and [migration](migration-to-1.0.md) before installing extensions. Historical @@ -63,7 +63,7 @@ kept; copied template files are also not rolled back. `dashscope/dashscope-agenticx-experts` GitHub repository by default. A downloaded profile that includes workspace configuration skips automatic `/setup` on subsequent startup. Use `/setup` to personalize that -profile if needed. `--repo` and the global `examples_repo` configuration +profile if needed. `--repo` and the global `expert_repo` configuration override the default download source. The welcome panel shows `Expert: `. Downloads record this identity in @@ -72,9 +72,7 @@ identity, or removes the record if there was none. Existing manually created or older profiles without this record show `Expert: (not recorded)`; AgenticX does not guess the role from their contents. -`/expert` and `agenticx expert` are the canonical commands. `/examples`, -`/example` and `/experts` (and their native-command equivalents) remain accepted -as hidden aliases; help and completion advertise only `/expert`. +`/expert` and `agenticx expert` list, download, and restore Expert profiles. ## Terminal Output @@ -256,7 +254,7 @@ Commands are organized into four tiers: Session → Configuration → Capability | `/summarize` | Manually summarize the current task (LLM extracts key steps and lessons) and store it in experience memory; also triggered automatically for long tasks (>=6 messages) | | `/history [subcmd]` | Conversation history management (stats for statistics / list [n] to list / search ` [limit]` full-text search / export ` [--format json\|md\|html]` to export / clear to clear) | | `/undo` | Undo the most recent file write/delete (write_file / delete_file snapshot automatically to `.agenticx/checkpoints/` before mutating) | -| `/session [subcmd]` | Session topic management (new [name] / list / switch `` / rename ` ` / remove `` / scene memory) | +| `/session [subcmd]` | Session topic management (new [name] / list / switch `` / rename ` ` / fork ` [src]` / remove `` / scene memory) | | `/feedback good\|bad` | Mark task satisfaction (stored in experience memory) | | `/report` | Generate and display a trace data performance report (LLM call count, tool success rate, average response time) | | `/log [subcmd]` | View the complete LLM prompts recorded in debug mode (requires `/debug on` first; data comes from the session trace): `tail [n]` the most recent n entries (default 3) / `search ` to search / `clear` to clear; paged through less in CLI mode, shown inline with truncation in the TUI | @@ -317,7 +315,7 @@ agenticx> /tts speed 0.9 # Set speech rate (0.5–2.0, 1.0 is nor | `/provider` | Q&A-style configuration wizard: Provider → model → optional connection settings → API Key | | `/model [name]` | Switch the model within the current provider; without an argument, shows the current model | | `/trust [subcmd]` | Tool trust/denial cache for the current conversation (automatically cleared at the end of each turn; asked again in the next turn) | -| `/rule [subcmd]` | User long-lived operation rules (list / add `""` / rm N / clear); injected into the system prompt **unconditionally** every turn | +| `/rule [subcmd]` | User long-lived operation rules (list / add `""` / rm N / clear); enabled rules are injected into the system prompt every turn | | `/theme [subcmd]` | Theme settings (list / set `` / custom colors); built-in dark / light / monokai / dracula / nord / solarized | | `/privacy [on\|off\|status]` | Privacy mode | | `/debug [on\|off\|status]` | Debug mode: when enabled, the complete prompt (sanitized) finally sent to the LLM each turn is written to the session trace `.agenticx/traces/.jsonl` as an `llm_call` event; view it with `/log` or `/trace` | @@ -394,6 +392,14 @@ agenticx --protocol anthropic # Use the Anthropic protocol directly at start agenticx --protocol openai # Use the OpenAI protocol directly at startup (default) ``` +Other main CLI options: + +```bash +agenticx -c "Summarize this project" # Run one prompt, then exit +agenticx --dry-run # Preview loaded configuration without starting +agenticx --max-turns 20 # Override the configured conversation turn limit +``` + **How protocol conversion works**: | Direction | Conversion content | @@ -412,7 +418,7 @@ Commands corresponding to capabilities that are not enabled are intercepted by t | `/mcp [subcmd]` | `bailian.mcp` | MCP services (add/remove/list); requires `/capability enable bailian.mcp` first | | `/skill` | — | List all available skills (single-file + packages, including MCP dependency status) | | `/skill [args]` | — | Invoke a preset Prompt | -| `/skill install `|git-url|tar.gz-url|name[@version]>` | — | Install a skill package (local path / git / tar.gz URL / registry name) | +| `/skill install ` | — | Install a skill package (local path / git / tar.gz URL / registry name) | | `/skill link ` | — | Symlink a local skill package (development mode) | | `/skill update ` | — | Update an installed skill package | | `/skill update --all` | — | Update all installed skill packages | @@ -427,11 +433,11 @@ Commands corresponding to capabilities that are not enabled are intercepted by t | `/app install ` | — | Install an App and display its permission diff | | `/app update ` | — | Update an App from its recorded source and recalculate permissions | | `/app verify ` | — | Verify App checksum and Runtime compatibility | -| `agenticx serve --token ` | — | Start the localhost-only Client API for Runs, Sessions, Artifacts, and Traces | +| `agenticx serve [--host ] [--port ] --token ` | — | Start the Client API for Runs, Sessions, Artifacts, and Traces (default: 127.0.0.1:8765) | | `/cron [subcmd]` | — | Scheduled tasks (add/list/remove/pause/resume), supporting intervals, specific times, and cron expressions | | `/privacy on\|off` | — | Privacy mode toggle (forces local storage, disables cloud capabilities and cron tasks) | | `/audit [subcmd]` | — | Audit log (recent/clear/query), records user input and tool decisions, automatically sanitized in privacy mode | -| `/directives [subcmd]` | — | Operation rule management (list/add/rm N/clear/proposals/accept ``/reject ``) | +| `/directives [subcmd]` | — | Custom directives (bare command lists; add/rm N/clear) and auto-learning proposals (proposals/accept ``/reject ``) | | `/feedback good\|bad` | — | Mark task satisfaction (stored in experience memory) | ### Development @@ -623,7 +629,7 @@ graph TD %% Developer Console %% ======================== subgraph Develop["Develope (universal developer console)"] - Ex["Examples (example generation)"] + Ex["Experts (Expert downloads)"] Conf["Config (configuration management)"] Dev["dev (extension wizard/debug/test)"] end @@ -680,7 +686,7 @@ src/dashscope_agenticx/ │ ├── completer.py # Context-aware completion + sanitized storage/retrieval of command history │ ├── multimodal.py # @file/directory reference expansion + image/audio multimodal content assembly │ ├── mcp.py # CLI-side MCP client management (connect/reconnect/tool injection) -│ ├── examples.py # /expert Expert management +│ ├── expert.py # /expert Expert management │ ├── constants.py # CLI constants and configuration data │ ├── startup.py # Startup initialization (provider checks, welcome message) │ ├── streaming.py # Streaming output rendering @@ -1038,7 +1044,7 @@ agenticx> /rule rm 2 | Dimension | `/profile` | `/rule` | |---|---|---| | Content | Identity/preferences/tech stack/project background (facts) | Operation rules, working habits, process constraints | -| Trigger | **Keyword recall**, injected only on relevant turns | **Unconditional**, injected every turn | +| Trigger | **Keyword recall**, injected only on relevant turns | **Enabled rules only**, injected every turn | | Storage | Local file (`.agenticx/memory/.json`) | Local workspace `.agenticx/rules.jsonl` | | When to use | "I'm a backend developer", "The project uses Python 3.11" | "Don't push after commit", "Update README when changing code" | | Not suitable for | Using it the other way around: operation rules placed in profile won't be recalled because keywords don't match | Using it the other way around: stuffing lots of identity facts wastes tokens | @@ -1050,10 +1056,10 @@ Simple rule of thumb: **use `/profile` for "who I am"; use `/rule` for "how thin AgenticX observes tool call sequences in the background; when the same adjacent tool pair appears more than 3 times, it automatically proposes solidifying it into a long-lived rule: ```bash -/directives proposals # View rule proposals pending review -/directives accept # Accept → written to /rule, takes effect immediately +/directives proposals # View directive proposals pending review +/directives accept # Accept → added to custom directives, takes effect immediately /directives reject # Reject -/directives list # View accepted directives +/directives # View custom directives ``` **How it works**: @@ -1886,8 +1892,8 @@ Customization path after downloading: `.agenticx/system-prompt.md` defines the r > Profiles are downloaded from `apps//` in `dashscope/dashscope-agenticx-experts` on GitHub. Only the 1.0 App Catalog layout is supported; top-level legacy Expert directories are not searched. The default branch is `main`. To use another source, override the platform-standard AgenticX global configuration or pass `--repo`: > ```toml -> examples_repo = "https://github.com/dashscope/dashscope-agenticx-experts.git" -> examples_branch = "main" # Optional, defaults to main +> expert_repo = "https://github.com/dashscope/dashscope-agenticx-experts.git" +> expert_branch = "main" # Optional, defaults to main > ``` #### basic-chat — General-purpose chat Agent (minimal viable configuration) @@ -1927,7 +1933,7 @@ agenticx --tui # Full-screen TUI agenticx -c "What's the weather in Shanghai today?" # one-shot (will invoke web_search) ``` -See the README of the basic-chat example for details (after `agenticx expert download basic-chat`, see `.agenticx/README.md`). +See the README of the basic-chat Expert for details (after `agenticx expert download basic-chat`, see `.agenticx/README.md`). #### dashscope-sdk-expert — DashScope SDK technical Q&A @@ -1964,7 +1970,23 @@ apps/dashscope-sdk-expert/ └── translate.md # Chinese-English translation ``` -**Installation and download:** +**Install from the App Catalog:** + +```bash +python -m pip install "dashscope-agenticx>=1.0.1,<2" +git clone https://github.com/dashscope/dashscope-agenticx-experts.git +cd dashscope-agenticx-experts +agenticx app install ./apps/dashscope-sdk-expert +agenticx app info dashscope-sdk-expert +agenticx app verify dashscope-sdk-expert +``` + +`app install` installs and registers a managed, versioned App under the current +workspace's `.agenticx/apps/`, but does not activate its profile. After the `cd` +above, the cloned repository is that workspace. `app info` shows the publisher, +permissions, Runtime compatibility range, and checksum; `app verify` checks the +installed content checksum and Runtime compatibility. To actually run the Expert, +merge its profile into the workspace and start AgenticX: ```bash pip install dashscope-agenticx @@ -1977,11 +1999,15 @@ agenticx --tui # Full-screen TUI agenticx -c "How do I use Generation.call?" # one-shot ``` +Expert downloads are not registered as Apps and cannot be managed with `app +list` or `app update`. `app update` reinstalls from the source recorded during +installation; run `git pull` in the clone before updating a locally cloned App. + View available Expert templates: `agenticx expert` **Reuse pattern:** use `agenticx expert download ` to initialize your project workspace, edit `.agenticx/system-prompt.md` to define your Agent's role, replace the skill templates under `.agenticx/skills/`, and use `.agenticx/build_sdk_index.py` as a starting point to build your own domain knowledge index (placed in `.agenticx/references/`). All the intelligence lives in the `.agenticx/` configuration; just run `agenticx` to start. -Each example is a pure configuration directory: `.agenticx/` configuration + README documentation, with no Python bootstrap code whatsoever. +Each Expert is a pure configuration directory: `.agenticx/` configuration + README documentation, with no Python bootstrap code whatsoever. --- diff --git a/user-guide_zh.md b/user-guide_zh.md index 1b1d94d..1f35511 100644 --- a/user-guide_zh.md +++ b/user-guide_zh.md @@ -3,7 +3,7 @@ [English](user-guide.md) | [中文](user-guide_zh.md) | [README](README_zh.md) 本手册承接原 README 的详细配置、命令、工具与 Runtime 参考,面向 -DashScope AgenticX `1.0.0`。首次使用请先阅读 +DashScope AgenticX `1.0.1`。首次使用请先阅读 [快速开始](README_zh.md#快速开始),安装扩展前检查[安全策略](SECURITY.md) 和[迁移指南](migration-to-1.0.md)。版本更新记录统一保留在 [CHANGELOG.md](CHANGELOG.md),不放入使用手册。 @@ -49,15 +49,14 @@ Provider/模型和 API Key 时跳过自动初始化;随时可运行 `/setup` `agenticx expert download basic-chat` 默认从公开 GitHub 仓库 `dashscope/dashscope-agenticx-experts` 下载。如果下载的 profile 自带 workspace 配置,下一次启动不会再次自动进入 `/setup`;需要时手动运行 `/setup` -修改人设和配置。`--repo` 和全局 `examples_repo` 配置可覆盖默认下载来源。 +修改人设和配置。`--repo` 和全局 `expert_repo` 配置可覆盖默认下载来源。 欢迎页显示 `Expert: <名称>`。下载时将身份记录到 `.agenticx/expert.toml`; 恢复合并备份时也会还原之前的身份,之前没有记录则移除新增记录。 手工创建或旧版下载的 profile 若没有这份记录,显示 `Expert: (not recorded)`, 不会根据配置内容猜测 Expert 名称。 -统一入口为 `/expert` 和 `agenticx expert`。`/examples`、`/example`、`/experts` -及对应终端命令仍可用作隐藏别名,帮助和一级补全只展示 `/expert`。 +统一入口为 `/expert` 和 `agenticx expert`,用于列出、下载和恢复 Expert Profile。 ## 终端输出 @@ -173,7 +172,7 @@ auth = false # 无需 API Key(本地部署) # protocol = "openai_compatible" # 等价于 openai ``` -`workspace` 覆盖全局同名 `name` 条目;最小模板见 basic-chat 示例(`agenticx expert download basic-chat` 后查看 `.agenticx/custom-extensions.toml`)。 +`workspace` 覆盖全局同名 `name` 条目;最小模板见 basic-chat Expert(`agenticx expert download basic-chat` 后查看 `.agenticx/custom-extensions.toml`)。 **base_url 的配置入口与解析优先级** @@ -239,7 +238,7 @@ API Key [Enter to skip]: | `/summarize` | 手动总结当前任务(LLM 提取关键步骤与教训),存入经验记忆;长任务(>=6 条消息)也会自动触发 | | `/history [subcmd]` | 对话历史管理(stats 统计 / list [n] 列出 / search `<关键词> [limit]` 全文检索 / export ` [--format json\|md\|html]` 导出 / clear 清空) | | `/undo` | 撤销最近一次文件写入/删除(write_file / delete_file 改动前自动快照到 `.agenticx/checkpoints/`) | -| `/session [subcmd]` | 会话主题管理(new [name] / list / switch `` / rename ` ` / remove `` / scene 场景记忆) | +| `/session [subcmd]` | 会话主题管理(new [name] / list / switch `` / rename ` ` / fork ` [src]` / remove `` / scene 场景记忆) | | `/feedback good\|bad` | 标注任务满意度(存入经验记忆) | | `/report` | 生成并展示 trace 数据性能报告(LLM 调用次数、工具成功率、平均响应时间) | | `/log [subcmd]` | 查看调试模式记录的完整 LLM prompt(需先 `/debug on`,数据来自会话 trace):`tail [n]` 最近 n 条(默认 3)/ `search <关键词>` 搜索 / `clear` 清空;CLI 模式经 less 分页,TUI 内联截断显示 | @@ -300,7 +299,7 @@ agenticx> /tts speed 0.9 # 设置语速(0.5–2.0,1.0 为正 | `/provider` | 问答式配置向导:Provider → 模型 → 可选连接设置 → API Key | | `/model [name]` | 切换当前 Provider 下的模型;不带参数显示当前模型 | | `/trust [subcmd]` | 本次对话工具授信/拒绝缓存(每轮结束自动清空,下一轮重新问) | -| `/rule [subcmd]` | 用户长效操作规则(list / add `""` / rm N / clear);每轮**无条件**注入 system prompt | +| `/rule [subcmd]` | 用户长效操作规则(list / add `""` / rm N / clear);每轮将**已启用**的规则注入 system prompt | | `/theme [subcmd]` | 主题设置(list / set `` / 自定义颜色);内置 dark / light / monokai / dracula / nord / solarized | | `/privacy [on\|off\|status]` | 隐私模式 | | `/debug [on\|off\|status]` | 调试模式:开启后每轮最终发送给 LLM 的完整 prompt(脱敏后)随 `llm_call` 事件写入会话 trace `.agenticx/traces/.jsonl`,用 `/log` 或 `/trace` 查看 | @@ -370,6 +369,14 @@ agenticx --protocol anthropic # 启动时直接使用 Anthropic 协议 agenticx --protocol openai # 启动时直接使用 OpenAI 协议(默认) ``` +其他主要 CLI 参数: + +```bash +agenticx -c "概述当前项目" # 执行一次请求后退出 +agenticx --dry-run # 预览已加载配置,不启动 Agent +agenticx --max-turns 20 # 覆盖配置中的对话轮次上限 +``` + **协议转换原理**: | 方向 | 转换内容 | @@ -388,7 +395,7 @@ agenticx --protocol openai # 启动时直接使用 OpenAI 协议(默认 | `/mcp [subcmd]` | `bailian.mcp` | MCP 服务 (add/remove/list);需先 `/capability enable bailian.mcp` | | `/skill` | — | 列出所有可用 skill(单文件 + 包,含 MCP 依赖状态) | | `/skill [args]` | — | 调用预置 Prompt | -| `/skill install `|git-url|tar.gz-url|name[@version]>` | — | 安装 skill 包(本地路径 / git / tar.gz URL / registry 名称) | +| `/skill install ` | — | 安装 skill 包(本地路径 / git / tar.gz URL / registry 名称) | | `/skill link ` | — | 软链接本地 skill 包(开发模式) | | `/skill update ` | — | 更新已安装的 skill 包 | | `/skill update --all` | — | 更新所有已安装的 skill 包 | @@ -403,11 +410,11 @@ agenticx --protocol openai # 启动时直接使用 OpenAI 协议(默认 | `/app install ` | — | 安装 App,并显示权限差异 | | `/app update ` | — | 从已记录来源更新 App,重新计算权限差异 | | `/app verify ` | — | 验证 App 校验和和 Runtime 兼容性 | -| `agenticx serve --token ` | — | 启动仅监听 localhost 的 Client API(Run / Session / Artifact / Trace) | +| `agenticx serve [--host ] [--port ] --token ` | — | 启动 Client API(Run / Session / Artifact / Trace),默认监听 127.0.0.1:8765 | | `/cron [subcmd]` | — | 定时任务 (add/list/remove/pause/resume),支持间隔、指定时间、cron 表达式 | | `/privacy on\|off` | — | 隐私模式开关(强制本地存储,禁用云端能力与 cron 任务) | | `/audit [subcmd]` | — | 审计日志 (recent/clear/query),记录用户输入与工具决策,隐私模式下自动脱敏 | -| `/directives [subcmd]` | — | 操作规则管理 (list/add/rm N/clear/proposals/accept ``/reject ``) | +| `/directives [subcmd]` | — | 自定义指令(裸命令列出;add/rm N/clear)与自动学习提议(proposals/accept ``/reject ``) | | `/feedback good\|bad` | — | 标注任务满意度(存入经验记忆) | ### 开发 @@ -609,7 +616,7 @@ graph TD %% Developer Console %% ======================== subgraph Develop["Develope (普世化 开发控制台)"] - Ex["Examples (示例生成)"] + Ex["Experts (Expert 下载)"] Conf["Config (配置管理)"] Dev["dev (扩展向导/调试/测试)"] end @@ -666,7 +673,7 @@ src/dashscope_agenticx/ │ ├── completer.py # 上下文感知补全 + 命令历史脱敏存取 │ ├── multimodal.py # @文件/目录引用展开 + 图片/音频多模态内容组装 │ ├── mcp.py # CLI 侧 MCP 客户端管理(连接/重连/工具注入) -│ ├── examples.py # /expert Expert 管理 +│ ├── expert.py # /expert Expert 管理 │ ├── constants.py # CLI 常量与配置数据 │ ├── startup.py # 启动初始化(provider 检查、欢迎语) │ ├── streaming.py # 流式输出渲染 @@ -1025,7 +1032,7 @@ agenticx> /rule rm 2 | 维度 | `/profile` | `/rule` | |---|---|---| | 内容 | 身份/偏好/技术栈/项目背景(事实) | 操作规则、做事习惯、流程约束 | -| 触发 | **关键词召回**,仅相关 turn 注入 | **无条件**,每轮都注入 | +| 触发 | **关键词召回**,仅相关 turn 注入 | **仅已启用规则**,每轮都注入 | | 存储 | 本地文件(`.agenticx/memory/.json`) | 本地 workspace `.agenticx/rules.jsonl` | | 何时用 | "我是后端开发"、"项目用 Python 3.11" | "commit 后别 push"、"改代码改 README" | | 不适合 | 反过来用:操作规则放 profile 会因为关键词不匹配而召不回 | 反过来用:放大量身份事实会浪费 token | @@ -1037,10 +1044,10 @@ agenticx> /rule rm 2 AgenticX 在后台观察工具调用序列,当同一相邻工具对出现 3 次以上时,自动提议将其固化为长效规则: ```bash -/directives proposals # 查看待审规则提议 -/directives accept # 接受 → 写入 /rule,立即生效 +/directives proposals # 查看待审指令提议 +/directives accept # 接受 → 加入自定义指令,立即生效 /directives reject # 拒绝 -/directives list # 查看已接受的指令 +/directives # 查看自定义指令 ``` **工作原理**: @@ -1873,8 +1880,8 @@ agenticx # 启动 —— 已是 Expert 定义的 > Profile 默认从 GitHub 的 `dashscope/dashscope-agenticx-experts` 仓库中 `apps//` 下载,只支持 1.0 App Catalog 布局,不再查找顶层旧Expert目录,默认分支为 `main`。如需其他来源,可覆盖平台标准位置的 AgenticX 全局配置,或传入 `--repo`: > ```toml -> examples_repo = "https://github.com/dashscope/dashscope-agenticx-experts.git" -> examples_branch = "main" # 可选,默认 main +> expert_repo = "https://github.com/dashscope/dashscope-agenticx-experts.git" +> expert_branch = "main" # 可选,默认 main > ``` #### basic-chat — 通用聊天 Agent(最小可用配置) @@ -1951,7 +1958,22 @@ apps/dashscope-sdk-expert/ └── translate.md # 中英互译 ``` -**安装与下载:** +**通过 App Catalog 安装:** + +```bash +python -m pip install "dashscope-agenticx>=1.0.1,<2" +git clone https://github.com/dashscope/dashscope-agenticx-experts.git +cd dashscope-agenticx-experts +agenticx app install ./apps/dashscope-sdk-expert +agenticx app info dashscope-sdk-expert +agenticx app verify dashscope-sdk-expert +``` + +`app install` 将受管理的版本化 App 安装并登记到当前工作区的 +`.agenticx/apps/`,但不会启用其 Profile。上述 `cd` 之后,克隆仓库就是当前 +工作区。`app info` 查看发布者、权限、Runtime 兼容范围与校验和,`app verify` +检查已安装内容的校验和及 Runtime 兼容性。如需实际运行 Expert,将 Profile +合并到工作区后启动: ```bash pip install dashscope-agenticx @@ -1964,6 +1986,10 @@ agenticx --tui # 全屏 TUI agenticx -c "Generation.call 怎么用?" # one-shot ``` +Expert 下载不会登记为 App,不能用 `app list` 或 `app update` 管理。 +`app update` 从安装时记录的来源重新安装;更新本地克隆的 App 前,需先在克隆 +目录执行 `git pull`。 + 查看可用 Expert 模板:`agenticx expert` **复用模式:** 用 `agenticx expert download ` 初始化你的项目 workspace,修改 `.agenticx/system-prompt.md` 定义你的 Agent 角色,替换 `.agenticx/skills/` 下的技能模板,用 `.agenticx/build_sdk_index.py` 作为起点构建你自己的领域知识索引(放入 `.agenticx/references/`)。所有智能都在 `.agenticx/` 配置中,直接运行 `agenticx` 启动。