diff --git a/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.i18n.yaml b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.i18n.yaml new file mode 100644 index 0000000000..d07841d540 --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.md +2026-08-08-dsh-run-headless-command.md: ce9cff965192357022c49655983fe6ff8d554b9f +2026-08-08-dsh-run-headless-command.zh.md: 0484c069ed6365235e4616542a4fb3d5ceb2d880 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.md similarity index 84% rename from .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md rename to .agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.md index ed095f4077..ce9cff9651 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md +++ b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.md @@ -1,9 +1,12 @@ # Agent Note: `dsh run` owns one-shot headless execution Status: implemented +Archived: 2026-08-10 English | [中文](2026-08-08-dsh-run-headless-command.zh.md) +> **Superseded command grammar.** [Apps now own their command lines](../architecture/2026-08-06-app-owned-command-line.md): the headless startup row parses the task from `dsh --profile headless `, and the launcher no longer has a `run` invocation or patches task text into rows. This note remains the rejected launcher-owned design context; the direct execution and completion contract it selected remains current in [headless is a direct core entry point](../architecture/2026-08-09-headless-direct-core-entry-point.md). + ## Problem Generic profile boot and one-shot task execution have different lifecycle contracts. A root grammar that accepts optional task text makes one argv shape mean either a long-lived process or a terminating task according to a plugin row discovered only after composition. It also exposes a profile implementation detail as the primary user command and gives custom profiles no explicit one-shot entry. diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.zh.md similarity index 85% rename from .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md rename to .agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.zh.md index 89d54e3557..0484c069ed 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md +++ b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.zh.md @@ -1,9 +1,12 @@ # Agent Note: `dsh run` 负责一次性 headless 执行 Status: implemented +Archived: 2026-08-10 [English](2026-08-08-dsh-run-headless-command.md) | 中文 +> **命令语法已被取代。** [应用现在持有自己的命令行](../architecture/2026-08-06-app-owned-command-line.md):headless 启动行从 `dsh --profile headless ` 解析任务,启动器不再包含 `run` 调用,也不再把任务文本 patch 进配置行。本笔记保留被否决的启动器持有设计背景;它选定的直接执行与完成约定仍由 [headless 是直接 core 入口](../architecture/2026-08-09-headless-direct-core-entry-point.md)持有。 + ## 问题 通用 profile 启动与一次性任务执行具有不同的生命周期约定。若根语法接受可选任务文本,同一种 argv 形态会表示常驻进程或终止式任务,具体含义取决于组合完成后才发现的插件配置行。它还会把 profile 实现细节暴露成主要用户命令,并使自定义 profile 缺少明确的一次性入口。 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 638d87373d..d3d1795489 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -253,6 +253,9 @@ "feature/2026-07-31-web-cards-toolrow.i18n.yaml": "sha256:f9a6ab72a77934cdcc02167c7313f08d7e9925362017b34bed7ad56c8c70fbaa", "feature/2026-07-31-web-cards-toolrow.md": "sha256:5058f7cec4497d1cb0a5c8e77b88fddacac6eead034f3edec88e8514919b8a3e", "feature/2026-07-31-web-cards-toolrow.zh.md": "sha256:ba84ef2e1be61211ab5ba6950b78ede3d3a979f252bc068d3e04e2c025f7bc03", + "feature/2026-08-08-dsh-run-headless-command.i18n.yaml": "sha256:1c2b4c5b61b9263b6267275d6fc69faeaad3cc887f0728a7ed4172d817af812b", + "feature/2026-08-08-dsh-run-headless-command.md": "sha256:7695fe7fd322377d5986f14e35f13337f4cd376405c758218a81230f6d182d1c", + "feature/2026-08-08-dsh-run-headless-command.zh.md": "sha256:113c14a36c64d2facc8ae46f37c7aa76359d8cacb9c18fcba26a723f15d036fb", "process/2026-06-11-doc-sync-enforcement.i18n.yaml": "sha256:33b6d5874427bd7a2bd82e7e2f4f482b12448b2464aef15a9c57975edb48554d", "process/2026-06-11-doc-sync-enforcement.md": "sha256:aa2fe83d519fc30d48dff19e596e83c8922aacc9e063e14fe2cc35b769b9100e", "process/2026-06-11-doc-sync-enforcement.zh.md": "sha256:698017bd35f030fdea3eac51df9e43138c48140f504739d687b7251d13fced2b", diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 8bef07d042..a48de33ce2 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: a45678c9bb5fcae340ff7134687890879f56c630 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: f1fccc508471356dd6434da0e126ed38f15ed3ba +2026-07-10-single-file-executable-sdk-runtime-distribution.md: c2b6d9ff1825915e39738bf8f782c302ecfc1d0d +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: d7758a77083e07b1d2cac99ae2be3f15e6edd2dc diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index a45678c9bb..c2b6d9ff18 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -34,27 +34,27 @@ Config discovery has two channels and fails loudly when both are missing: the `D ### Plugin resolution: the VFS holds a real package tree, the closure manifest IS the deploy root -Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`); the Loader resolves plugin names through standard dynamic `import()`: bare specifiers resolve upward along `node_modules` from the Loader's position inside the VFS, and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. +Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`). The packaged JSON-RPC entry supplies its installed harness base to app-boot's root Include: relative plugin specifiers resolve from the external configuration directory, while bare package names resolve from the VFS, so a configuration inside another Node project cannot shadow the packaged plugin set. The ordinary development bin leaves bare packages configuration-owned. Bare specifiers in the packaged entry resolve upward along `node_modules` from the entry's position inside the VFS and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) traverses every workspace package covered by that manifest and requires every non-optional workspace peer at the runtime root, reporting the complete referencing-package → missing-peer chain; `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source, so the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory; macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink file tree (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source, so the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory; macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached; macOS ad-hoc signing is handled by pkg. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects GLIBC requirements and runs in a manylinux 2.28 container. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts only `python-vX.Y.Z` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. ### Python SDK distribution: two carriers, exe for production, node for development -The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe and optional helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. +The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe and optional helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative stable `X.Y.Z` from the repository root `package.json` and stages both packages at that version, with the SDK depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. An optional `python-vX.Y.Z` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, and the macOS wheel also contains its architecture-matched helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra helpers, and unsupported platforms. +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative stable `X.Y.Z` from the repository root `package.json` and stages both packages at that version, with `deepseek-harness-sdk` depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. An optional `python-vX.Y.Z` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, and the macOS wheel also contains its architecture-matched helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra helpers, and unsupported platforms. The exe's "must be explicitly configured" hard semantic is unchanged; the zero-config experience is restored by the wrapper: when the caller gave no `cordis`, named no explicit runtime, and the environment has no `DSH_CORDIS_CONFIG`, the client explicitly injects the checked-in default `cordis.yml` (agent-core + preloaded llm-deepseek + JSONL persistence + bash-local + the `dsh-jsonrpc` serving entry, with `!!js` environment-variable fallbacks) via `DSH_CORDIS_CONFIG`. ### Naming lineage -`@deepseek-ai/dsh-jsonrpc-demo` (the package) → `dsh-jsonrpc-agent` (the bin) → `dsh-jsonrpc-agent-pkg` (the closure manifest; no scope prefix, deliberately sidestepping the constraints' package-shape rules for `@deepseek-ai/dsh-*`) → `dsh-jsonrpc-agent-pkg--` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python dist names are `deepseek-harness` / `deepseek-harness-runtime-bin`. +`@deepseek-ai/dsh-jsonrpc-demo` (the package) → `dsh-jsonrpc-agent` (the bin) → `dsh-jsonrpc-agent-pkg` (the closure manifest; no scope prefix, deliberately sidestepping the constraints' package-shape rules for `@deepseek-ai/dsh-*`) → `dsh-jsonrpc-agent-pkg--` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python distribution names are `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`, while the import modules remain `deepseek_harness` / `deepseek_harness_runtime`. ## Disposition of worker-style plugins @@ -62,7 +62,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c ## Testing -The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, and the direct binary protocol, with final text and JSONL checked. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message IDs in the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. +The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message IDs in the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index f1fccc5084..d7758a7708 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -34,27 +34,27 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 ### 插件解析:VFS 装载真实包树,闭包 manifest(元数据清单)就是部署根目录 -exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。loader 通过标准动态 `import()` 解析插件名:裸包名从 VFS 内 loader 所在位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 +exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。打包专用 JSON-RPC 入口会向 app-boot 的根 Include 提供自身已安装 harness 的基准位置:相对插件说明符从外部配置目录解析,裸包名则从 VFS 解析,因此位于另一个 Node 项目内的配置无法遮蔽已打包的插件集合。普通开发 bin 仍由配置项目提供裸包。打包入口中的裸包名从该入口在 VFS 内的位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列在运行时根目录,并报告“引用包 → 缺失对等依赖”的完整链路;`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 ### 构建管线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`,而 `--legacy` 部署会省略该副作用目录,因此构建器会把它从根安装目录复制到暂存闭包;macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 产出无符号链接的文件树(对 pkg VFS 最稳定,并从物理上保证只有一个 Cordis 实例);关闭对等依赖自动安装可避免未发布包名触发注册表解析;`link-workspace-packages` 让闭包指向工作区/vendor 源码。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`,而 `--legacy` 部署会省略该副作用目录,因此构建器会把它从根安装目录复制到暂存闭包;macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),且只允许显式触发:手动派发 `workflow_dispatch`,或给 PR(Pull Request)添加 `build-exe` 标签。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查 GLIBC 依赖,并在 manylinux 2.28 容器中运行。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-vX.Y.Z` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 -Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 与可选 helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 +Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 与可选 helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的稳定版本 `X.Y.Z`,以该版本暂存两个包,并让 SDK 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64` 三种标签之一;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。 +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的稳定版本 `X.Y.Z`,以该版本暂存两个包,并让 `deepseek-harness-sdk` 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64` 三种标签之一;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。 exe「必须显式配置」的硬语义不变;零配置体验由包装层恢复:调用方没有提供 `cordis`、没有显式指定运行时,且环境中没有 `DSH_CORDIS_CONFIG` 时,客户端将检入的默认 `cordis.yml`(`agent-core` + 预载的 `llm-deepseek` + JSONL 持久化 + `bash-local` + `dsh-jsonrpc` 对外服务条目,并通过 `!!js` 使用环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 ### 命名血统 -`@deepseek-ai/dsh-jsonrpc-demo`(包)→ `dsh-jsonrpc-agent`(`bin`)→ `dsh-jsonrpc-agent-pkg`(闭包 manifest;没有作用域前缀,刻意避开 `constraints` 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg--`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发名为 `deepseek-harness` / `deepseek-harness-runtime-bin`。 +`@deepseek-ai/dsh-jsonrpc-demo`(包)→ `dsh-jsonrpc-agent`(`bin`)→ `dsh-jsonrpc-agent-pkg`(闭包 manifest;没有作用域前缀,刻意避开 `constraints` 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg--`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发包名为 `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`,导入模块名仍为 `deepseek_harness` / `deepseek_harness_runtime`。 ## 工作线程插件 @@ -62,7 +62,7 @@ exe 内支持 `dsh-workflow-workerthread` 与 `dsh-code-runtime-worker`。两个 ## 测试 -验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化以下各处的不透明消息 ID:SDK 结果与通知流,以及父会话和两个子会话的 JSONL 日志。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 +验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化以下各处的不透明消息 ID:SDK 结果与通知流,以及父会话和两个子会话的 JSONL 日志。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,短命管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index bc2d26325d..3d370c1e37 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: 514deb890d4e08d465db869669078473d32fb215 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: f6fa71e3dac25f48b2ad4744a0cc695417528b34 +2026-07-19-gui-layering-and-rpc-protocol.md: da96ae97f2a2d64aeef7794bd82ccbd86602b1ad +2026-07-19-gui-layering-and-rpc-protocol.zh.md: 36dc7391bc3f9bb0d5105fea14a2763d0b7159a1 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index 514deb890d..da96ae97f2 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -10,7 +10,7 @@ English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md) We need a UI integration layer. Beyond the existing ACP/stdio baseline, more product clients are coming — Web (server), Electron, and others. We call them Clients and want the following capabilities: -- One `dsh` process supporting both `dsh web` (serve) and `dsh run` (headless) — one process, two modes (a design reservation) +- One `dsh` process supporting both `dsh web` (serve) and `dsh --profile headless` (headless) — one process, two modes (a design reservation) - Launching inside Electron with the same Web technologies as `dsh web` That demands a stable layered responsibility model in the engineering codebase, so future clients plug in cleanly. @@ -31,7 +31,7 @@ Directories layer as follows: - **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dsh.client` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services. - `apps/` holds the externally exported applications, assembled from Client / Host mixtures. - `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`. - - `apps/cli` (`@deepseek-ai/dsh`) dispatches commands: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh run` = [a direct core Agent/Session entry point](2026-08-09-headless-direct-core-entry-point.md), with zero Host, HTTP, or browser layer. + - `apps/cli` (`@deepseek-ai/dsh`) dispatches commands: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh --profile headless` = [a direct core Agent/Session entry point](2026-08-09-headless-direct-core-entry-point.md), with zero Host, HTTP, or browser layer. - A future Electron application reuses the same web client packages over an IPC fetch carrier. ``` @@ -79,7 +79,7 @@ Packages under `packages/host/*` and `packages/client/*` **must carry the direct 2. **Write an assembly module under `apps/`**: `startHost()` + a client subclass + the application's private signal/print/exit semantics; a mixture never becomes a package — assembly is written in the app. 3. **Import `dsh-host-webserver` only if you need HTTP carriage**, otherwise zero ports. -The two existing applications preserve the division: the Web application mounts Host, carrier, and browser composition, while `dsh run` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem and mount directly via `ctx.plugin(entry-point plugin)` without fetch. +The two existing applications preserve the division: the Web application mounts Host, carrier, and browser composition, while `dsh --profile headless` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem and mount directly via `ctx.plugin(entry-point plugin)` without fetch. ## Message protocol @@ -215,7 +215,7 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation | Subclass | Package | doFetch | Purpose | |---|---|---|---| -| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing; carrier tests and callers can exercise the protocol without opening a port, while product `dsh run` drives core directly | +| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing; carrier tests and callers can exercise the protocol without opening a port, while product `dsh --profile headless` drives core directly | | `WebApiClient` | dsh-client-connection | `globalThis.fetch` uplink + one same-origin WebSocket downlink per logical stream | the browser client; physical boundary in the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | unused (protocol-layer override) | serverless UI development (`?fixture`): overrides the `callUnary`/`openMux`/`openHost`/`respond` virtuals and is itself the fake server (frame rpcIds minted by it, semantics self-consistent) | | IPC bridge subclass (hypothetical example — no such shell exists) | an Electron shell | IPC serialization round trip | would swap only doFetch; contract and base class unchanged | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index f6fa71e3da..36dc7391bc 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -9,7 +9,7 @@ Status: implemented ## Problem 需要提供 UI 对接层,除已有 ACP(Agent Client Protocol)/stdio 基线外,还需要 Web(server)、Electron 等其他产品客户端。我们把它们统一称为 Client。希望具备以下能力: -- 一个 `dsh` 进程同时支持 `dsh web`(启动)和 `dsh run`(headless),一个进程两种模式(设计预留) +- 一个 `dsh` 进程同时支持 `dsh web`(启动)和 `dsh --profile headless`(headless),一个进程两种模式(设计预留) - 在 Electron 中使用与 `dsh web` 相同的 Web 技术启动 那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client。 @@ -29,7 +29,7 @@ Status: implemented - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dsh.client` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 - `apps/` 作为对外导出的应用入口,可以由 Client / Host 混合组装。 - `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。 - - `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。 + - `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh --profile headless` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。 - 将来的 Electron 应用经由 IPC fetch 载体复用同一套 web client 包。 ``` @@ -77,7 +77,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig. 2. **在 `apps/` 下写拼装模块**:`startHost()` + 客户端子类 + 该应用私有的信号/打印/退出语义;混合体不建包,拼装写在 app 里。 3. **需要 HTTP 承载才 import `dsh-host-webserver`**,否则零端口。 -现有两个应用保持这一区分:Web 应用挂载 Host、载体与浏览器组合,而 `dsh run` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(入口插件)` 挂载,不使用 fetch。 +现有两个应用保持这一区分:Web 应用挂载 Host、载体与浏览器组合,而 `dsh --profile headless` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(入口插件)` 挂载,不使用 fetch。 ## 消息协议 @@ -213,7 +213,7 @@ export type ResponseValue = | 子类 | 所在包 | doFetch | 用途 | |---|---|---|---| -| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧;载体测试与调用方可以在不打开端口的情况下运行这套协议,而产品 `dsh run` 直接驱动 core | +| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧;载体测试与调用方可以在不打开端口的情况下运行这套协议,而产品 `dsh --profile headless` 直接驱动 core | | `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器客户端;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) | | IPC 桥子类(假想示例——尚无此形态) | Electron 壳 | IPC 序列化往返 | 只需换 doFetch,约定/基类零改 | diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml index 8f17ecd480..f4fb753e79 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md -2026-07-29-dsh-source-launch-tsx-esm.md: ed22e51d59a25db130b3760ce484c116bade4348 -2026-07-29-dsh-source-launch-tsx-esm.zh.md: bdd549092eb30f7749c8f7561068daafe3548b28 +2026-07-29-dsh-source-launch-tsx-esm.md: 5cf4a227f388a1ac8315594af4e0256864ef17f5 +2026-07-29-dsh-source-launch-tsx-esm.zh.md: b5a52b3d01840337c0091310e50d8fac34245519 diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md index ed22e51d59..5cf4a227f3 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md @@ -35,4 +35,4 @@ The node-compat CI matrix (Node 22.19 and 26) gains `dsh-source-launch-smoke` (` - One launch vector across the whole engines range, including future Node lines that change native TypeScript support; the smoke gate enforces it per matrix line. - TypeScript transformation is delegated to tsx/esbuild again, reversing the prior note's goal of proving Node-native transformation; that goal is unreachable while vendored sources use non-erasable syntax and Node ships no transform mode. - The runtime declared-dependency enforcement in source launches is gone; undeclared workspace imports now surface only through static gates or built-mode resolution failures. -- Startup improves ~0.4s over the full tsx default (`demo:headless` now aliases the same `dsh run` source launch; ACP keeps `--import tsx` because its graph was not audited for CJS-hook dependence and its launch latency is not on the interactive path). +- Startup improves ~0.4s over the full tsx default (`demo:headless` now aliases the same `dsh --profile headless` source launch; ACP keeps `--import tsx` because its graph was not audited for CJS-hook dependence and its launch latency is not on the interactive path). diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md index bdd549092e..b5a52b3d01 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md @@ -35,4 +35,4 @@ node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(` - 整个 engines 范围(包括未来改变原生 TypeScript 支持的 Node 版本线)只有一个启动向量;冒烟门禁按矩阵行强制执行。 - TypeScript 转换重新委托给 tsx/esbuild,逆转了前一篇 Agent Note「证明 Node 原生转换可用」的目标;在 vendor 源码使用不可擦除语法且 Node 不再提供 transform 模式的情况下,该目标不可达。 - 源码启动中的运行时依赖声明强制不复存在;未声明的 workspace import 现在只能通过静态门禁或构建模式的解析失败暴露。 -- 启动相比完整 tsx 默认形态快约 0.4s(`demo:headless` 现为同一条 `dsh run` 源码启动命令的别名;ACP 保留 `--import tsx`,因为它的依赖图尚未就 CJS 钩子依赖性做审计,且其启动延迟不在交互路径上)。 +- 启动相比完整 tsx 默认形态快约 0.4s(`demo:headless` 现为同一条 `dsh --profile headless` 源码启动命令的别名;ACP 保留 `--import tsx`,因为它的依赖图尚未就 CJS 钩子依赖性做审计,且其启动延迟不在交互路径上)。 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index f9e1b3c024..29f0dd7d88 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md -2026-08-03-per-session-agent-presets.md: c39117ab0de001650a95f98ccf3e42f3a5034c92 -2026-08-03-per-session-agent-presets.zh.md: 5e98a2865013a355c134317ded8a4f2ddaccf42c +2026-08-03-per-session-agent-presets.md: 5a82f0220058c10892b819a83499c817aa9be6ad +2026-08-03-per-session-agent-presets.zh.md: 0fb3330014eb703c854f70c9e5ed552256651c31 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index c39117ab0d..5a82f02200 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -18,7 +18,7 @@ Composition splits into two planes, decided by what must be shared rather than b | Plane | Instances | Contents | |---|---|---| -| Host | one | The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), cross-session facilities (persistence, query, projections, storage, settings, credentials, telemetry), and the web host | +| Host | one | The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), cross-session facilities (persistence, query, projections, storage, settings, credentials, telemetry), the subagent providers those facilities resolve, and the web host | | Agent | one per session | What a single agent contributes to those registries: tool plugins, persona and prompt sections, compaction policy | Model routing stays out of presets. `installAgentLlmTarget` is already the per-agent seam for provider, model, and reasoning effort, and an LLM adapter mounted inside a preset would never be resolved by `agent-loop`, which lives in the host plane. @@ -55,7 +55,7 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) **Authoring a preset is an RPC, and a privileged one.** A composition is a file, but "edit it on the filesystem" is not a browser affordance, so the roster gained `read`/`write`/`remove` beside `select`. Those three are loopback-pinned: a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. `list` and `select` deliberately stay ordinary. The roster carries ids and trust only, and a LAN client's picker needs it; and choosing a preset looked like escalation — one of them mounts the toolset that edits the live runtime — but `session.create` already takes an `agentPreset`, so pinning only the switch would have left the same capability one method over. The capability is not the preset's to grant either: the deployment's own default already carries `bash` and the filesystem tools, so any caller that may start a session at all can already run commands as this process. Containment is a property of the id (`[a-z0-9][a-z0-9-]*`), checked before it becomes a directory name rather than by inspecting the joined path afterwards; the text is parsed with the loader's own schema and dialect, so a save cannot leave a file no session could load. Shipped presets are refused for writes and deletes, because the deployment's copy is what a broken local preset is compared against — which also makes "duplicate, then edit" the authoring path rather than an afterthought. -**A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `dsh-host-apiproxy` is a HOST row that injects `subagents` to answer the browser's cross-session queries (`listChildren`, `followup`), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and its backends are host-plane; the preset contributes the delegation TOOLS, which resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones. +**A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `dsh-host-apiproxy` is a HOST row that injects `subagents` to answer the browser's cross-session queries (`listChildren`, `followup`), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and every shared backend, including the [fixed Codex and Claude Code product providers](2026-08-10-product-subagent-providers-in-shared-host.md), are host-plane; a preset contributes whichever delegation TOOLS its agent should see, and those tools resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones. **A real-composition test that disables a host row cannot audit that row.** The web composition test disabled `api-gateway` — the api-proxy itself — as a row with side effects, which is exactly the row whose pending injection would have named the break. It now boots with the api-proxy enabled and the browse directory picker substituted, so the boot audit covers the whole host-plane injection graph; only the port, the asset tree, and the telemetry exporter stay off. @@ -78,3 +78,7 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) **Make the agent's scope key the preset.** Sessions on one preset would share a layer for free, but per-agent registrations — `installAgentLlmTarget`, per-agent tool restrictions — would then collide across sessions. **Run each preset as a child process.** [`subagent-dsh-sdk`](../../../../packages/subagent/subagent-dsh-sdk/README.md) already proves a full child harness works, and isolation would be absolute. It also means proxying streaming, approvals, and projections per session, which is a transport project rather than a composition one. + +**Give product subagents global enable settings and a separate settings page.** The process-wide value would compete with the preset as owner of model-visible tools and could not express two sessions using different compositions. Product providers stay host-side, while ordinary preset rows independently expose Codex and Claude Code tools. + +**Ship one preset for every Codex and Claude Code combination.** Four identities duplicate the full preset composition to represent two independent rows. A copied preset can enable either row directly, so combination presets add roster and maintenance cost without adding a user result. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index 5e98a28650..0fb3330014 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -18,7 +18,7 @@ Status: implemented | 平面 | 实例数 | 内容 | |---|---|---| -| 宿主 | 一份 | 注册表本身(`tools`、`systemPrompt`、`agents`、`agent-loop`、`sessions`)、跨会话设施(持久化、查询、投影、存储、设置、凭据、遥测),以及 web 宿主 | +| 宿主 | 一份 | 注册表本身(`tools`、`systemPrompt`、`agents`、`agent-loop`、`sessions`)、跨会话设施(持久化、查询、投影、存储、设置、凭据、遥测)、这些设施所解析的 subagent provider,以及 web 宿主 | | agent | 每会话一份 | 单个 agent 对这些注册表的贡献:工具插件、人设与提示词段落、压缩策略 | 模型路由不进 preset。`installAgentLlmTarget` 已经是 provider、model 与 reasoning effort 的按 agent 可替换点;而挂在 preset 内部的 LLM 适配器永远不会被 `agent-loop` 解析到,因为后者位于宿主平面。 @@ -56,7 +56,7 @@ Status: implemented **创作 preset 是一次 RPC,而且是特权 RPC。** 组装是一个文件,但“去文件系统里改它”并不是浏览器能提供的操作,因此名单在 `select` 之外新增了 `read`/`write`/`remove`。这三者被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力。`list` 与 `select` 刻意保持为普通方法。名单只携带 id 与信任级别,而局域网客户端的选择器需要它;至于选择本身,它看起来像提权——其中一个 preset 会挂载可编辑活动运行时的工具集——但 `session.create` 本就接受 `agentPreset`,只固定切换会把同一能力留在隔壁一个方法上。这份能力也不由 preset 授予:部署自带的默认 preset 本就带着 `bash` 与文件系统工具,因此任何被允许开启会话的调用方,早已能以本进程的身份执行命令。约束是 id 自身的性质(`[a-z0-9][a-z0-9-]*`),在它成为目录名之前就检查,而不是事后再去审视拼接出的路径;文本使用 loader 自身的 schema 与方言解析,因此保存不会留下任何会话都无法加载的文件。随部署提供的 preset 拒绝写入与删除,因为部署自带的那一份正是用来对照有问题的本地 preset 的——这也让“先复制、再编辑”成为创作路径本身,而非事后补充。 -**在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`dsh-host-apiproxy` 是宿主行,它注入 `subagents` 来回答浏览器的跨会话查询(`listChildren`、`followup`),因而永远等待一个此刻只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与后端属于宿主平面;preset 贡献的是委派**工具**,它们解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。 +**在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`dsh-host-apiproxy` 是宿主行,它注入 `subagents` 来回答浏览器的跨会话查询(`listChildren`、`followup`),因而永远等待一个此刻只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与所有共享后端,包括[固定的 Codex 与 Claude Code 产品 provider](2026-08-10-product-subagent-providers-in-shared-host.md),都属于宿主平面;preset 只贡献自己的 agent 应看见的委派**工具**,这些工具解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。 **真实组装测试若禁用了某个宿主行,就无法审计该行。** web 组装测试把 `api-gateway`——也就是 api-proxy 本身——当作「有外部副作用的行」禁用了,而它恰恰是那个会以 pending 注入点名此次断裂的行。现在它在启用 api-proxy、并替换为 browse 目录选择器的前提下引导,启动审计因此覆盖整个宿主平面的注入图;只有端口、资源目录与遥测导出器仍然关闭。 @@ -79,3 +79,7 @@ Status: implemented **把 agent 的 scope 键设为 preset。** 同一 preset 上的会话就能免费共享一层,但按 agent 的注册——`installAgentLlmTarget`、按 agent 的工具限制——会跨会话相撞。 **把每个 preset 作为子进程运行。** [`subagent-dsh-sdk`](../../../../packages/subagent/subagent-dsh-sdk/README.md) 已经证明完整的子 harness 可行,隔离性也会是绝对的。但这同时意味着要按会话代理流式输出、审批与投影,那是一个传输层项目,而非组装问题。 + +**给产品 subagent 增加全局启用设置与独立设置页。** 进程级值会与 preset 争夺模型可见工具的所有权,也无法表达两个会话使用不同组装。产品 provider 留在宿主,普通 preset 行分别暴露 Codex 与 Claude Code 工具。 + +**为 Codex 与 Claude Code 的每种组合交付一份 preset。** 四个身份会复制完整 preset 组装,只为表示两条独立行。复制后的 preset 已能直接启用任一行,因此组合 preset 只增加名单与维护成本,不增加用户结果。 diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml index 938e802716..c583d6f373 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md -2026-08-05-profile-plugin-bundles.md: 2924b3cb445064fd47d82bcc94ec8d77ded5721b -2026-08-05-profile-plugin-bundles.zh.md: b2287034010bcac1048bb385b2266f1bc75921da +2026-08-05-profile-plugin-bundles.md: 54626e3f48a2ba7db19813e6e883f0e77499d0e2 +2026-08-05-profile-plugin-bundles.zh.md: 357e0f63d4eba0f0985c9e14aad54595c7b41c77 diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md index 2924b3cb44..54626e3f48 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md @@ -10,11 +10,9 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y ## Decision -Everything becomes a **profile**: a directory `$DSH_HOME/profiles/` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer, then `--patch` overlays, then flag patches — one `applyEntryPatches` call, identical for boot, flag derivation, and `--dump-config`. +Everything becomes a **profile**: a directory `$DSH_HOME/profiles/` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer and `--patch` overlays — one `applyEntryPatches` call shared by boot and `--dump-config`. App invocation values later moved from launcher-derived patches to startup services in the [app-owned command-line decision](2026-08-06-app-owned-command-line.md). -The shipped bundles are `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). `dsh web` is the Web-flag alias for `--profile web`; `dsh run [--profile ] "task"` owns one-shot execution and defaults to the headless profile; generic `dsh --profile ` boots without a task. Patch overlays use `--patch`. `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract. - -The [`dsh run` command decision](../feature/2026-08-08-dsh-run-headless-command.md) owns the one-shot grammar; this note owns the profile composition it selects. +The shipped bundles are `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). Generic `dsh --profile ` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, while headless owns its task positional. Patch overlays use launcher-owned `--patch`. `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract. Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory — so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). @@ -24,7 +22,7 @@ Two supporting refactors: the webserver's built-in static dist serving became th - **Dependency-scan plus partial `patchOrder`** (the original sketch): scanning `dependencies` for bundles and ordering unlisted ones alphabetically has two sources of truth and an implicit tie-break; one explicit ordered `dsh.profile.bundles` list is smaller and fully deterministic. A raw `pnpm add` inside the profile installs a library without activating any patch — explicit, no spooky scan. - **`link:` entries for in-box bundles**: pnpm cannot version, install, or update a `link:` into the installation, it embeds a machine path in a user file, and it breaks when the installation moves. The two-anchor resolution plus healed symlink fallback gives the same guarantee ("bundles come from the installation") without ceremony. -- **A pre-boot `context` module in the bundle manifest** for boot-time values (dist path, flag facts): rejected in favor of pure plugins — the glue is ordinary rows the launcher patches, so the composition stays fully dumpable and the manifest stays data-only. The launcher-owned `ctx.headlessIo` host hook is the one host-provided slot, and it is provided in `boot()`'s `prepare` hook, before any config-tree entry mounts. +- **A pre-boot `context` module in the bundle manifest** for boot-time values (dist path, flag facts): rejected in favor of pure plugins — the glue is ordinary rows and app-owned startup services, so the composition stays fully dumpable and the manifest stays data-only. The launcher-owned `ctx.headlessIo` host hook is the one host-provided slot, and it is provided in `boot()`'s `prepare` hook, before any config-tree entry mounts. - **Transitive bundle auto-application**: only direct `dsh.profile.bundles` entries contribute layers; a meta-bundle wanting to re-export another bundle's patch must do so explicitly in its own patch file. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md index b228703401..357e0f63d4 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md @@ -10,11 +10,9 @@ Status: implemented ## Decision -一切都变成 **profile**:即目录 `$DSH_HOME/profiles/`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层,然后是 `--patch` overlay,最后是 flag patch——全部收敛为一次 `applyEntryPatches` 调用,启动、flag 派生与 `--dump-config` 使用完全相同的路径。 +一切都变成 **profile**:即目录 `$DSH_HOME/profiles/`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层与 `--patch` overlay——启动与 `--dump-config` 共享同一条 `applyEntryPatches` 路径。随后,[应用持有命令行的决策](2026-08-06-app-owned-command-line.md)又把调用期取值从启动器派生的 patch 迁移到了启动服务。 -随附的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。`dsh web` 是携带 Web flag 家族的 `--profile web` 别名;`dsh run [--profile ] "task"` 负责一次性执行,默认使用 headless profile;通用的 `dsh --profile ` 启动 profile 而不携带任务。patch overlay 使用 `--patch`。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。 - -[`dsh run` 命令决策](../feature/2026-08-08-dsh-run-headless-command.md)负责一次性语法;本 Agent Note 负责该语法所选择的 profile 组合。 +随附的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。通用的 `dsh --profile ` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 则持有任务位置参数。patch overlay 使用启动器持有的 `--patch`。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。 解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。 @@ -24,7 +22,7 @@ Status: implemented - **依赖扫描加部分 `patchOrder`**(最初的草案):扫描 `dependencies` 找出组合包、未列出者按字母序排列,会产生两个真源和一条隐式决胜规则;一份显式有序的 `dsh.profile.bundles` 列表更小、完全确定。在 profile 内直接 `pnpm add` 只会安装一个库,不激活任何 patch——行为显式,没有暗中扫描。 - **内置组合包使用 `link:` 条目**:pnpm 无法对指向安装目录的 `link:` 做版本管理、安装或更新,它会把机器路径嵌进用户文件,并且在安装目录移动后失效。双锚点解析加上每次启动修复的符号链接回退提供了同样的保证(「组合包来自安装目录」),且没有这些繁文缛节。 -- **在组合包 manifest 中放一个启动前 `context` 模块**承载启动期取值(dist 路径、flag 事实):否决,改用纯插件——粘合逻辑就是启动器 patch 的普通配置行,因此组合始终可完整 dump,manifest 保持纯数据。启动器持有的 `ctx.headlessIo` 宿主钩子是唯一由宿主提供的 slot,且在任何配置树条目挂载之前,于 `boot()` 的 `prepare` 钩子中提供。 +- **在组合包 manifest 中放一个启动前 `context` 模块**承载启动期取值(dist 路径、flag 事实):否决,改用纯插件——粘合逻辑就是普通配置行和由应用持有的启动服务,因此组合始终可完整 dump,manifest 保持纯数据。启动器持有的 `ctx.headlessIo` 宿主钩子是唯一由宿主提供的 slot,且在任何配置树条目挂载之前,于 `boot()` 的 `prepare` 钩子中提供。 - **组合包的传递式自动应用**:只有直接列在 `dsh.profile.bundles` 中的条目才贡献层;想重新导出另一个组合包 patch 的元组合包,必须在自己的 patch 文件中显式完成。 ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml similarity index 58% rename from .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index 730f57e681..15abf1380b 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md -2026-08-08-dsh-run-headless-command.md: ed095f4077a23e51bffb647d24eed19ba09e11ed -2026-08-08-dsh-run-headless-command.zh.md: 89d54e35573f14786e05d648f2b42891ca27a043 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md +2026-08-06-app-owned-command-line.md: 4a05cac5ed7f44fb55c2d4498bf28a43befdb073 +2026-08-06-app-owned-command-line.zh.md: 86a37f416d17c4615152b29d73f171803f24c4c3 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md new file mode 100644 index 0000000000..4a05cac5ed --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md @@ -0,0 +1,51 @@ +# Agent Note: Apps own their command line through `ctx.cmdlineArgs` + +Status: implemented + +English | [中文](2026-08-06-app-owned-command-line.zh.md) + +## Problem + +After profiles, compositions were installable but their command lines were not. `apps/cli` still declared the Web flag family (`--host`, `--port`, `--dev`, `--workspace-root`, `--trusted-host`) and the one-shot task positional, then derived patches for row ids it hardcoded (`webserver`, `api-gateway`, `connection`, `web-runtime`). An out-of-tree app such as [turtle-ui](https://github.com/deepseek-harness/turtle-ui) could contribute rows but had no way to accept a flag: `dsh --profile tui --resume ` had nowhere to be parsed, and `dsh --profile web --help` printed the launcher's help rather than the web app's. + +## Decision + +The launcher parses only what it owns — `--profile`, `--patch`, the config dumps — and hands **everything after its own flags** to the booted tree verbatim. The split is positional: the first token the launcher does not recognize starts the app's arguments (commander's `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`). A bare `dsh -h`, which has no app to hand the flag to, still prints the launcher's own help. + +The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`) and `ctx.appExit`. Any ordinary app plugin may inject `cmdlineArgs`, call `parseCmdline(ctx, program, plan)` with its own commander program, and provide the returned value as an app-owned service. Its Loader row carries no launcher marker or special kind, and the launcher does not inspect the composition for an owner. Multiple plugins may read the same immutable snapshot; a profile with no reader ignores its app arguments. Rows configured from a provider inject its service and read direct lazy config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. + +The boot mounts the composition once. Cordis holds each row until its injections are active; Loader then interpolates that row's `!!js` against the injection-ready plugin context immediately before activation. Include keeps nested row expressions raw until their target row reaches this point. `--help` leaves the provider's service absent, so dependent rows never activate, and a live patch reload interpolates again against the service that remains active, so a served port cannot be silently reset. + +The shipped apps moved their flags into their bundles: `dsh-web-app` owns the Web family (and enables the `client-hmr` row it now ships disabled, for `--dev`), and `dsh-headless` owns the task positional and rejects a missing task as a usage error. `apps/cli/src/web.ts` is gone; `runProfile` no longer knows any flag-target row id. Out of tree, turtle-ui gained `--resume ` / `--session ` the same way, which is the design's real validation: an installed plugin added a flag with no launcher change. + +Two further consequences. Loader mounts sibling rows concurrently, so one row can activate while another still mounts or while the whole boot is rolling back; the Web bundle therefore publishes its URL only after its own Loader tree settles. The Web bundle's runtime plugin owns the harness-source prompt section too, so `dsh web` and `dsh --profile web` boot identically without Web-specific launcher setup. + +## Why Loader owns the ordering + +Four framework facts shape the mechanism: + +- **A profile's rows arrive inside the root include's `patches` option.** Include is an entry-tree owner, so its static entry-config resolver interpolates Include's own options while preserving nested `!!js` nodes for their target rows instead of recursively evaluating them in the Include context. +- **Cordis activates a fiber only after all declared injections are active.** Immediately before each activation, Cordis runs the `internal/config` waterfall against the fiber's own context; Loader's listener interpolates the raw config after Cordis snapshots its injected services. +- **Provider replacement and HMR must preserve the same contract.** Fiber reactivation re-runs the waterfall, HMR carries the raw config to the replacement fiber, and a pending row accepts option changes without prematurely evaluating expressions against absent services. +- **A row cannot be inserted from inside a mounting plugin** — `tree.create` returns a prefixed id it then fails to resolve — so a conditional row ships `disabled: true` and an active row enables it (`dsh web --dev` and its reload chain). Enablement is an in-memory Loader override rather than an options rewrite, so Include reapplication cannot silently disable it. The Web bundle also starts client discovery only after enabling the optional row, ensuring the first browser graph already contains its HMR receiver. + +This leaves dependency ordering in Cordis activation and Loader interpolation, which own it. Rows keep their `inject` and config, Loader mounts the composition once, and the launcher only provides argv and process-lifecycle services. + +## Alternatives considered + +- **Writing the resolved values into each row** (a config update per row, plus a patch layer handed back to the launcher so a reload could not undo it): it worked, but it meant patches travelling from an app to the launcher and back, two mechanisms for one fact, and a recycle whose correctness depended on Loader restart internals. The maintainer rejected the round trip; the service the rows read replaced all of it. +- **Releasing rows by clearing their `inject`**: it worked in isolation and failed on the real web tree, because clearing `inject` is exactly what loses the plugin's static injections. The failure is silent until a plugin reads a service it declared. +- **Launcher-managed two-pass mounting**: it can make a provider active before readers are applied, but duplicates the composition, makes ordering a launcher concern, and conceals the Loader defect that nested expressions were evaluated in the include context rather than the target row's injected context. +- **The launcher running each bundle's command function before boot** (no Cordis involvement): strictly earlier than "boot, then help", but it makes app startup a second plugin protocol outside the tree. An ordinary `cmdlineArgs`-injected provider keeps one protocol and remains dumpable and patchable. +- **A launcher-enforced command-line owner**: rejecting zero or multiple readers would arbitrate overlaps such as `-h`, but `get()` is an immutable read and normal composition may need several app-owned services. Plugins therefore share the snapshot and own any parser interaction through ordinary composition. +- **`instanceof CommanderError`**: an out-of-tree plugin brings its own commander copy, so the class identity differs and a printed `--help` was rethrown as a fatal load failure. Commander's control-flow errors are detected structurally instead. + +## Consequences + +- An app's flags, help text, and usage errors live with the rows they configure; adding a flag to an installed plugin needs no launcher change. +- The launcher still recognizes the headless runner for one-shot process lifetime and the telemetry row for its environment switch; neither path interprets app arguments. +- `--help` leaves every row that depends on the provider's service pending and requests bounded exit; unrelated rows may activate concurrently before teardown. +- An app-owned service has no statically declared provider: a bundle shipping consumer rows without that provider fails at settlement with pending entries naming the service, not at load. +- A user patch that replaces a row's whole `config` drops its expressions, and with them the flag's precedence for that row. +- Launcher flags must precede app arguments; a first app argument equal to `web` or `plugin` selects that subcommand instead, `-V`/`--version` remains launcher-owned before that boundary, and the launcher's parser consumes one `--`, so a literal `--` for the app needs `-- --`. +- `--dump-config` never runs app command-line providers, so it prints the composition before any app argument is resolved and rejects an invocation that carries app arguments. diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md new file mode 100644 index 0000000000..86a37f416d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 应用通过 `ctx.cmdlineArgs` 持有自己的命令行 + +Status: implemented + +[English](2026-08-06-app-owned-command-line.md) | 中文 + +## 问题 + +profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍然声明着 Web flag 家族(`--host`、`--port`、`--dev`、`--workspace-root`、`--trusted-host`)和一次性任务位置参数,再为自己硬编码的行 id(`webserver`、`api-gateway`、`connection`、`web-runtime`)派生 patch。像 [turtle-ui](https://github.com/deepseek-harness/turtle-ui) 这样的树外应用能贡献行,却无处接受一个 flag:`dsh --profile tui --resume ` 没有地方可供解析,而 `dsh --profile web --help` 打印的是启动器的 help,而不是 web 应用的 help。 + +## 决策 + +启动器只解析属于自己的部分(`--profile`、`--patch`、配置 dump),并把**自己 flag 之后的一切**原样交给引导起来的配置树。切分按位置进行:启动器不认识的第一个 token 就是应用参数的起点(依靠 commander 的 `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`)。裸的 `dsh -h` 没有可交付的应用,仍然打印启动器自己的 help。 + +新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)与 `ctx.appExit`。任何普通应用插件都可以注入 `cmdlineArgs`,用自己的 commander program 调用 `parseCmdline(ctx, program, plan)`,再把返回值作为应用自有服务提供出去。它的 Loader 行不携带启动器标记或特殊类型,启动器也不会检查组合中的所有者。多个插件可以读取同一份不可变快照;没有读取方的 profile 会忽略自己的应用参数。由提供方配置的行注入其服务,并在惰性配置表达式中直接读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 + +boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 会让提供方服务保持缺失,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。 + +已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族(并为 `--dev` 启用它如今以禁用状态交付的 `client-hmr` 行),`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何 flag 目标行 id。在树外,turtle-ui 以同样的方式获得了 `--resume ` / `--session `,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。 + +还有两条后果。Loader 会并发挂载兄弟行,因此一行可能已经激活,而另一行仍在挂载,或整次 boot 正在回滚;所以 Web 组合包只会在自身的 Loader 配置树结算后公布 URL。另外,Web 组合包的运行时插件也持有 harness 源码提示词段,因此 `dsh web` 与 `dsh --profile web` 无需 Web 专用启动器设置即可按完全相同的方式启动。 + +## 为什么由 Loader 持有顺序 + +四条框架事实塑造了这套机制: + +- **profile 的各行位于根 include 的 `patches` 选项内部。** Include 是条目树所有者,因此它的静态条目配置解析器会插值 Include 自身的选项,同时为目标行保留嵌套的 `!!js` 节点,而不是在 Include 上下文中递归求值。 +- **Cordis 只在所有声明的注入都已激活后才激活 fiber。** 每次激活前一刻,Cordis 会基于 fiber 自身上下文运行 `internal/config` waterfall;Cordis 快照注入服务之后,Loader 的监听器再插值原始配置。 +- **提供方替换与 HMR 必须保持相同契约。** fiber 重新激活时会重跑 waterfall,HMR 会把原始配置带给替换 fiber,而待处理行可以接受选项变更,不会针对缺失服务提前求值表达式。 +- **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id,随后它自己解析不出来——因此条件性的行以 `disabled: true` 交付,再由活跃行启用(`dsh web --dev` 及其重载链路)。启用采用 Loader 的内存覆盖而非改写选项,因此 Include 重新应用配置时不会悄然将其禁用。Web 组合包还会在启用可选行之后才启动客户端发现,确保首份浏览器图中已经包含 HMR 接收端。 + +这样,依赖顺序仍由负责它的 Cordis 激活与 Loader 插值流程处理。各行保留自己的 `inject` 和配置,Loader 只挂载一次组合,启动器只提供 argv 与进程生命周期服务。 + +## 曾考虑的替代方案 + +- **把解析出的取值写进每一行**(逐行一次配置更新,外加交还给启动器的一层 patch,使重载无法撤销它):它能工作,但这意味着 patch 在应用与启动器之间来回传递、同一件事有两套机制,以及一套其正确性依赖 Loader 重启内部细节的回收重建。维护者否决了这次往返;供各行读取的服务取代了这一切。 +- **通过清空行的 `inject` 来放行**:孤立测试可行,在真实 web 树上失败,因为清空 `inject` 恰恰会丢失插件的静态注入。在插件真的去读它声明过的服务之前,这个失败是静默的。 +- **由启动器管理两趟挂载**:它可以让提供方先于读取行激活,但会重复组合、把顺序变成启动器职责,还掩盖了 Loader 的缺陷——嵌套表达式在 include 上下文而不是目标行的注入上下文中求值。 +- **由启动器在 boot 之前运行每个组合包的命令函数**(完全不经过 Cordis):严格早于「先 boot 再 help」,但这会让应用启动成为配置树之外的第二套插件协议。使用注入 `cmdlineArgs` 的普通提供方只保留一套协议,并且仍可 dump、可 patch。 +- **由启动器强制指定命令行所有者**:拒绝零个或多个读取方可以裁决 `-h` 等重叠项,但 `get()` 是不可变读取,普通组合也可能需要多个应用自有服务。因此插件共享该快照,并通过普通组合持有各自解析器的交互。 +- **`instanceof CommanderError`**:树外插件会带来自己的一份 commander 副本,类身份因此不同,已经打印出来的 `--help` 会被重新抛成致命的加载失败。改为按结构识别 commander 的控制流错误。 + +## 后果 + +- 应用的 flag、help 文本和用法错误与它们所配置的行放在一起;给已安装的插件加一个 flag 不需要改动启动器。 +- 启动器仍会识别 headless runner 以管理一次性进程生命周期,并识别 telemetry 行以应用环境开关;两条路径都不解析应用参数。 +- `--help` 会让所有依赖提供方服务的行保持待处理并请求有边界的退出;无关行可能在拆除前并发激活。 +- 应用自有服务没有静态声明的提供方:交付了消费行却缺少对应提供方的组合包会在结算时失败,报出指向该服务的待处理条目,而不是在加载时失败。 +- 用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉,该行上 flag 的优先级也随之消失。 +- 启动器的 flag 必须写在应用参数之前;如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令;`-V`/`--version` 在该边界之前仍归启动器持有;而且启动器的解析器会消耗掉一个 `--`,因此要给应用传一个字面量 `--` 需要写成 `-- --`。 +- `--dump-config` 从不运行应用命令行提供方,因此它在任何应用参数被解析之前打印组合,并拒绝携带应用参数的调用。 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml index b851627050..a5e5d22982 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md -2026-08-09-headless-direct-core-entry-point.md: 49afe2993de7302adbedcdf9e8e2347d6424ee2a -2026-08-09-headless-direct-core-entry-point.zh.md: 73c1cbe5ac777025f63f46751b1d5ccebbfe9676 +2026-08-09-headless-direct-core-entry-point.md: b705df2e6d88e096ee3ba50a6156b815dbd98b98 +2026-08-09-headless-direct-core-entry-point.zh.md: 439f4c21a2ce1741e8d483bc307508550da1bec7 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md index 49afe2993d..b705df2e6d 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md @@ -20,11 +20,11 @@ The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The headl `loadProfile` recognizes the exact installation-owned headless tuple (`dsh-base`, `dsh-web-app`, `dsh-headless`) and normalizes it to the shipped headless template while preserving every other manifest field. Extra, missing, or reordered bundle lists are user-owned and remain untouched. -This note owns the headless transport and completion contracts. [`dsh run` owns one-shot headless execution](../feature/2026-08-08-dsh-run-headless-command.md) owns the command grammar, [GUI layering and RPC protocol](2026-07-19-gui-layering-and-rpc-protocol.md) owns browser gateway boundaries, [web config-tree boot and transport layering](2026-07-24-web-config-tree-boot-and-transport-layering.md) owns the Web tree, and [the default model follows the picker](../feature/2026-08-07-default-model-follows-the-picker.md) owns persistence of the shared Agent default. +This note owns the headless transport and completion contracts. [Apps own their command lines](2026-08-06-app-owned-command-line.md) owns the current `dsh --profile headless` grammar; the former [`dsh run` decision](../../archived/feature/2026-08-08-dsh-run-headless-command.md) records the superseded launcher-owned grammar, [GUI layering and RPC protocol](2026-07-19-gui-layering-and-rpc-protocol.md) owns browser gateway boundaries, [web config-tree boot and transport layering](2026-07-24-web-config-tree-boot-and-transport-layering.md) owns the Web tree, and [the default model follows the picker](../feature/2026-08-07-default-model-follows-the-picker.md) owns persistence of the shared Agent default. ## Verification -Package tests use the real Session store and Agent registry around a scripted Agent factory to pin idle-to-idle aggregation, late asynchronous completion, terminal model diagnostics, other non-completed exits, direct failures, Loader-time disposal, and flush-before-exit ordering. The keyless assembled snapshots drive `dsh run` through a replayed tool round trip, record a `user/message` with `source.kind: 'user'`, and expose a terminal model failure on stderr. Built-bin acceptance reaches a mock provider through the published entry and requires final text on stdout, exit 0, and empty stderr. Config-dump acceptance excludes every Host, Web, and Client package from the shipped headless tree; PTY shutdown coverage requires no observation line and bounded disposal. +Package tests use the real Session store and Agent registry around a scripted Agent factory to pin idle-to-idle aggregation, late asynchronous completion, terminal model diagnostics, other non-completed exits, direct failures, Loader-time disposal, and flush-before-exit ordering. The keyless assembled snapshots drive `dsh --profile headless` through a replayed tool round trip, record a `user/message` with `source.kind: 'user'`, and expose a terminal model failure on stderr. Built-bin acceptance reaches a mock provider through the published entry and requires final text on stdout, exit 0, and empty stderr. Config-dump acceptance excludes every Host, Web, and Client package from the shipped headless tree; PTY shutdown coverage requires no observation line and bounded disposal. ## Alternatives considered @@ -39,6 +39,6 @@ Package tests use the real Session store and Agent registry around a scripted Ag ## Consequences -`dsh run` provides a local Agent task rather than browser observation, Host APIs, or HTTP. Users who need those capabilities choose `dsh web`. Successful stderr is empty, completion follows durable flush, and the persisted Session remains available to later tooling. Its initial user message records `source.kind: 'user'` and therefore carries no ApiProxy `rpcId`. +`dsh --profile headless` provides a local Agent task rather than browser observation, Host APIs, or HTTP. Users who need those capabilities choose `dsh web`. Successful stderr is empty, completion follows durable flush, and the persisted Session remains available to later tooling. Its initial user message records `source.kind: 'user'` and therefore carries no ApiProxy `rpcId`. ApiProxy carrier coverage stays in the ApiProxy package. Custom one-shot profiles may include Host or Web bundles explicitly, while the shipped profile and the recognized installation-owned tuple are Web-free. diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md index 73c1cbe5ac..439f4c21a2 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md @@ -20,11 +20,11 @@ Status: implemented `loadProfile` 识别安装过程拥有的精确 headless 元组(`dsh-base`、`dsh-web-app`、`dsh-headless`),将其规范化为随附的 headless 模板,并保留 manifest(元数据清单)的其他所有字段。带额外项、缺少项或顺序不同的组合包列表归用户所有,保持不变。 -本 Agent Note 负责 headless 的传输与完成约定。[`dsh run` 负责一次性 headless 执行](../feature/2026-08-08-dsh-run-headless-command.md)负责命令语法,[GUI 分层与 RPC 协议](2026-07-19-gui-layering-and-rpc-protocol.md)负责浏览器网关边界,[Web 配置树启动与传输分层](2026-07-24-web-config-tree-boot-and-transport-layering.md)负责 Web 插件树,[默认模型跟随选择器](../feature/2026-08-07-default-model-follows-the-picker.md)负责共享 Agent 默认值的持久化。 +本 Agent Note 负责 headless 的传输与完成约定。[应用持有自己的命令行](2026-08-06-app-owned-command-line.md)负责当前的 `dsh --profile headless` 语法;原 [`dsh run` 决策](../../archived/feature/2026-08-08-dsh-run-headless-command.md)记录已被取代的启动器持有语法,[GUI 分层与 RPC 协议](2026-07-19-gui-layering-and-rpc-protocol.md)负责浏览器网关边界,[Web 配置树启动与传输分层](2026-07-24-web-config-tree-boot-and-transport-layering.md)负责 Web 插件树,[默认模型跟随选择器](../feature/2026-08-07-default-model-follows-the-picker.md)负责共享 Agent 默认值的持久化。 ## 验证 -包测试围绕脚本化 Agent 工厂使用真实的会话存储与 Agent 注册表,固定空闲态到空闲态的聚合、延迟异步完成、终止态模型诊断、其他未完成退出、直接失败、Loader 加载期间的 dispose(资源释放),以及退出前 flush 的顺序。组装后的无密钥快照通过回放的工具往返驱动 `dsh run`,记录一条带 `source.kind: 'user'` 的 `user/message`,并在 stderr 暴露终止态模型失败。构建后二进制验收通过已发布入口访问 mock 提供方,并要求最终文本出现在 stdout、退出状态为 0 且 stderr 为空。配置转储验收排除随附 headless 树中的所有 Host、Web 与 Client 包;PTY 关闭覆盖要求不出现观察行,并在有界时间内完成 dispose。 +包测试围绕脚本化 Agent 工厂使用真实的会话存储与 Agent 注册表,固定空闲态到空闲态的聚合、延迟异步完成、终止态模型诊断、其他未完成退出、直接失败、Loader 加载期间的 dispose(资源释放),以及退出前 flush 的顺序。组装后的无密钥快照通过回放的工具往返驱动 `dsh --profile headless`,记录一条带 `source.kind: 'user'` 的 `user/message`,并在 stderr 暴露终止态模型失败。构建后二进制验收通过已发布入口访问 mock 提供方,并要求最终文本出现在 stdout、退出状态为 0 且 stderr 为空。配置转储验收排除随附 headless 树中的所有 Host、Web 与 Client 包;PTY 关闭覆盖要求不出现观察行,并在有界时间内完成 dispose。 ## 考虑过的替代方案 @@ -39,6 +39,6 @@ Status: implemented ## 后果 -`dsh run` 提供本地 Agent 任务,而不是浏览器观察、Host API 或 HTTP。需要这些能力的用户选择 `dsh web`。成功时 stderr 为空,完成结果在持久化 flush 后推导,持久化会话仍可供后续工具使用。初始用户消息记录 `source.kind: 'user'`,因此不携带 ApiProxy `rpcId`。 +`dsh --profile headless` 提供本地 Agent 任务,而不是浏览器观察、Host API 或 HTTP。需要这些能力的用户选择 `dsh web`。成功时 stderr 为空,完成结果在持久化 flush 后推导,持久化会话仍可供后续工具使用。初始用户消息记录 `source.kind: 'user'`,因此不携带 ApiProxy `rpcId`。 ApiProxy 载体覆盖保留在 ApiProxy 包中。自定义一次性 profile 可以显式包含 Host 或 Web 组合包;随附 profile 与可识别的安装过程所属元组均不含 Web。 diff --git a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.i18n.yaml index 22e5090312..22296f97a9 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md -2026-08-09-layered-skill-registry.md: 3f092cfb4b722e3dd51fa4dc46c620259eaffa39 -2026-08-09-layered-skill-registry.zh.md: 38b17329c8d46ee9bbd0863f3fae7cf6be39aa75 +2026-08-09-layered-skill-registry.md: 73897c3cb7e0055ff59221b7ea47c5d6ced06991 +2026-08-09-layered-skill-registry.zh.md: 655780d4ef154434d6debf478134d3293d6c564f diff --git a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md index 3f092cfb4b..73897c3cb7 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md +++ b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md @@ -24,7 +24,7 @@ The composition moves with it: the web-app bundle re-enables the base `skill` re **A deployment-level skill reaches every preset-composed session that mounts `tool-skill`.** The repository-plugin e2e's skill root and assertions are restored; the shipped-Web e2e proves the badge row (the same host-registration shape) merges into a standard-preset agent's catalog while the host view stays global-only. -**Layer visibility and consumption stay separate choices.** A core-web agent can read the global layer in principle, but composes no `skill` tool — whether an agent has skills at all remains the preset's decision, made by mounting or omitting `tool-skill`. +**Layer visibility and consumption stay separate choices.** A `minimal` agent can read the global layer in principle, but composes no `skill` tool — whether an agent has skills at all remains the preset's decision, made by mounting or omitting `tool-skill`. **Provider options are still the borrowed caller object.** `SkillViewOptions` extends `SkillLookupOptions`; the registry consumes `scope` and providers read only their own contract from the same readonly object, preserving the existing borrow-identity guarantee. diff --git a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.zh.md b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.zh.md index 38b17329c8..655780d4ef 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.zh.md @@ -24,7 +24,7 @@ agent-preset stack 曾把整个 skill 能力——注册表、本地提供方和 **部署级 skill 会到达每个挂载 `tool-skill` 的 preset 会话。**repository-plugin e2e 的 skill 根目录与断言已恢复;shipped-Web e2e 证明 badge 行(同一种宿主注册形态)汇入 standard preset agent 的目录,而宿主视图保持仅全局。 -**层可见性与消费仍是两个独立选择。**core-web agent 原则上可读全局层,但不组合 `skill` 工具——agent 是否拥有 skill 依旧由 preset 通过挂载或省略 `tool-skill` 决定。 +**层可见性与消费仍是两个独立选择。** `minimal` agent 原则上可读全局层,但不组合 `skill` 工具——agent 是否拥有 skill 依旧由 preset 通过挂载或省略 `tool-skill` 决定。 **提供方选项仍是借用的调用方对象。**`SkillViewOptions` 扩展 `SkillLookupOptions`;注册表消费 `scope`,提供方只从同一个只读对象中读取自己的契约,保持既有的借用恒等保证。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml new file mode 100644 index 0000000000..b696b46749 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md +2026-08-10-product-subagent-providers-in-shared-host.md: 33b6eb6cf7a6c19e9ea71cdb7dc8881e8052ef24 +2026-08-10-product-subagent-providers-in-shared-host.zh.md: fd78c7a3fee4e4ee30d27d87c752e1a23576fd85 diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md new file mode 100644 index 0000000000..33b6eb6cf7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md @@ -0,0 +1,41 @@ +# Agent Note: Product subagent providers live in the shared profile host + +Status: implemented + +English | [中文](2026-08-10-product-subagent-providers-in-shared-host.zh.md) + +## Problem + +The [Codex and Claude Code provider contracts](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md) were first shipped as independently installable packages that a deployment loaded beside the common subagent tool. Agent Presets later became the ordinary owner of one agent's model-visible tools, but a preset cannot safely own these product providers: `ctx.subagents` is a process registry, provider names are unique, and host consumers resolve the same registry across sessions. Requiring a person to edit both a Profile and a Preset would also make a generic preset row incomplete by itself. + +The placement decision must preserve two independent facts. Loading a provider must not start or authenticate a product, while enabling a tool must remain per preset so two sessions can expose different products. A global product switch, a provider instance per agent, or pre-enumerated combination presets would each create a second owner for one of those facts. + +## Decision + +Every shipped Profile loads the fixed `codex` and `claude-code` providers once through the base bundle's host plane. Loading either plugin only registers a dormant backend; the corresponding Codex or Claude process starts on the first actual delegation call. Agent Presets independently contribute ordinary `dsh-tool-subagent` rows for `subagent_codex` and `subagent_claude_code`, so a preset can expose neither tool, either one, or both without changing the provider registry. + +This decision supersedes only the opt-in composition placement recorded by the provider-contract note. That note continues to own each product protocol, result mapping, cancellation, process-tree lifecycle, and evidence tiers. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions. + +The providers use products already selected by the host environment. Codex starts `codex` from `PATH`; Claude Code resolves `claude` through the shared subprocess execution world and passes the exact path to the official SDK. Profile loading does not install a product, create product state, probe a version, test authentication, or add product-specific settings. Missing commands and product failures remain local to the attempted delegation. + +The current base dependency closure still includes the Claude Agent SDK's optional platform CLI payload even though production resolves the host `claude`. Removing that unused payload belongs to the separate product installation-closure follow-up; this placement decision neither installs it dynamically nor treats it as the production executable. + +## Verification + +The base Loader test proves both provider names register exactly once and no product process starts during Profile boot. Real Agent Preset composition covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Keyless ACP snapshots pin the model-visible tool schemas for one and both products, while provider tests separately prove native executable resolution, failure, cancellation, and process-tree quiescence. + +## Alternatives considered + +**Keep product providers opt-in at the Profile layer.** This preserves a smaller default dependency closure, but a copied or agent-authored Preset row is not usable unless the person also discovers and edits a second composition layer. It leaves the general Preset entry incomplete for these otherwise ordinary tools. + +**Store global or per-Profile product enable switches.** A process switch competes with the Preset as owner of model-visible tools and cannot express two sessions using different combinations. Availability and authentication are deployment facts, not another persisted product state. + +**Mount a provider inside every Agent Preset.** Provider names belong to a process registry, so the second session would collide with the first. Host consumers also need the registry independently of any one agent's lifetime. + +**Ship four product-combination presets.** Four identities duplicate complete compositions to represent two independent tool rows. Ordinary rows already express the full matrix without adding roster or maintenance state. + +## Consequences + +A user manages both products through the same Agent Preset authoring path as other plugins, and each new session receives exactly the tools its chosen preset contributes. Every Profile carries two dormant provider registrations, so unused products consume package and module-loading footprint but no product process, login, model call, or product home. + +The Host registry remains the single provider authority and each Preset remains the single model-tool authority. The trade-off is the current Claude SDK optional-payload installation cost, which stays explicitly deferred rather than being hidden behind another enable state or installer lifecycle. diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md new file mode 100644 index 0000000000..fd78c7a3fe --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 产品 subagent 提供方位于共享 profile 宿主 + +Status: implemented + +[English](2026-08-10-product-subagent-providers-in-shared-host.md) | 中文 + +## 问题 + +[Codex 与 Claude Code 提供方约定](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md)最初以可独立安装的包交付,由部署环境在通用 subagent 工具旁加载。Agent Preset 后来成为单个 agent(智能体)的模型可见工具的常规责任方,但 preset 不能安全地拥有这些产品提供方:`ctx.subagents` 是进程级注册表,提供方名称唯一,而宿主消费方会跨会话解析同一个注册表。如果要求用户同时编辑 Profile 和 Preset,也会使通用 preset 行本身不完整。 + +归属决策必须同时保留两个彼此独立的事实:加载提供方不得启动产品,也不得对产品执行身份验证;而工具是否启用仍须按 preset 决定,这样两个会话才能暴露不同的产品。全局产品开关、按 agent 创建提供方实例或预先枚举的组合 preset,都会为其中一个事实另设第二责任方。 + +## 决策 + +每个随发行版交付的 Profile 都会通过 base 组合包的宿主平面,把固定的 `codex` 与 `claude-code` 提供方各加载一次。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 分别通过普通的 `dsh-tool-subagent` 行贡献 `subagent_codex` 与 `subagent_claude_code`,因此一个 preset 可以不暴露任何工具、只暴露其中一个或同时暴露两者,而无需更改提供方注册表。 + +本决策仅取代提供方约定说明所记录的、原先由用户选择启用的组装位置。该说明仍负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)仍负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 + +这些提供方使用宿主环境已经选定的产品。Codex 启动 `codex`,该命令从 `PATH` 解析;Claude Code 通过共享的子进程执行世界解析 `claude`,并把确切路径交给官方 SDK。加载 Profile 不会安装产品、创建产品状态、探测版本、测试身份验证,也不会新增产品专属设置。命令缺失和产品故障仍局限于发生问题的那次委派。 + +当前 base 依赖闭包仍包含 Claude Agent SDK 的可选平台 CLI(命令行界面)载荷,尽管生产环境解析的是宿主提供的 `claude`。移除这份未使用载荷属于独立的产品安装闭包后续项;本归属决策既不会动态安装它,也不会将它当作生产可执行文件。 + +## 验证 + +base Loader 测试证明两个提供方名称都恰好注册一次,而且 Profile 启动期间不会启动产品进程。真实 Agent Preset 组装覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。无密钥 ACP(Agent Client Protocol)快照固定单个产品与两个产品同时启用时的模型可见工具 schema,提供方测试则另行证明原生可执行文件解析、失败、取消和进程树完全停稳。 + +## 考虑过的替代方案 + +**将产品提供方保留为 Profile 层的按需启用项。** 这样可缩小默认依赖闭包,但复制或由 agent 创作的 Preset 行无法直接使用,除非用户还发现并编辑第二个组装层。对于这些本来与其他工具无异的工具,通用 Preset 入口仍不完整。 + +**存储全局或按 Profile 配置的产品启用开关。** 进程级开关会与 Preset 争夺模型可见工具的责任归属,也无法表示两个会话使用不同组合。可用性与身份验证属于部署事实,并非另一份需要持久化的产品状态。 + +**在每个 Agent Preset 内挂载一个提供方。** 提供方名称属于进程级注册表,因此第二个会话会与第一个冲突。宿主消费方也需要独立于任何单个 agent 的生命周期使用该注册表。 + +**交付四个产品组合 preset。** 四个身份会复制完整组装,只为表示两条独立的工具行。普通行已经能表达完整矩阵,无需新增名单或维护状态。 + +## 后果 + +用户通过与其他插件相同的 Agent Preset 创作路径管理两个产品,每个新会话只会获得其所选 preset 所贡献的工具。每个 Profile 都携带两个休眠的提供方注册,因此未使用的产品会产生包和模块加载开销,但不会启动产品进程、登录、调用模型或创建产品主目录。 + +宿主注册表仍是提供方的唯一权威,每个 Preset 仍是模型工具的唯一权威。代价是当前 Claude SDK 可选载荷的安装成本继续被明确延期处理,而不会隐藏在另一种启用状态或安装程序生命周期之后。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml index 34f0f1457e..e4ff6a9cfa 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md -2026-08-03-cli-signal-shutdown-escalation.md: 55917400fac2728d13dc2cdd799a7e234b6ed661 -2026-08-03-cli-signal-shutdown-escalation.zh.md: c7897a8d77e8c2ebad43cec4e12170b04c837350 +2026-08-03-cli-signal-shutdown-escalation.md: 173d06482cd8a1fcbb985763cc313e3f9b170bc6 +2026-08-03-cli-signal-shutdown-escalation.zh.md: efa52524199906cf636cb2b55cb4249857dc1348 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md index 55917400fa..173d06482c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md @@ -6,7 +6,7 @@ English | [中文](2026-08-03-cli-signal-shutdown-escalation.zh.md) ## Problem -The default telemetry mount added SIGINT/SIGTERM handlers to `dsh web` and the headless command (now `dsh run`) so process exit could drain the Cordis tree instead of dropping queued telemetry. Each handler used a one-way boolean latch and exited only after `ctx.fiber.dispose()` settled. Headless normal completion also awaited that disposal without a bound. +The default telemetry mount added SIGINT/SIGTERM handlers to `dsh web` and the headless command (now `dsh --profile headless`) so process exit could drain the Cordis tree instead of dropping queued telemetry. Each handler used a one-way boolean latch and exited only after `ctx.fiber.dispose()` settled. Headless normal completion also awaited that disposal without a bound. A user then reproduced the headless command hanging immediately after the observation URL and ignoring repeated `Ctrl+C`; `DSH_TELEMETRY_DISABLED=1` removed the hang, while a standalone Node handler in the same Linux sandbox received SIGINT. This isolated the pending disposer to telemetry rather than terminal signal forwarding. OTel's `BatchLogRecordProcessor.shutdown()` awaits `exporter.forceFlush()` before the `exportTimeoutMillis`-bounded completion promise, and the OTLP exporter's `forceFlush()` waits directly on its in-flight HTTP Promise. A proxy/sandbox connection that never obtains a socket can therefore leave provider shutdown pending despite both configured SDK timeouts. diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md index c7897a8d77..efa5252419 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -默认挂载遥测后,`dsh web` 与 headless 命令(现为 `dsh run`)新增了 SIGINT/SIGTERM 处理器,使进程退出时可以排空 Cordis 插件树,而不是丢弃排队中的遥测数据。每个处理器都使用单向布尔闩锁(latch),并且只有在 `ctx.fiber.dispose()` 结算后才退出。headless 正常完成时同样会无界等待整棵树执行 dispose(资源释放)。 +默认挂载遥测后,`dsh web` 与 headless 命令(现为 `dsh --profile headless`)新增了 SIGINT/SIGTERM 处理器,使进程退出时可以排空 Cordis 插件树,而不是丢弃排队中的遥测数据。每个处理器都使用单向布尔闩锁(latch),并且只有在 `ctx.fiber.dispose()` 结算后才退出。headless 正常完成时同样会无界等待整棵树执行 dispose(资源释放)。 随后有用户复现,headless 命令在打印观察 URL 后立即卡死,重复按 `Ctrl+C` 也没有反应;设置 `DSH_TELEMETRY_DISABLED=1` 后不再卡死,而同一 Linux 沙箱中的独立 Node 信号处理器能够收到 SIGINT。这将待结算的 disposer 定位到遥测,而非终端信号转发。OTel 的 `BatchLogRecordProcessor.shutdown()` 会先等待 `exporter.forceFlush()`,再进入受 `exportTimeoutMillis` 限制的完成 promise;OTLP 导出器的 `forceFlush()` 则直接等待正在进行的 HTTP Promise。因此,代理/沙箱连接始终无法取得 socket 时,即使已经配置两项 SDK 超时,也会让提供方关闭一直待结算。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml new file mode 100644 index 0000000000..68f399bab8 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md +2026-08-10-minimal-preset-owns-rl-composition.md: 002cad0827e969b322997821dc978db85e2955f3 +2026-08-10-minimal-preset-owns-rl-composition.zh.md: e957b57395c68b336695bdae07ea15a54ca1ea4e diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md new file mode 100644 index 0000000000..002cad0827 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md @@ -0,0 +1,39 @@ +# Agent Note: The minimal preset owns the complete RL agent composition + +Status: implemented + +English | [中文](2026-08-10-minimal-preset-owns-rl-composition.zh.md) + +## Problem + +The Web surface offered two owners for the Claude SWE-compatible RL agent: a process-wide `core-web.cordis.yml` patch and the per-session `minimal` preset. Once [agent presets](../architecture/2026-08-03-per-session-agent-presets.md) became the agent-composition boundary, the preset's scoped `deployment:persona` shadowed the overlay's corrected global persona with stale coding-agent text. The overlay test mounted no preset, while the preset test booted without the overlay, so neither exercised the composition users selected. + +The split also hid other drift. The preset mounted one-shot Bash rather than the [persistent Bash](../feature/2026-07-29-persistent-bash-str-replace-editor.md) used by the RL harness and omitted the RL compaction policy. Keeping both owners makes every future prompt, tool, and policy change a cross-product. + +## Decision + +The shipped Web `minimal` preset is the sole Web owner of the RL agent composition. It declares an entry-local PTY registry and local backend, persistent `bash` with the RL environment description and 300-second timeout, `str_replace_editor`, and an entry-local compaction backend. Tool presentation remains a deployment choice. The compaction policy keeps the RL threshold, absolute retention, generation cap, and retry count; model capacity comes from routed adapter metadata because `contextWindow` is no longer a compact-basic config field. The editor accepts no `requireAbsolutePath` setting because absolute paths are its unconditional contract. + +The preset persona is exactly `You are a helpful software engineer assistant.` and sets `complete: true`. A complete `PromptSection` participates in ordinary assembly so tools, contexts, variables, and cooperative listeners still resolve; after the `system-prompt/assemble` waterfall, the prompt registry restores a detached copy of that section as the sole system-prompt section. Multiple effective complete sections reject assembly. This final registry constraint prevents harness identity, Web orientation, tool guidance, or an assembly listener from appending prompt text. + +The process-wide `core-web.cordis.yml` patch is absent. Browser UI, workspace attachment, persistence, filesystem, subprocess, sandbox, permission, model routing, and other cross-session services remain host-owned. Selecting `minimal` changes one agent's model-facing composition without changing other sessions in the Web process. + +## Verification + +System-prompt and persona package tests prove final complete-section enforcement, including waterfall mutation and duplicate rejection. The shipped-preset composition test asserts the exact prompt, Bash description, absolute editor schema, and two-tool catalog under the default native presentation. The keyless Web replay sends a real request through a `minimal` agent while global identity, Web surface text, and a test section are registered, then executes two persistent Bash calls to prove environment and cwd state survive and executes the editor through an absolute path. + +The standalone [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal.cordis.yml) mirrors the same prompt, tools, timeouts, and compaction policy for the bundled JSON-RPC runtime. Its keyless SDK replay asserts the assembled system prompt and two-tool catalog, executes persistent Bash across calls, and exercises the editor; the Python SDK tutorial provides the runnable entry point. + +## Alternatives considered + +**Keep `core-web.cordis.yml` as a compatibility patch.** Rejected because a process patch and a session preset are two independent owners for one agent contract; precedence makes either one capable of silently undoing the other. + +**Disable every known prompt contributor in the preset.** Rejected because host rows are process-wide and new contributors would reopen the prompt. A final complete-section constraint expresses the negative guarantee at the registry that assembles the prompt. + +**Filter sections only with a prepended waterfall listener.** Rejected because another prepended wrapper can run outside it and append after the filter. Enforcement after the complete waterfall has stable final authority. + +**Mount PTY services on the Web host.** Rejected because only the minimal agent consumes them. An entry-local `pty` realm gives the services the same lifetime and scope as their sole consumer without publishing a process-global service from a preset. + +## Consequences + +The RL prompt is fixed rather than environment-overridable. The Web preset and standalone JSON-RPC example state the same contract for their respective launch surfaces. The model sees only persistent `bash` and `str_replace_editor`; shell state is per agent and disappears with that agent. The preset pays for its own PTY and compaction service instances, while other presets pay nothing for them. The local persistent-shell backend requires the supported POSIX terminal substrate, so this preset is not a Windows agent surface. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md new file mode 100644 index 0000000000..e957b57395 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md @@ -0,0 +1,39 @@ +# Agent Note: minimal preset 拥有完整的 RL agent 组合 + +Status: implemented + +[English](2026-08-10-minimal-preset-owns-rl-composition.md) | 中文 + +## 问题 + +Web surface 同时由两个位置定义与 Claude SWE 兼容的 RL agent(智能体):进程级 `core-web.cordis.yml` patch,以及逐会话的 `minimal` preset。[agent preset](../architecture/2026-08-03-per-session-agent-presets.md) 成为 agent 组合边界后,preset 中带作用域的 `deployment:persona` 会用陈旧的 coding-agent 文本遮蔽 overlay 修正过的全局 persona。overlay 测试没有挂载 preset,而 preset 测试启动时没有 overlay,因此两者都没有覆盖用户实际选择的组合。 + +这种拆分还掩盖了其他偏差。preset 挂载了一次性 Bash,而不是 RL harness 使用的[持久 Bash](../feature/2026-07-29-persistent-bash-str-replace-editor.md),并且遗漏了 RL 压缩(compaction)策略。保留两个所有者,会使今后每次修改提示词、工具或策略时都必须验证二者的交叉组合。 + +## 决策 + +随附的 Web `minimal` preset 是 RL agent 组合在 Web 中的唯一所有者。它声明 entry 本地的 PTY 注册表与本地后端、带 RL 环境描述且超时为 300 秒的持久 `bash`、`str_replace_editor`,以及 entry 本地的压缩后端。工具呈现仍由部署选择。压缩策略保留 RL 的阈值、绝对保留量、生成上限和重试次数;模型容量来自经路由选定的适配器元数据,因为 `contextWindow` 已不再是 compact-basic 的配置字段。编辑器不接受 `requireAbsolutePath` 设置,因为要求绝对路径是它的无条件约定。 + +preset persona 恰好是 `You are a helpful software engineer assistant.`,并设置 `complete: true`。complete `PromptSection` 参与常规组装,因此工具、上下文、变量和协作式监听器仍会解析;`system-prompt/assemble` waterfall(瀑布式事件)结束后,提示词注册表会将该段落的独立副本恢复为唯一的系统提示词段落。存在多个有效 complete 段时,组装会被拒绝。这项最终注册表约束可防止 harness 身份、Web 定位、工具引导或组装监听器追加提示词文本。 + +进程级 `core-web.cordis.yml` patch 不再存在。浏览器 UI、workspace 附加、持久化、文件系统、子进程、沙箱、权限、模型路由及其他跨会话服务仍由宿主持有。选择 `minimal` 只会改变一个 agent 面向模型的组合,不会改变 Web 进程中的其他会话。 + +## 验证 + +系统提示词与 persona 包测试证明了 complete 段的最终约束,包括 waterfall 修改与重复项拒绝。交付 preset 组合测试在默认原生呈现下断言精确的提示词、Bash 描述、要求绝对路径的编辑器 schema 和双工具目录。无密钥 Web 回放通过 `minimal` agent 发送一个真实请求,同时注册全局身份、Web surface 文本和一个测试段落;随后执行两次持久 Bash 调用,证明环境与 cwd 状态能够保留,并通过绝对路径执行编辑器。 + +独立的 [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal.cordis.yml) 为内置 JSON-RPC 运行时复现相同的提示词、工具、超时和压缩策略。其无密钥 SDK 回放会断言组装后的系统提示词与双工具目录,跨调用执行持久 Bash,并使用编辑器;Python SDK 教程提供可运行的入口。 + +## 考虑过的替代方案 + +**将 `core-web.cordis.yml` 保留为兼容 patch。** 被拒绝,因为进程 patch 与会话 preset 是同一 agent 约定的两个独立所有者;优先级会使任意一方都能静默撤销另一方的配置。 + +**在 preset 中禁用每个已知的提示词贡献方。** 被拒绝,因为宿主行属于整个进程,新的贡献方也会重新开放提示词。由组装提示词的注册表实施最终 complete 段约束,才能表达这项否定保证。 + +**仅使用前置 waterfall 监听器筛选段落。** 被拒绝,因为另一个前置包装层可以在该监听器外执行,并在筛选后追加内容。在整个 waterfall 结束后实施约束,才能稳定拥有最终决定权。 + +**在 Web 宿主上挂载 PTY 服务。** 被拒绝,因为只有 minimal agent 消费这些服务。entry 本地的 `pty` realm 与唯一消费方具有相同的生命周期和作用域,无需由 preset 发布进程级全局服务。 + +## 后果 + +RL 提示词固定不变,不能通过环境覆盖。Web preset 与独立 JSON-RPC 示例分别在各自的启动界面声明相同的约定。模型只看到持久 `bash` 与 `str_replace_editor`;shell 状态按 agent 隔离,并随该 agent 一并消失。preset 为自身的 PTY 与压缩服务实例承担开销,其他 preset 无需承担。持久 shell 的本地后端需要受支持的 POSIX 终端基础环境,因此该 preset 不适用于 Windows agent surface。 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index 42a8e3e6bd..8753c066cf 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: c4750e30370bfd253064c39cb1adc0f5b2baa60d -2026-07-29-persistent-bash-str-replace-editor.zh.md: 83159d9792fd9fadaaa342cc289300b35da34e4a +2026-07-29-persistent-bash-str-replace-editor.md: 2c077a08e6027245779a0db364c83d17a9c74fce +2026-07-29-persistent-bash-str-replace-editor.zh.md: f642f2100cbc40ddf688400c5e6124ca9a6ff72d diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index c4750e3037..2c077a08e6 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -18,7 +18,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with a `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch checks `DSH_NODE_PTY_SPAWN_HELPER` first, so it remains a true override for a current external consumer that supplies a non-sibling helper. When the override is unset, the patch resolves the packaged executable sibling if present and otherwise preserves upstream lookup in ordinary Node runs. The macOS builders fail before publication when the helper is absent or not executable. -The shipped [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) overlay composes both plugins over the ordinary Web surface for the Claude SWE-compatible RL contract. It pins native tool mode and makes the complete system prompt `DSH_SYSTEM_PROMPT` when set or `You are a helpful software engineer assistant.` otherwise, with no harness identity, source-checkout section, Web orientation, Workspace instructions, or tool-mode guidance. It disables every other model-facing consumer, so the model receives exactly the persistent `bash` and `str_replace_editor` schemas, while the Web host, browser, Workspace, persistence, sandbox, and permission stack remains in place. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox. +The shipped [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) composes both plugins for the Claude SWE-compatible RL contract. Its entry-local PTY realm carries the registry, local backend, and persistent Bash tool; the editor registers beside that realm against the host filesystem. The preset fixes the complete system prompt, follows the deployment tool-presentation mode, omits every other model-facing consumer, and leaves browser, Workspace, persistence, sandbox, and permission services on the shared Web host. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox. The [minimal-preset decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) owns this composition boundary. ## Alternatives considered @@ -32,4 +32,4 @@ The shipped [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis. ## Consequences -Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. The Core Web profile retains Web permissions but must close its persistent shell before changing modes. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper. +Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. A minimal Web agent retains Web permissions but must close its persistent shell before changing modes. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper. diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 83159d9792..f642f2100c 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -18,7 +18,7 @@ Status: implemented 两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁会先检查 `DSH_NODE_PTY_SPAWN_HELPER`,因此对当前提供非伴随 helper 的外部消费方而言,该变量仍是真正的覆盖项。未设置该覆盖时,补丁会在打包可执行文件的伴随文件存在时解析它,否则在普通 Node 运行中保留上游查找方式。若 helper 缺失或不可执行,macOS 构建器会在发布前失败。 -已交付的 [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) overlay 会在常规 Web 界面之上组合这两个插件,以满足与 Claude SWE 兼容的 RL 约定。它固定使用原生工具模式;完整的系统提示词在设置 `DSH_SYSTEM_PROMPT` 时采用其值,否则采用 `You are a helpful software engineer assistant.`,且不包含 harness 身份、源码 checkout 提示词段、Web 界面定位、Workspace 指令或工具模式指引。它会禁用其他所有面向模型的消费方,使模型恰好只收到持久 `bash` 和 `str_replace_editor` 两个 schema,同时保留 Web 宿主、浏览器、Workspace、持久化、沙箱与权限栈。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。 +随附的 [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) 会组合这两个插件,以满足与 Claude SWE 兼容的 RL 约定。其 entry 本地 PTY realm 持有注册表、本地后端和持久 Bash 工具;编辑器在该 realm 旁注册,并使用宿主文件系统。preset 会固定完整系统提示词、跟随部署的工具呈现模式,省略其他所有面向模型的消费方,并将浏览器、Workspace、持久化、沙箱与权限服务留在共享 Web 宿主上。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。这一组合边界由 [minimal-preset 决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md)负责说明。 ## 考虑过的替代方案 @@ -32,4 +32,4 @@ Status: implemented ## 后果 -Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。Core Web profile 保留 Web 权限,但必须先关闭持久 shell 才能更改权限模式。运行时 wheel 包的消费方仍无需安装 Node;Linux wheel 包包含一个可执行文件,macOS wheel 包还包含其私有原生 helper。 +Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。minimal Web agent 保留 Web 权限,但必须先关闭持久 shell 才能更改权限模式。运行时 wheel 包的消费方仍无需安装 Node;Linux wheel 包包含一个可执行文件,macOS wheel 包还包含其私有原生 helper。 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml index 151d74e3dc..41756caeb4 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md -2026-07-31-even-out-shipped-tool-rosters.md: 0195620055da5e570d2f54792d950a88bab8d652 -2026-07-31-even-out-shipped-tool-rosters.zh.md: ab6982e33c4a0a25cbc2fde386456840ce99d9c5 +2026-07-31-even-out-shipped-tool-rosters.md: 7647506e5d9c39d64b686ab18923f9681a48cd87 +2026-07-31-even-out-shipped-tool-rosters.zh.md: f04a59c9f00455b00f70975ad9b6bd4defd3d847 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md index 0195620055..7647506e5d 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md @@ -12,11 +12,11 @@ The result was a user-visible difference nobody had decided: the same model, ask ## Decision -The rows that are not surface-specific move into [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-guard`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces assemble the same roster: twenty-two tools on every host — the twenty shared rows plus `glob` and `grep`, which are fixed members because `dsh-tool-fs-search` spawns the [packaged ripgrep binary](../architecture/2026-08-01-packaged-ripgrep-search.md). `tool-session-query` joined and then left again — the [session-search-not-shipped-default decision](2026-08-02-session-search-not-shipped-default.md) keeps the model-facing consumer opt-in — while the rest of this roster stands. +The rows that are not surface-specific move into [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-guard`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces assemble the same roster, including fixed `glob` and `grep` members because `dsh-tool-fs-search` spawns the [packaged ripgrep binary](../architecture/2026-08-01-packaged-ripgrep-search.md). Two later decisions narrow that roster: the [session-search decision](2026-08-02-session-search-not-shipped-default.md) keeps `tool-session-query` opt-in, and the [single-editor decision](../simplification/2026-08-10-default-presets-single-editor.md) keeps `tool-str-replace-editor` out of the general-purpose presets while retaining it in `minimal`. Two rows stay surface-specific. `tmux-context` is TUI-only because a browser surface has no terminal multiplexer to describe. `session-reference` is TUI-only because it drives the shared session-query index from the launcher's process-local path, and the browser sidebar reconciles that index on its own first search. -**This roster decision added only at the time.** No tool row was removed from either surface when it landed, and a catalog comparison found additions and nothing else. One of those additions, `tool-session-query`, was subsequently removed by the [session-search-not-shipped-default decision](2026-08-02-session-search-not-shipped-default.md). The shared executors, sandbox composition, and access default are owned independently by the [workspace-write default decision](2026-07-31-workspace-write-surface-default.md). +**This roster decision added only at the time.** No tool row was removed from either surface when it landed, and a catalog comparison found additions and nothing else. The later session-search and single-editor decisions own their respective default-roster exceptions. The shared executors, sandbox composition, and access default are owned independently by the [workspace-write default decision](2026-07-31-workspace-write-surface-default.md). ### What stays unmounted, and why diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md index ab6982e33c..f04a59c9f0 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md @@ -12,11 +12,11 @@ Status: implemented ## 决策 -那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-guard`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 组装同一份清单:每台宿主上都有二十二个工具——二十个共享行加上 `glob` 和 `grep`,它们成为固定成员,因为 `dsh-tool-fs-search` 直接 spawn [打包的 ripgrep 二进制](../architecture/2026-08-01-packaged-ripgrep-search.md)。`tool-session-query` 加入后又退出了——[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)让面向模型的消费方保持需显式启用——而这份清单的其余部分保持不变。 +那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-guard`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 组装同一份清单,其中 `glob` 和 `grep` 是固定成员,因为 `dsh-tool-fs-search` 直接 spawn [打包的 ripgrep 二进制](../architecture/2026-08-01-packaged-ripgrep-search.md)。之后有两项决策收窄这份清单:[session-search 决策](2026-08-02-session-search-not-shipped-default.md)让 `tool-session-query` 保持需显式启用,[单一编辑器决策](../simplification/2026-08-10-default-presets-single-editor.md)让通用 preset 不提供 `tool-str-replace-editor`,但在 `minimal` 中保留它。 有两行仍是 surface 专属。`tmux-context` 只在 TUI,因为浏览器 surface 没有终端复用器可描述。`session-reference` 只在 TUI,因为它以 launcher 的进程本地路径驱动共享的 session-query 索引,而浏览器侧边栏会在自己的首次搜索里重建该索引。 -**本次工具清单决策当时只做加法。** 落地时两个 surface 均未移除任何工具行,目录对比只发现了新增,别无其他。这些新增中的一项 `tool-session-query` 随后被[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)移除。共享执行器、沙箱组合与访问默认值独立归属[workspace-write 默认值决策](2026-07-31-workspace-write-surface-default.md)。 +**本次工具清单决策当时只做加法。** 落地时两个 surface 均未移除任何工具行,目录对比只发现了新增,别无其他。后续的 session-search 与单一编辑器决策分别负责对应的默认清单例外。共享执行器、沙箱组合与访问默认值独立归属[workspace-write 默认值决策](2026-07-31-workspace-write-surface-default.md)。 ### 什么保持不挂,以及为什么 diff --git a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml index fd479c217e..1a4d923540 100644 --- a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md -2026-08-02-session-search-not-shipped-default.md: 65bd72fff76210b726e7562fb8e88e5f8802434a -2026-08-02-session-search-not-shipped-default.zh.md: 4eb0851c1e584b84847b6bb5118c8bb2f3156845 +2026-08-02-session-search-not-shipped-default.md: c1bfd7f8e354a4480c5635619514fe782ea71d2c +2026-08-02-session-search-not-shipped-default.zh.md: 9b80c549425c26055700480dd57f1a0a7d01e4a8 diff --git a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md index 65bd72fff7..c1bfd7f8e3 100644 --- a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md +++ b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md @@ -10,7 +10,7 @@ The [shipped-roster decision](2026-07-31-even-out-shipped-tool-rosters.md) made ## Decision -The shipped TUI, Web, and headless surfaces no longer mount `@deepseek-ai/dsh-tool-session-query`: the row is removed from the shared `cordis.patch.yml`, the now-dangling `disabled` patch in the opt-in [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) profile goes with it, and the workspace dependency drops from `apps/cli/package.json`. The consumer stays opt-in exactly as the model-facing-session-query-tools note describes: the ACP example's [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) and its snapshot counterpart remain the mounted reference, and a custom composition can mount the package with the timeout and spill policies. +The shipped TUI, Web, and headless surfaces do not mount `@deepseek-ai/dsh-tool-session-query`, and no shipped agent preset carries it. The consumer stays opt-in exactly as the model-facing-session-query-tools note describes: the ACP example's [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) and its snapshot counterpart remain the mounted reference, and a custom composition can mount the package with the timeout and spill policies. The `ctx.sessionQuery` service itself stays mounted. `session-query-sqlite` remains a base row — the TUI's `session-reference` consumes it for `/resume` — and the Web overlay keeps patching it to an in-memory index for the browser content search. Only the model-facing consumer is removed. diff --git a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md index 4eb0851c1e..9b80c54942 100644 --- a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -交付的 TUI、Web 与无头 surface 不再挂载 `@deepseek-ai/dsh-tool-session-query`:该行从共享的 `cordis.patch.yml` 移除,opt-in 的 [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) profile 中那条已悬空的 `disabled` patch 也随之删除,workspace 依赖也从 `apps/cli/package.json` 中移除。该消费方仍保持 opt-in,与面向模型的会话查询工具决策所述完全一致:ACP(Agent Client Protocol)示例的 [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) 及其快照对侧文件仍是挂载参考,自定义组合也可以连同超时与 spill 策略一起挂载该包。 +交付的 TUI、Web 与无头 surface 均不挂载 `@deepseek-ai/dsh-tool-session-query`,交付的 agent preset 也都不包含它。该消费方仍保持 opt-in,与面向模型的会话查询工具决策所述完全一致:ACP(Agent Client Protocol)示例的 [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) 及其快照对侧文件仍是挂载参考,自定义组合也可以连同超时与 spill 策略一起挂载该包。 `ctx.sessionQuery` 服务本身保持挂载。`session-query-sqlite` 仍是 base 的一行,TUI 的 `session-reference` 消费它来实现 `/resume`,Web overlay 也继续把它 patch 成内存索引,供浏览器内容搜索使用。被移除的只有面向模型的消费方。 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 89e150f59c..84cb091651 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 2063b99a7b0ca34f434b3e56628f3ce765d90d81 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 71d4a8e9fc060f8c96483b22641fd67ee1f71c08 +2026-08-04-claude-code-and-codex-subagent-backends.md: ccc96d6c998c4ab958a7eea1e502d036d16ec90d +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 740eeb633e336d5b01cb0b84fb656612e690959d diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 2063b99a7b..ccc96d6c99 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -12,7 +12,7 @@ The product integrations must not become second owners for task text, cwd, cance ## Decision -The harness publishes two sibling one-shot providers as independently installable, opt-in packages. A user loads a provider and the existing common subagent tool in their own `cordis.yml`: `subagent_codex` binds `codex`, while `subagent_claude_code` binds `claude-code`. The shipped CLI dependency closure and base, Web, and headless configurations load neither provider. Each tool accepts only a standalone text task; product selection and background execution are not model arguments. +The harness publishes two sibling one-shot provider packages: `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [shared-profile-host placement decision](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md) supersedes the original opt-in composition placement. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection and background execution are not model arguments. Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools disable background execution and use `maxDepth: 'provider-managed'`, leaving recursion policy with the out-of-process product instead of sending a limit the provider cannot enforce. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. @@ -47,7 +47,7 @@ Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-comp ## Claude Code provider -`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. The SDK's platform `optionalDependency` supplies the real Claude Code 2.1.220 CLI. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` command, arguments, cwd, environment, and forwarded signal unchanged to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. +`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. Before each run, the provider resolves the fixed `claude` name through the host subprocess execution world and passes that exact path as `pathToClaudeCodeExecutable`; the SDK therefore uses the native product that launched DSH rather than selecting its platform `optionalDependency`. A Windows `.cmd` or `.bat` path crosses `cmd.exe /v:off` as a quoted per-spawn environment expansion, so percent, ampersand, and exclamation path components remain data without changing the shared subprocess contract. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` arguments, cwd, environment, and forwarded signal to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. The public configuration contains the same two deployment-owned values as the Codex sibling: an explicit `env` overlay and a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`. Each run creates its own `AbortController`, sets `persistSession: false`, and disables `AskUserQuestion`. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK rather than waiting for a user interface the provider does not own. @@ -65,7 +65,7 @@ The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its rea The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. -The Claude Code evidence pins Agent SDK 0.3.220 and its platform-distributed Claude Code 2.1.220 CLI. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, and whole-tree exit. The Loader e2e resolves both product packages by name while neither product command is available and records zero child starts. +The Claude Code evidence pins Agent SDK 0.3.220 and uses its platform-distributed Claude Code 2.1.220 CLI as the deterministic compatibility fixture, routed through the same native executable-resolution path production uses. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, whole-tree exit, and a real Windows batch shim under a path containing percent, ampersand, and exclamation metacharacters. This evidence proves the official SDK/CLI integration path, not compatibility with every independently installed product version. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`. The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test. @@ -87,7 +87,7 @@ The project owner's distribution authorization is scoped to the official `@anthr ## Consequences -Users can install either or both product providers, bind stable foreground tools in their own Cordis configuration, and delegate one self-contained task through the existing subagent contract. Official product integrations preserve native settings and behavior while shared services retain the sole ownership of task settlement and process-tree quiescence. +Users delegate through two stable foreground tools backed by the official product integrations. Their Profile placement and per-Preset exposure are owned by the [shared-host placement decision](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md); this note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of task settlement and process-tree quiescence. Every delegation pays for a fresh product process and independent model context, and only final text reaches the parent. Product-native configuration makes behavior depend on the deployment's installed product, account state, and workspace settings. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 71d4a8e9fc..740eeb633e 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -harness 将两个一次性兄弟提供方作为可独立安装、选择启用的包交付。用户在自己的 `cordis.yml` 中加载提供方与现有的通用 subagent 工具:`subagent_codex` 绑定 `codex`,`subagent_claude_code` 绑定 `claude-code`。随产品交付的 CLI(命令行界面)依赖闭包,以及基础、Web 与 headless 配置都不会加载任一提供方。每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。 +harness 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[共享 profile 宿主归属决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)取代原先由用户选择启用的组装位置。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。 这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具会禁用后台执行,并使用 `maxDepth: 'provider-managed'`,将递归策略留给进程外产品,而不是发送提供方无法强制执行的限制。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 @@ -47,7 +47,7 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 ## Claude Code 提供方 -`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。SDK 的平台 `optionalDependency` 提供真实的 Claude Code 2.1.220 CLI。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 命令、参数、cwd、环境和转发的信号原样传入 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 +`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。每次运行前,提供方经宿主 subprocess 执行世界解析固定名称 `claude`,并把准确路径作为 `pathToClaudeCodeExecutable` 交给 SDK;SDK 因此使用启动 DSH 的原生产品,而不是选择自身的 platform `optionalDependency`。Windows `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境展开值穿过 `cmd.exe /v:off`,因此路径中的百分号、与号和感叹号仍只是数据,且无需改变共享子进程约定。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 参数、cwd、环境和转发的信号交给 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 公开配置包含与 Codex 兄弟提供方相同、由部署方负责的两个值:显式的 `env` 覆盖项,以及须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。 @@ -65,7 +65,7 @@ Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 -Claude Code 证据锁定 Agent SDK 0.3.220 及其平台分发的 Claude Code 2.1.220 CLI。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消以及整棵进程树退出。Loader e2e 会在两个产品命令均不可用时按名称解析两个产品包,并记录零次子级启动。 +Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Claude Code 2.1.220 CLI 作为确定性兼容性 fixture(测试前置数据),且该 fixture 经生产环境所用的同一原生可执行文件解析路径运行。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消、整棵进程树退出,以及位于同时含百分号、与号和感叹号路径中的真实 Windows batch shim。这项证据证明官方 SDK/CLI 集成路径,而不证明它与每个独立安装的产品版本兼容。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 @@ -79,7 +79,7 @@ Claude Code 证据锁定 Agent SDK 0.3.220 及其平台分发的 Claude Code 2.1 **面向模型的产品选择器。** 产品可用性和身份验证属于部署事实。两个固定工具使各自的 schema 与提供方绑定保持明确,也避免在通用服务中添加动态选择状态。 -**以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture(测试前置数据)。 +**以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture。 **由插件管理登录、产品主目录、模型、设置或权限。** 这些选择会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。提供方只公开显式环境覆盖项和清理宽限期;无人值守交互会以默认拒绝方式失败。 @@ -87,7 +87,7 @@ Claude Code 证据锁定 Agent SDK 0.3.220 及其平台分发的 Claude Code 2.1 ## 后果 -用户可以安装任一或两个产品提供方,在自己的 Cordis 配置中绑定稳定的前台工具,并通过现有 subagent 约定委派一项自包含任务。官方产品集成会保留原生设置与行为,而共享服务继续独占任务结算与进程树完全停稳的责任。 +用户通过官方产品集成支持的两个稳定前台工具进行委派。它们在 Profile 中的归属和按 Preset 暴露方式由[共享宿主归属决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)负责;本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占任务结算与进程树完全停稳的责任。 每次委派都要承担新建产品进程和独立模型上下文的开销,且只有最终文本会到达父级。产品原生配置使行为取决于部署环境中安装的产品、账户状态和工作区设置。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml index dcef1b190f..b23fcae758 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md -2026-07-20-remove-stdio-and-echo-agents.md: 256e626f4ff41016d0227eef7cc3e4e51e15058b -2026-07-20-remove-stdio-and-echo-agents.zh.md: 013135eff5d1dbbc570561e70c751ff4de289989 +2026-07-20-remove-stdio-and-echo-agents.md: 23fcb90599c2ff96cd7ac0e6f7ee8fd508a6d1ad +2026-07-20-remove-stdio-and-echo-agents.zh.md: 7aabf5612a54245327da9af804f745237472cb88 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md index 256e626f4f..23fcb90599 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md @@ -19,7 +19,7 @@ The stdio and Echo agents are removed without compatibility packages, modes, com The remaining application roles are explicit: - `@deepseek-ai/dsh-tui` owns terminal-interactive execution. It rejects non-TTY streams before Loader boot; `apps/cli/config/base.cordis.yml` plus the `tui.cordis.yml` overlay own the complete coding composition, with PTY plus terminal-snapshot coverage in `apps/cli/tests/`. -- [`dsh run`](../../../../apps/cli/README.md) owns non-interactive execution. Its `headless` profile is the product composition; `examples/headless-agent` owns replay snapshots, generic real-agent suites, and an unexported keyless Loader driver. +- [`dsh --profile headless`](../../../../apps/cli/README.md) owns non-interactive execution. Its `headless` profile is the product composition; `examples/headless-agent` owns replay snapshots, generic real-agent suites, and an unexported keyless Loader driver. - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) and `@deepseek-ai/dsh-jsonrpc` own their framed protocol integrations. The SDK project model and create/config workflows replace the `stdio` run-interface option with `tui`; generated TUI projects compose `@deepseek-ai/dsh-tui` and create or resume one exact session. Repository-facing demo documentation requires a DeepSeek API key and leads with the real Headless or TUI agents. @@ -30,7 +30,7 @@ Keyless validation is test-owned. The Headless Loader smoke uses a fixture adapt TUI and Headless Loader coverage run the real app packages in source and built modes. PTY-driven subprocess coverage is reserved for the TUI lifecycle; other entry-point smokes use the one-shot pipe protocol. Headless proves its task/result and tool-call contracts. Generated graphs and repository searches reject stale package, command, leaf, SDK-interface, `createStdioChat`, and `StdioRuntime` references. -The built `dsh` bin rejects a piped TUI launch before Loader boot and points at `dsh run`; `apps/cli/tests/built-bin.e2e.ts` pins the product one-shot entry under plain Node, including output and invalid arguments. `examples/headless-agent/tests/headless.snapshot.ts` pins product persistence, while `apps/cli/tests/headless-shutdown.e2e.ts` owns bounded signal escalation. The headless example's test-only JSONL driver preserves assembled canonical-event snapshots without creating a second CLI contract. Code Mode has programmatic TUI snapshots and an ACP overlay demo. Time-context integration uses the explicit Headless test composition for two ordered turns, while its package tests own finer elapsed-time behavior. +The built `dsh` bin rejects a piped TUI launch before Loader boot and points at `dsh --profile headless`; `apps/cli/tests/built-bin.e2e.ts` pins the product one-shot entry under plain Node, including output and invalid arguments. `examples/headless-agent/tests/headless.snapshot.ts` pins product persistence, while `apps/cli/tests/headless-shutdown.e2e.ts` owns bounded signal escalation. The headless example's test-only JSONL driver preserves assembled canonical-event snapshots without creating a second CLI contract. Code Mode has programmatic TUI snapshots and an ACP overlay demo. Time-context integration uses the explicit Headless test composition for two ordered turns, while its package tests own finer elapsed-time behavior. ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md index 013135eff5..7aabf5612a 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md @@ -19,7 +19,7 @@ DeepSeek Harness 在 TUI 和 Headless coding agent 之外,还提供了两个 保留的应用角色均有明确归属: - `@deepseek-ai/dsh-tui` 负责终端交互式执行。它会在 Loader 启动前拒绝非 TTY 流;`apps/cli/config/base.cordis.yml` 与 `tui.cordis.yml` overlay 拥有完整 coding 组装,PTY 与终端快照覆盖则位于 `apps/cli/tests/`。 -- [`dsh run`](../../../../apps/cli/README.md) 负责非交互式执行。其 `headless` profile 是产品组装;`examples/headless-agent` 负责回放快照、通用真实 agent 测试套件和未导出的无密钥 Loader driver。 +- [`dsh --profile headless`](../../../../apps/cli/README.md) 负责非交互式执行。其 `headless` profile 是产品组装;`examples/headless-agent` 负责回放快照、通用真实 agent 测试套件和未导出的无密钥 Loader driver。 - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) 和 `@deepseek-ai/dsh-jsonrpc` 负责各自的分帧协议集成。 SDK 工程模型与 create/config 工作流将 `stdio` 运行接口选项替换为 `tui`;生成的 TUI 工程组合 `@deepseek-ai/dsh-tui`,并创建或恢复一个确切会话。仓库中的演示文档要求 DeepSeek API key,并优先引导到真实的 Headless 或 TUI agent。 @@ -30,7 +30,7 @@ SDK 工程模型与 create/config 工作流将 `stdio` 运行接口选项替换 TUI 与 Headless 的 Loader 覆盖以源码和构建产物两种模式运行真实 app 包。由 PTY 驱动的子进程覆盖仅用于 TUI 生命周期;其他入口冒烟测试使用单次管道协议。Headless 验证任务/结果约定和工具调用约定。生成图谱与仓库搜索会拒绝陈旧的包、命令、叶节点、SDK 接口、`createStdioChat` 和 `StdioRuntime` 引用。 -构建后的 `dsh` 可执行文件会在 Loader 启动前拒绝通过管道启动 TUI,并指向 `dsh run`;`apps/cli/tests/built-bin.e2e.ts` 在普通 Node 下固定产品的一次性入口,包括输出和无效参数。`examples/headless-agent/tests/headless.snapshot.ts` 固定产品持久化,`apps/cli/tests/headless-shutdown.e2e.ts` 则负责有界信号升级。headless 示例仅供测试的 JSONL driver 保留组装后的规范事件快照,而不会创建第二套 CLI(命令行界面)约定。Code Mode 由程序化 TUI 快照与 ACP overlay demo 覆盖。时间上下文集成通过显式的 Headless 测试组装执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。 +构建后的 `dsh` 可执行文件会在 Loader 启动前拒绝通过管道启动 TUI,并指向 `dsh --profile headless`;`apps/cli/tests/built-bin.e2e.ts` 在普通 Node 下固定产品的一次性入口,包括输出和无效参数。`examples/headless-agent/tests/headless.snapshot.ts` 固定产品持久化,`apps/cli/tests/headless-shutdown.e2e.ts` 则负责有界信号升级。headless 示例仅供测试的 JSONL driver 保留组装后的规范事件快照,而不会创建第二套 CLI(命令行界面)约定。Code Mode 由程序化 TUI 快照与 ACP overlay demo 覆盖。时间上下文集成通过显式的 Headless 测试组装执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml index 217265168d..fac9ae9452 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md -2026-08-08-remove-cli-demo.md: 403e01f94c976d2d17eb391830721b31675cd6a9 -2026-08-08-remove-cli-demo.zh.md: 7f11e0c17a15454b99b32d14ea6eda177f4b01f6 +2026-08-08-remove-cli-demo.md: 31879e8284daf5f34af6731b09fce743f9eb5391 +2026-08-08-remove-cli-demo.zh.md: d4dfef1ba8c4d27cd667e319519cfa1ace74baef diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md index 403e01f94c..31879e8284 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md @@ -6,29 +6,29 @@ English | [中文](2026-08-08-remove-cli-demo.zh.md) ## Problem -After [`dsh run`](../feature/2026-08-08-dsh-run-headless-command.md) became the product one-shot command, `@deepseek-ai/dsh-cli-demo` remained a second application package for the same job. It carried another executable, argument grammar, app composition, cancellation lifecycle, text/JSON/stream-JSON output contract, built artifact, documentation surface, and test suite. The two entry points also assembled different trees, so a successful demo did not prove the shipped `headless` profile and users had to choose between overlapping commands. +After [`dsh --profile headless`](../architecture/2026-08-06-app-owned-command-line.md) became the product one-shot command, `@deepseek-ai/dsh-cli-demo` remained a second application package for the same job. It carried another executable, argument grammar, app composition, cancellation lifecycle, text/JSON/stream-JSON output contract, built artifact, documentation surface, and test suite. The two entry points also assembled different trees, so a successful demo did not prove the shipped `headless` profile and users had to choose between overlapping commands. The replay suites still need canonical session events to pin assembled backend behavior. That testing need does not require a published command or compatibility contract. ## Decision -Delete `@deepseek-ai/dsh-cli-demo` completely: its package, bin, parser, app plugin, output formats, tests, workspace references, generated-catalog entries, and active documentation. No alias or compatibility package remains. The root `demo:headless` script is retained only as a direct alias of `dsh run`; the product command owns final-text stdout, the observation URL on stderr, persistence, exit status, and shutdown. +Delete `@deepseek-ai/dsh-cli-demo` completely: its package, bin, parser, app plugin, output formats, tests, workspace references, generated-catalog entries, and active documentation. No alias or compatibility package remains. The root `demo:headless` script is retained only as a direct alias of `dsh --profile headless`; the product command owns final-text stdout, failure diagnostics on stderr, persistence, exit status, and shutdown. `examples/headless-agent` becomes an explicit test composition. Its Loader configs mount `@deepseek-ai/dsh-agent-spine-demo`, one root agent, JSONL persistence, and checkpoint policy as separate rows instead of hiding them behind an app bundle. The support-tier `@deepseek-ai/dsh-loader-smoke` package owns the shared direct-agent turn helper; unexported example-local drivers select their Loader configuration and render canonical events as JSONL. They are launched only by tests, have no bin, and do not define a supported product output format. ## Alternatives considered -- **Keep `dsh-cli-demo` as an alias or wrapper around `dsh run`.** Rejected because a second bin and package would preserve two discoverable owners without adding capability. -- **Move JSON and stream-JSON flags onto `dsh run`.** Rejected because no current product consumer requires them; adopting the old demo protocol would enlarge the canonical CLI contract solely to save test machinery. +- **Keep `dsh-cli-demo` as an alias or wrapper around `dsh --profile headless`.** Rejected because a second bin and package would preserve two discoverable owners without adding capability. +- **Move JSON and stream-JSON flags onto `dsh --profile headless`.** Rejected because no current product consumer requires them; adopting the old demo protocol would enlarge the canonical CLI contract solely to save test machinery. - **Delete the canonical-event snapshots with the package.** Rejected because they pin model-visible assembled behavior that final-text product acceptance cannot observe. - **Keep the app plugin but delete only its bin.** Rejected because the hidden composition would still duplicate the explicit headless profile and conceal which services the test leaf mounts. ## Consequences -This is intentionally breaking. `dsh-cli-demo`, its `--output-format` choices, and imports from `@deepseek-ai/dsh-cli-demo/src/cli.ts` no longer resolve. There is no public event-stream replacement in this change; callers use `dsh run` for one-shot execution and must choose an existing protocol surface when they need structured automation. +This is intentionally breaking. `dsh-cli-demo`, its `--output-format` choices, and imports from `@deepseek-ai/dsh-cli-demo/src/cli.ts` no longer resolve. There is no public event-stream replacement in this change; callers use `dsh --profile headless` for one-shot execution and must choose an existing protocol surface when they need structured automation. -The repository retains backend replay coverage through test-only infrastructure, while product smoke and built-bin acceptance exercise `dsh run`. A separate one-shot package may return only if it owns a genuinely independent, versioned protocol that cannot belong to the product launcher; a second spelling or output shim is not enough. +The repository retains backend replay coverage through test-only infrastructure, while product smoke and built-bin acceptance exercise `dsh --profile headless`. A separate one-shot package may return only if it owns a genuinely independent, versioned protocol that cannot belong to the product launcher; a second spelling or output shim is not enough. ## Verification -Focused Loader smokes cover the explicit composition in source and plain-Node built modes, snapshot tests diff its canonical JSONL and persisted logs, product acceptance covers `dsh run`, and documentation plus generated graph/catalog gates reject live references to the removed package. The frozen Agent Note archive remains historical evidence and is not rewritten. +Focused Loader smokes cover the explicit composition in source and plain-Node built modes, snapshot tests diff its canonical JSONL and persisted logs, product acceptance covers `dsh --profile headless`, and documentation plus generated graph/catalog gates reject live references to the removed package. The frozen Agent Note archive remains historical evidence and is not rewritten. diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.zh.md b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.zh.md index 7f11e0c17a..d4dfef1ba8 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.zh.md @@ -6,29 +6,29 @@ Status: implemented ## 问题 -在 [`dsh run`](../feature/2026-08-08-dsh-run-headless-command.md) 成为产品的一次性命令后,`@deepseek-ai/dsh-cli-demo` 仍是承担同一工作的第二个应用包。它另行拥有一套可执行文件、参数语法、应用组装、取消生命周期、文本/JSON/stream-JSON 输出约定、构建产物、配套文档和测试套件。两个入口组装的树也不相同,因此 demo 成功不能证明已交付的 `headless` profile 可用,用户还必须在功能重叠的命令之间作出选择。 +在 [`dsh --profile headless`](../architecture/2026-08-06-app-owned-command-line.md) 成为产品的一次性命令后,`@deepseek-ai/dsh-cli-demo` 仍是承担同一工作的第二个应用包。它另行拥有一套可执行文件、参数语法、应用组装、取消生命周期、文本/JSON/stream-JSON 输出约定、构建产物、配套文档和测试套件。两个入口组装的树也不相同,因此 demo 成功不能证明已交付的 `headless` profile 可用,用户还必须在功能重叠的命令之间作出选择。 回放套件仍需要规范会话事件来固定组装后的后端行为。这一测试需求不需要已发布命令或兼容性约定。 ## 决策 -彻底删除 `@deepseek-ai/dsh-cli-demo`:包括它的包、bin、解析器、应用插件、输出格式、测试、workspace 引用、生成目录条目和现行文档。不保留别名或兼容包。根目录的 `demo:headless` 脚本仅作为 `dsh run` 的直接别名保留;stdout 上的最终文本、stderr 上的观察 URL、持久化、退出状态和关闭行为均由产品命令负责。 +彻底删除 `@deepseek-ai/dsh-cli-demo`:包括它的包、bin、解析器、应用插件、输出格式、测试、workspace 引用、生成目录条目和现行文档。不保留别名或兼容包。根目录的 `demo:headless` 脚本仅作为 `dsh --profile headless` 的直接别名保留;stdout 上的最终文本、stderr 上的失败诊断、持久化、退出状态和关闭行为均由产品命令负责。 `examples/headless-agent` 成为显式测试组装。其 Loader 配置把 `@deepseek-ai/dsh-agent-spine-demo`、一个根 agent(智能体)、JSONL 持久化和检查点策略挂载为独立配置行,不再将其隐藏在应用组合包之后。支持层的 `@deepseek-ai/dsh-loader-smoke` 包负责共享的直接 agent 轮次 helper;未导出的示例本地 driver 选择各自的 Loader 配置,并将规范事件渲染为 JSONL。这些 driver 只由测试启动,不提供 bin,也不定义受支持的产品输出格式。 ## 考虑过的替代方案 -- **保留 `dsh-cli-demo` 作为 `dsh run` 的别名或包装层。** 不予采纳:第二个 bin 和包会让同一功能继续存在两个可发现的归属方,却没有增加任何能力。 -- **把 JSON 和 stream-JSON 标志移到 `dsh run`。** 不予采纳:当前没有产品消费方需要这些标志;沿用旧 demo 协议,只会为了保留测试机制而扩大规范 CLI(命令行界面)约定。 +- **保留 `dsh-cli-demo` 作为 `dsh --profile headless` 的别名或包装层。** 不予采纳:第二个 bin 和包会让同一功能继续存在两个可发现的归属方,却没有增加任何能力。 +- **把 JSON 和 stream-JSON 标志移到 `dsh --profile headless`。** 不予采纳:当前没有产品消费方需要这些标志;沿用旧 demo 协议,只会为了保留测试机制而扩大规范 CLI(命令行界面)约定。 - **随包一并删除规范事件快照。** 不予采纳:这些快照固定了模型可见的组装行为,而只检查最终文本的产品验收无法观察这些行为。 - **保留应用插件,只删除它的 bin。** 不予采纳:隐藏的组装仍会重复显式的 headless profile,并掩盖测试叶节点挂载了哪些服务。 ## 后果 -这是有意为之的破坏性变更。`dsh-cli-demo`、它的 `--output-format` 选项以及对 `@deepseek-ai/dsh-cli-demo/src/cli.ts` 的导入都不再可解析。本变更不提供公开的事件流替代接口;调用方使用 `dsh run` 执行一次性任务,需要结构化自动化时则必须选择现有的协议接口。 +这是有意为之的破坏性变更。`dsh-cli-demo`、它的 `--output-format` 选项以及对 `@deepseek-ai/dsh-cli-demo/src/cli.ts` 的导入都不再可解析。本变更不提供公开的事件流替代接口;调用方使用 `dsh --profile headless` 执行一次性任务,需要结构化自动化时则必须选择现有的协议接口。 -仓库通过仅供测试的基础设施保留后端回放覆盖,产品冒烟测试和 built-bin 验收则运行 `dsh run`。只有当独立的一次性包负责一套真正独立、带版本且不能归产品启动器所有的协议时,它才可以重新引入;第二种命令写法或输出 shim 并不足以构成理由。 +仓库通过仅供测试的基础设施保留后端回放覆盖,产品冒烟测试和 built-bin 验收则运行 `dsh --profile headless`。只有当独立的一次性包负责一套真正独立、带版本且不能归产品启动器所有的协议时,它才可以重新引入;第二种命令写法或输出 shim 并不足以构成理由。 ## 验证 -聚焦的 Loader 冒烟测试在源码模式和由普通 Node 启动的构建模式下覆盖显式组装,快照测试对比其规范 JSONL 和持久化日志,产品验收覆盖 `dsh run`,文档检查及生成图谱/目录门禁则拒绝对已移除包的活跃引用。冻结的 Agent Note 归档保留为历史证据,不会被重写。 +聚焦的 Loader 冒烟测试在源码模式和由普通 Node 启动的构建模式下覆盖显式组装,快照测试对比其规范 JSONL 和持久化日志,产品验收覆盖 `dsh --profile headless`,文档检查及生成图谱/目录门禁则拒绝对已移除包的活跃引用。冻结的 Agent Note 归档保留为历史证据,不会被重写。 diff --git a/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.i18n.yaml new file mode 100644 index 0000000000..e67d7df745 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.md +2026-08-10-default-presets-single-editor.md: 82f254079080aeb88d76f4e7cc2c7ab195646ec4 +2026-08-10-default-presets-single-editor.zh.md: 22bbc19feadfb71fe3a9480bc60b8777e1fb50c2 diff --git a/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.md b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.md new file mode 100644 index 0000000000..82f2540790 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.md @@ -0,0 +1,25 @@ +# Agent Note: One editor family in general-purpose presets + +Status: implemented + +English | [中文](2026-08-10-default-presets-single-editor.zh.md) + +## Problem + +The `standard`, `code`, and `cordis` presets exposed both the `read`/`write`/`edit` filesystem tools and `str_replace_editor`. The two interfaces overlap for ordinary file inspection and editing, so every request carried an additional tool schema without adding a distinct default capability. The `minimal` preset has a different composition contract: its exact two-tool roster intentionally includes `str_replace_editor` beside persistent `bash`. + +## Decision + +The `standard`, `code`, and `cordis` preset configurations mount `dsh-tool-fs` and `dsh-tool-fs-search`, but do not mount `dsh-tool-str-replace-editor`. Code Mode therefore omits `str_replace_editor` from both its registry and generated SDK. The `minimal` preset continues to mount `dsh-tool-str-replace-editor`, and deployments or user-authored presets may still mount the plugin explicitly. + +This decision narrows the preset roster rather than removing the tool package or its Python runtime support. The earlier [shared-roster decision](../feature/2026-07-31-even-out-shipped-tool-rosters.md) continues to own why surface-neutral tools live in preset composition; this note owns the editor exception. + +## Alternatives considered + +**Keep both editing interfaces in the general-purpose presets.** Rejected because the overlapping model-visible schemas increase tool choice without supplying a separate default operation. + +**Remove `str_replace_editor` from every shipped composition.** Rejected because the `minimal` preset intentionally exposes that schema as one of its two tools, and explicit deployments remain valid consumers of the standalone plugin. + +## Consequences + +General-purpose agents use `read`, `write`, and `edit` for filesystem mutations, while the minimal agent retains `str_replace_editor`. Preset composition tests pin its absence from the standard roster, the Cordis roster, and the Code Mode SDK, while the minimal assertions continue to pin its presence. diff --git a/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.zh.md b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.zh.md new file mode 100644 index 0000000000..22bbc19fea --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 通用 preset 只提供一套编辑工具 + +Status: implemented + +[English](2026-08-10-default-presets-single-editor.md) | 中文 + +## 问题 + +`standard`、`code` 和 `cordis` preset 同时提供 `read`/`write`/`edit` 文件系统工具与 `str_replace_editor`。两套接口在常规文件查看和编辑上重叠,导致每次请求都携带额外的工具 schema,却没有增加独立的默认能力。`minimal` preset 具有不同的组合约定:它固定的双工具清单有意在持久 `bash` 之外提供 `str_replace_editor`。 + +## 决策 + +`standard`、`code` 和 `cordis` preset 配置挂载 `dsh-tool-fs` 与 `dsh-tool-fs-search`,但不挂载 `dsh-tool-str-replace-editor`。因此 Code Mode 的注册表和生成的 SDK 均不包含 `str_replace_editor`。`minimal` preset 继续挂载 `dsh-tool-str-replace-editor`,部署配置或用户自定义 preset 仍可显式挂载该插件。 + +此决策收窄 preset 工具清单,不移除工具包及其 Python 运行时支持。较早的[共享清单决策](../feature/2026-07-31-even-out-shipped-tool-rosters.md)继续说明与 surface 无关的工具为何归 preset 组合所有;本记录说明编辑器例外。 + +## 曾考虑的替代方案 + +**在通用 preset 中保留两套编辑接口。** 不予采用,因为重叠的模型可见 schema 增加了工具选择,却没有提供不同的默认操作。 + +**从所有交付组合中移除 `str_replace_editor`。** 不予采用,因为 `minimal` preset 有意将该 schema 作为两个工具之一,显式部署仍是该独立插件的有效消费方。 + +## 后果 + +通用 agent 使用 `read`、`write` 和 `edit` 完成文件系统修改,minimal agent 保留 `str_replace_editor`。preset 组合测试固定其不会出现在 standard 清单、Cordis 清单及 Code Mode SDK 中,同时 minimal 断言继续固定其存在。 diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 017b77ee75..0924da5b35 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -92,7 +92,7 @@ jobs: sdk-wheel: needs: plan - name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl + name: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -113,8 +113,8 @@ jobs: - uses: actions/upload-artifact@v7 with: - name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl - path: dist-python/deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl + name: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl + path: dist-python/deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl if-no-files-found: error build: @@ -197,7 +197,7 @@ jobs: - uses: actions/download-artifact@v8 with: - name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl + name: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl path: dist-python - name: Install only the SDK into a clean venv and run zero-config @@ -208,7 +208,7 @@ jobs: python -m venv "$RUNNER_TEMP/dsh-sdk-smoke" "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" -m pip install \ --find-links dist-python \ - deepseek-harness=="$VERSION" + deepseek-harness-sdk=="$VERSION" "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \ --scenario sdk-default @@ -238,7 +238,7 @@ jobs: esac docker run --rm -e VERSION -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c ' /opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk - /tmp/dsh-sdk/bin/python -m pip install --find-links /work/dist-python deepseek-harness=="$VERSION" + /tmp/dsh-sdk/bin/python -m pip install --find-links /work/dist-python deepseek-harness-sdk=="$VERSION" /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default ' diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b4af9e3ecb..fd56278195 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -42,7 +42,7 @@ sdk-wheel: - uv run --python 3.10 --group test --project python/sdk python scripts/smoke-python-runtime.py --scenario all --exe "$EXE" - python scripts/build-python-release.py --package runtime --tag "$CI_COMMIT_TAG" --platform "$PLATFORM" --runtime-exe "$EXE" --output-dir "release/$PLATFORM" - python -m venv .wheel-smoke - - .wheel-smoke/bin/python -m pip install --find-links "release/$PLATFORM" --find-links release/sdk deepseek-harness=="$DSH_VERSION" + - .wheel-smoke/bin/python -m pip install --find-links "release/$PLATFORM" --find-links release/sdk deepseek-harness-sdk=="$DSH_VERSION" - .wheel-smoke/bin/python scripts/smoke-python-runtime.py --scenario sdk-default - | if [ "${PLATFORM#linux-}" != "$PLATFORM" ]; then @@ -55,7 +55,7 @@ sdk-wheel: linux-arm64) image=quay.io/pypa/manylinux_2_28_aarch64 ;; *) echo "Unsupported Linux platform $PLATFORM"; exit 1 ;; esac - docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness==$DSH_VERSION && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default" + docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness-sdk==$DSH_VERSION && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default" fi artifacts: paths: [release/$PLATFORM/*.whl] @@ -112,7 +112,7 @@ publish-python: - python -m pip install twine==6.2.0 script: - test "$(find release -name '*.whl' | wc -l | tr -d ' ')" = 4 - - test -f "release/sdk/deepseek_harness-${DSH_VERSION}-py3-none-any.whl" + - test -f "release/sdk/deepseek_harness_sdk-${DSH_VERSION}-py3-none-any.whl" - test -f "release/linux-x64/deepseek_harness_runtime_bin-${DSH_VERSION}-py3-none-manylinux_2_28_x86_64.whl" - test -f "release/linux-arm64/deepseek_harness_runtime_bin-${DSH_VERSION}-py3-none-manylinux_2_28_aarch64.whl" - test -f "release/macos-arm64/deepseek_harness_runtime_bin-${DSH_VERSION}-py3-none-macosx_11_0_arm64.whl" diff --git a/README.i18n.yaml b/README.i18n.yaml index 35cb82e776..43185dc239 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md -README.md: 3174630d021b3868986d6ad9989d257fe8ac29fb -README.zh.md: 377ea6372a9a531c1400d08b0ef33792b452dc65 +README.md: 9b8944027572072e08a39bd9e482996f8128224c +README.zh.md: a1b2b6a36c4baac8a49b88a42c9f17c986287ff1 diff --git a/README.md b/README.md index 3174630d02..9b89440275 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ The [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer Run one task, print the final answer, and exit: ```sh -dsh run "summarize this workspace" +dsh --profile headless "summarize this workspace" ``` ### Automation and SDKs diff --git a/README.zh.md b/README.zh.md index 377ea6372a..a1b2b6a36c 100644 --- a/README.zh.md +++ b/README.zh.md @@ -56,7 +56,7 @@ profile 布局、层语义与配置输出命令详见 [CLI(命令行界面) 运行一项任务,打印最终答案后退出: ```sh -dsh run "summarize this workspace" +dsh --profile headless "summarize this workspace" ``` ### 自动化与 SDK diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index b5b0fc5b29..586cd240a0 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -180,7 +180,7 @@ Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the developm | Package | License | Role | | --- | --- | --- | | [`hatchling`](https://github.com/pypa/hatch) | MIT | build backend | -| [`pydantic`](https://github.com/pydantic/pydantic) | MIT | runtime dependency of `deepseek-harness` | +| [`pydantic`](https://github.com/pydantic/pydantic) | MIT | runtime dependency of `deepseek-harness-sdk` | | [`pytest`](https://github.com/pytest-dev/pytest) | MIT | test-only | | [`uv`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool | diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 101332555d..04026262c8 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: dd29f7fc03a783079ea3194de99589c1f545be5b -README.zh.md: 60e7aa1ec1ea2fad7e3f3d97a0f6bf42355adffc +README.md: 4fae5338a89ce12c2620e123530acf883ae9efff +README.zh.md: a2d086b8ff12fb07f2446fc4162de09739bcdeab diff --git a/apps/cli/README.md b/apps/cli/README.md index dd29f7fc03..4fae5338a8 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -9,15 +9,27 @@ The `dsh` command is the product launcher for profiles: ordered stacks of plugin | Command | Purpose | |---|---| | `dsh --profile ` | Boot the named profile under `$DSH_HOME/profiles/`. | -| `dsh run [--profile ] [--patch ...] "task"` | Run one fresh persisted session directly over core, print the final answer, and exit; the profile defaults to `headless` and mounts no Web server. | -| `dsh web` | Alias of `--profile web` with the Web flag family (`--host`, `--port`, `--dev`, ...). | +| `dsh --profile headless "task"` | Run one fresh persisted session, print the final answer, and exit. | +| `dsh web` | Alias of `--profile web`. | | `dsh plugin --profile ` | Manage a profile's plugins by forwarding to pnpm in the profile directory. | -The invoking directory is the default workspace root. `dsh run` requires non-blank task text and the selected profile must mount the `headless-runner` row; `--profile` preserves custom one-shot profiles. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. +The invoking directory is the default workspace root. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. + +## App arguments + +The launcher parses only its own flags and hands everything after them to the booted profile, where any injected app plugin may parse the shared immutable snapshot ([`dsh-cmdline`](../../packages/boot/cmdline/README.md)). Launcher flags therefore come first, and the first token the launcher does not recognize starts the app's arguments: + +```sh +dsh --profile web --port 8080 # --port belongs to the web app +dsh --profile tui --resume # --resume belongs to the terminal app +dsh --profile headless "run the tests" +dsh --profile web --help # the web app's flags, not the launcher's +dsh --help # the launcher's own help +``` ## Profiles -A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer, hot-reloaded on long-lived surfaces). The tree composes over an empty root: each bundle's patch in `dsh.profile.bundles` order, then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`, then `--patch` overlays, then flag patches. Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it. +A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer, hot-reloaded on long-lived surfaces). The tree composes over an empty root: each bundle's patch in `dsh.profile.bundles` order, then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`, then `--patch` overlays. Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it. The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and the source launcher. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 60e7aa1ec1..a2d086b8ff 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -9,18 +9,30 @@ | 命令 | 用途 | |---|---| | `dsh --profile ` | 启动位于 `$DSH_HOME/profiles/` 的指定 profile。 | -| `dsh run [--profile ] [--patch ...] "task"` | 直接在 core 上运行一个新的持久化会话,打印最终答案并退出;profile 默认为 `headless`,且不挂载 Web server。 | -| `dsh web` | `--profile web` 的别名,附带 Web flag 系列(`--host`、`--port`、`--dev` 等)。 | +| `dsh --profile headless "task"` | 运行一个新的持久化会话,打印最终答案并退出。 | +| `dsh web` | `--profile web` 的别名。 | | `dsh plugin --profile ` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 | -调用目录是默认 workspace 根目录。`dsh run` 要求任务文本非空白,且所选 profile 必须挂载 `headless-runner` 行;`--profile` 保留对自定义一次性 profile 的支持。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 +调用目录是默认 workspace 根目录。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 + +## 应用参数 + +启动器只解析属于自己的 flag,并把其后的一切交给启动起来的 profile,任何注入它的应用插件都可以解析这份共享的不可变快照([`dsh-cmdline`](../../packages/boot/cmdline/README.md))。因此启动器的 flag 必须写在前面,而启动器不认识的第一个 token 就是应用参数的起点: + +```sh +dsh --profile web --port 8080 # --port belongs to the web app +dsh --profile tui --resume # --resume belongs to the terminal app +dsh --profile headless "run the tests" +dsh --profile web --help # the web app's flags, not the launcher's +dsh --help # the launcher's own help +``` ## Profile -profile 目录包含一个 `package.json`(树外插件依赖,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 列表)和一个 `cordis.patch.yml`(用户自己的 patch 层,在长期运行的 surface 上热重载)。配置树在空根之上组合:先按 `dsh.profile.bundles` 顺序应用各组合包的 patch,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`,然后是 `--patch` overlay,最后是 flag patch。`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自己的 `node_modules` 解析;pnpm 把树外插件安装在后者。使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。 +profile 目录包含一个 `package.json`(树外插件依赖,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 列表)和一个 `cordis.patch.yml`(用户自己的 patch 层,在长期运行的 surface 上热重载)。配置树在空根之上组合:先按 `dsh.profile.bundles` 顺序应用各组合包的 patch,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`,然后是 `--patch` overlay。`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自己的 `node_modules` 解析;pnpm 把树外插件安装在后者。使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。 [CLI(命令行界面)行为参考](reference/README.md)负责确切的层优先级、flag、关闭行为、部署默认值和源码启动器。 ## 开发 -生产运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析约定。 +生产运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析契约。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 77d8143d53..303903566b 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -110,6 +110,10 @@ flowchart LR cfg --> plugin_dsh_base_subagent_spawn plugin_dsh_base_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] cfg --> plugin_dsh_base_subagent_fork + plugin_dsh_base_subagent_codex["subagent-codex
@deepseek-ai/dsh-subagent-codex"] + cfg --> plugin_dsh_base_subagent_codex + plugin_dsh_base_subagent_claude_code["subagent-claude-code
@deepseek-ai/dsh-subagent-claude-code"] + cfg --> plugin_dsh_base_subagent_claude_code plugin_dsh_base_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] cfg --> plugin_dsh_base_tool_subagent_control plugin_dsh_base_tool_subagent_list_agents["tool-subagent-list-agents
@deepseek-ai/dsh-tool-subagent-control/list-agents"] @@ -215,6 +219,8 @@ flowchart LR | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | +| `subagent-codex` | `@deepseek-ai/dsh-subagent-codex` | +| `subagent-claude-code` | `@deepseek-ai/dsh-subagent-claude-code` | | `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | | `tool-subagent-list-agents` | `@deepseek-ai/dsh-tool-subagent-control/list-agents` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml index 5e273bb955..a4b01eddb7 100644 --- a/apps/cli/config/agent-presets/code/agent.cordis.yml +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -53,7 +53,7 @@ # ── filesystem ────────────────────────────────────────────────────────────── -# All three register into the host `tools` registry and provide nothing, so +# Both register into the host `tools` registry and provide nothing, so # they need no realm. The `fs` service and its policy stay in the host. - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' @@ -63,11 +63,6 @@ config: sampleOverCapGlobResults: false -- id: tool-str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' - config: - maxOutputChars: 16000 - # ── background tasks ──────────────────────────────────────────────────────── # Only the model-facing controls. The task REGISTRY stays on the host plane: @@ -195,6 +190,27 @@ toolName: subagent_fork backgroundMode: continuable + # Product providers are host-plane singletons. Copy this preset, then + # remove `disabled` from either ordinary tool row to expose that product + # only to agents composed from the copy. + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed + + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed + - id: workflow-workerthread name: '@deepseek-ai/dsh-workflow-workerthread' config: diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index 1bc3d3bcf0..4c296659af 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -47,7 +47,7 @@ # ── filesystem ────────────────────────────────────────────────────────────── -# All three register into the host `tools` registry and provide nothing, so +# Both register into the host `tools` registry and provide nothing, so # they need no realm. The `fs` service and its policy stay in the host. - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' @@ -57,11 +57,6 @@ config: sampleOverCapGlobResults: false -- id: tool-str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' - config: - maxOutputChars: 16000 - # ── background tasks ──────────────────────────────────────────────────────── # Only the model-facing controls. The task REGISTRY stays on the host plane: @@ -182,6 +177,27 @@ toolName: subagent_fork backgroundMode: continuable + # Product providers are host-plane singletons. Copy this preset, then + # remove `disabled` from either ordinary tool row to expose that product + # only to agents composed from the copy. + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed + + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed + - id: workflow-workerthread name: '@deepseek-ai/dsh-workflow-workerthread' config: diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md index f6d9a6126c..ce751f039d 100644 --- a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md +++ b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -26,6 +26,34 @@ A preset is a directory holding one `agent.cordis.yml`, optionally beside a `pre 3. **Rewrite `preset.yml`**: give the copy its own `name` and `description`, and drop any `order` the source declared — that field sorts the shipped roster. 4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and realm rule above. +### Native product subagents + +Codex and Claude Code providers already live in the host composition. A preset chooses either product by contributing the same ordinary delegation-tool row used for spawn and fork; never move a product provider into the preset and never add a product-specific settings field. + +Copy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested: + +```yaml +- id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed + +- id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed +``` + +The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. The host must provide `codex` or `claude` on `PATH`; the preset does not install, authenticate, select a model for, or probe either product. + The shipped preset directories are off-limits: never edit or delete them, and never escalate the sandbox to reach them, even when a change there looks quicker — an upgrade overwrites the install, and corrupting the `cordis` preset disables preset authoring itself. Locally authored presets under the user root are yours to create, edit, and delete. ## The rule that catches people diff --git a/apps/cli/config/agent-presets/minimal/agent.cordis.yml b/apps/cli/config/agent-presets/minimal/agent.cordis.yml index 6ae88b9339..742412268b 100644 --- a/apps/cli/config/agent-presets/minimal/agent.cordis.yml +++ b/apps/cli/config/agent-presets/minimal/agent.cordis.yml @@ -1,39 +1,70 @@ -# The `minimal` agent preset: the two-tool benchmark surface. +# The `minimal` agent preset: a fixed-prompt, two-tool coding surface. # -# The native model surface is exactly persistent `bash` plus -# `str_replace_editor`. Everything else a session could reach — skills, goals, -# plan mode, delegation, workflows, todo, web — is simply absent rather than -# disabled, because a preset composes what an agent has instead of subtracting -# from a shared default. -# -# The host composition is unchanged: this agent still runs inside the same -# sandbox, approval, persistence, and model routing as any other session. +# The persona is the complete system prompt, so global identity, Web surface, +# tool guidance, and later assembly listeners cannot add prompt text. The model +# composes only the persistent `bash` and `str_replace_editor` tools. - id: persona name: '@deepseek-ai/dsh-persona' config: - text: >- - You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. + text: You are a helpful software engineer assistant. + complete: true -# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to -# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is -# the criterion for host-plane ownership — injection resolves before any session -# exists, so there is no agent to key by. Behind a preset realm those variables -# never reached the model's shell at all. `tool-bash` consumes the host registry -# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the -# sandbox policy owns it. -# -# `run_in_background` is off because this preset mounts no `tool-tasks`. The -# host registry already refuses a start for an owner no attached control -# surface serves, so this is not the safety boundary — it is the model-facing -# one: an agent that could never collect a task should not be offered the -# parameter at all, and disabling it drops the parameter from the schema. -- id: tool-bash - name: '@deepseek-ai/dsh-tool-bash' +# The PTY registry is an agent-owned service, so it lives in an entry-local +# realm. The backend still consumes the host sandbox policy and subprocess +# implementation, while the tool registers into this agent's scoped catalog. +- id: persistent-shell + name: cordis:group + group: true + isolate: + pty: true config: - enableRunInBackground: false + - id: pty + name: '@deepseek-ai/dsh-pty' -- id: tool-str-replace-editor + - id: pty-local + name: '@deepseek-ai/dsh-pty-local' + config: + timeoutMs: 300000 + + - id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' + config: + timeoutMs: 300000 + description: |- + Run commands in a bash shell + * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. + * You don't have access to the internet via this tool. + * You do have access to a mirror of common linux and python packages via apt and pip. + * State is persistent across command calls and discussions with the user. + * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. + * Please avoid commands that may produce a very large amount of output. + * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. + +# The editor requires absolute paths unconditionally. +- id: str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' config: maxOutputChars: 16000 + +# Model capacity comes from routed model metadata; this block states the +# compaction policy explicitly. +- id: compaction + name: cordis:group + group: true + isolate: + tokenMeter: true + compact: true + config: + - id: token-meter + name: '@deepseek-ai/dsh-token-meter' + + - id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + config: + thresholdRatio: 0.8 + retainTokens: 20480 + summarizationProvider: '' + summarizationModel: '' + maxTokens: 8192 + compactionRetries: 1 diff --git a/apps/cli/config/agent-presets/minimal/preset.yml b/apps/cli/config/agent-presets/minimal/preset.yml index 7160b51c43..97c714b72a 100644 --- a/apps/cli/config/agent-presets/minimal/preset.yml +++ b/apps/cli/config/agent-presets/minimal/preset.yml @@ -1,3 +1,3 @@ name: 极简模式 -description: 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 +description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。 order: 3 diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index 6442b12306..5e22f5da11 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -46,7 +46,7 @@ # ── filesystem ────────────────────────────────────────────────────────────── -# All three register into the host `tools` registry and provide nothing, so +# Both register into the host `tools` registry and provide nothing, so # they need no realm. The `fs` service and its policy stay in the host. - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' @@ -56,11 +56,6 @@ config: sampleOverCapGlobResults: false -- id: tool-str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' - config: - maxOutputChars: 16000 - # ── background tasks ──────────────────────────────────────────────────────── # Only the model-facing controls. The task REGISTRY stays on the host plane: @@ -194,6 +189,27 @@ toolName: subagent_fork backgroundMode: continuable + # Product providers are host-plane singletons. Copy this preset, then + # remove `disabled` from either ordinary tool row to expose that product + # only to agents composed from the copy. + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed + + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed + - id: workflow-workerthread name: '@deepseek-ai/dsh-workflow-workerthread' config: diff --git a/apps/cli/config/core-web.cordis.yml b/apps/cli/config/core-web.cordis.yml deleted file mode 100644 index 43860418c4..0000000000 --- a/apps/cli/config/core-web.cordis.yml +++ /dev/null @@ -1,113 +0,0 @@ -# Opt-in Web shell for the RL core agent contract. The model receives exactly -# the configured persona plus the native `bash` and `str_replace_editor` -# schemas; the Web host, browser shell, persistence, and permission stack stay. - -# Match the Claude SWE-compatible RL core prompt. Disabling the Web runtime's -# surface context removes its GUI orientation, managed shell variables, and the -# launcher's source-checkout section through one configuration contract. -# Workspace instructions are model-visible user context rather than a system -# section, but RL core disables them as part of the same prompt contract. -- id: system-prompt - config: - includeHarnessIdentity: false - persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a helpful software engineer assistant.' - -- id: web-runtime - config: - surfaceContext: false - -- id: workspace-context - disabled: true - -- id: tools - config: - mode: native - -# Disable every model-facing consumer in the base/Web tree. plan-mode owns the -# always-registered exit_plan_mode tool even while the session is not planning. -- id: tool-bash - disabled: true - -- id: tool-tasks - disabled: true - -- id: tool-fs - disabled: true - -- id: tool-fs-search - disabled: true - -- id: tool-web - disabled: true - -- id: tool-skill - disabled: true - -- id: plan-mode - disabled: true - -- id: tool-subagent-control - disabled: true - -- id: tool-subagent-list-agents - disabled: true - -- id: tool-subagent - disabled: true - -- id: tool-subagent-fork - disabled: true - -- id: tool-workflow - disabled: true - -- id: tool-todo - disabled: true - -# These consumers are shared defaults on the ordinary shipped surfaces, but -# this opt-in profile keeps exactly its two named tools. -- id: tool-goal - disabled: true - -- id: tool-ralph - disabled: true - -- id: tool-str-replace-editor - disabled: true - -# The matching browser controls must not offer surfaces whose tool this -# overlay omits: the panels would render for a capability the model does not -# have. Turning the row off no longer removes a tool — `ui-question`'s host -# half is empty and `tool-ask-user` is composed per preset — so this is a UI -# decision now, not a capability one. -- id: ui-plan - disabled: true - -- id: ui-question - disabled: true - -- insert: - - id: pty - name: '@deepseek-ai/dsh-pty' - - # This backend consumes the existing Web sandbox and permission policy. - # It loads only on Linux/macOS; Windows and other platforms fail at boot. - # Its 300s send wait matches the persistent Bash command timeout instead of - # pty-local's 30s default. An open persistent shell fences permission-mode - # changes until it closes. - - id: pty-local - name: '@deepseek-ai/dsh-pty-local' - config: - timeoutMs: 300000 - - - id: persistent-bash - name: '@deepseek-ai/dsh-tool-bash-persistent' - config: - timeoutMs: 300000 - - # The editor consumes the Web fs-sandbox provider and therefore retains - # the selected session permission mode. - - id: str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' - config: - maxOutputChars: 16000 diff --git a/apps/cli/package.json b/apps/cli/package.json index a2695f59c9..8b50f180f2 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -27,6 +27,8 @@ "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", + "@deepseek-ai/dsh-cmdline": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-headless": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 6aa39844d0..8f6db85427 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 7f0bd7b0bd50482b3ba7ee95adf6aaf1defb6d28 -README.zh.md: 55f23c5644b8b063ef12fb56fa587ff5d2eb21a2 +README.md: fd0647347312051a1814a5e3464b34032ae70dfc +README.zh.md: afe4b9ba5651e962288ffebbe7c095ad20bcc617 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 7f0bd7b0bd..fd06473473 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -2,17 +2,32 @@ English | [中文](README.zh.md) -This reference defines the profile, one-shot run, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner. +This reference defines the profile, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner. ## Profile boot -`dsh --profile ` boots the profile at `$DSH_HOME/profiles/`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), each `--patch ` overlay in argv order, and launcher flag patches. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. +`dsh --profile ` boots the profile at `$DSH_HOME/profiles/`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch ` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent-walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). -The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + headless). On load, the exact installation-owned headless tuple (base + web-app + headless) normalizes to the shipped template; extra, missing, or reordered bundle lists are user-owned and remain untouched. Any other missing profile fails loud with a hint to run `dsh plugin --profile add `. +The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + headless). Any other missing profile fails loud with a hint to run `dsh plugin --profile add `. -Profile boot accepts no positional task. A profile that mounts the one-shot runner row (`headless-runner`) therefore fails loud with the canonical `dsh run --profile ""` command instead of reaching the row's raw required-field error. +### App arguments + +The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where any injected app plugin may parse it ([`dsh-cmdline`](../../../packages/boot/cmdline/README.md)). `dsh --profile web --port 8080` therefore reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary. + +A composition mounts once. An ordinary plugin injects `cmdlineArgs`, parses this app's arguments, and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the provider's service. A live `cordis.patch.yml` edit re-evaluates expressions against services that are still up, so it cannot reset a served port. + +Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `web` or `plugin` selects that subcommand instead. `ctx.cmdlineArgs.get()` is a shared immutable read: multiple plugins may parse the same snapshot, while a profile with no reader ignores its app arguments. + +The shipped apps own these command lines: + +| Profile | Arguments | +|---|---| +| `web` | `--host`, `--port`, `--dev`, repeatable `--trusted-host` | +| `headless` | the task text, as the positional argument | + +A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port. Inspect the composed tree without booting it: @@ -21,13 +36,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. - -## One-shot run - -`dsh run [--profile ] [--patch ...] ` joins the task arguments with spaces, rejects a missing or blank task, and defaults `--profile` to `headless`. Repeatable `--patch` overlays occupy the same layer position as profile-boot overlays. A custom selected profile must mount `headless-runner`; otherwise launch fails before boot with a diagnostic naming that missing row. - -The launcher patches the task text into the runner row. After Loader settlement, the runner reads the shared `ctx.agentDefaultModel` default, creates one fresh persisted Agent through `ctx.agents`, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port. +`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. A dump never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments. ## Plugin management @@ -43,12 +52,13 @@ Git-hosted plugins that ship sources build during install through their `prepare ## Web alias -`dsh web` is a hardcoded alias for `--profile web` that additionally accepts the Web flag family. `--host`, `--port`, and repeatable `--trusted-host` values become patches over the composed rows; their owning plugin schemas validate them at boot. `--dev` switches the web-runtime row to development mode and inserts the client-plugin HMR receiver; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. +`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--dev` switches the web-runtime row to development mode and enables the client-plugin HMR receiver the bundle ships disabled; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. ```sh dsh web dsh web --patch ./extra.cordis.yml dsh web --dump-config +dsh web --help ``` The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default. Binding all interfaces also trusts the machine's discovered LAN IP literals; `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence. @@ -59,9 +69,7 @@ All modes treat the invoking directory as the default workspace root, load appli New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. -`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. [`config/core-web.cordis.yml`](../config/core-web.cordis.yml) is an optional RL-compatible `--patch` overlay that pins native mode, renders only `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.` as the system prompt, disables Workspace instructions and every Web runtime prompt contribution, and exposes only persistent `bash` and `str_replace_editor` while retaining the shipped host, browser, workspace, persistence, and permission composition. - -`DSH_SYSTEM_PROMPT` is passed as the system-prompt [`persona`](../../../packages/core/system-prompt/README.md#config): complete `{{…}}` groups use that contract's strict variable interpolation rules and have no literal-brace escape; any set value, including an empty string, is authoritative and an empty value therefore removes the system prompt, while only an unset variable selects the fallback. +`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only persistent `bash` plus `str_replace_editor`. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place. ## Shared deployment behavior diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 55f23c5644..afe4b9ba56 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -2,17 +2,32 @@ [English](README.md) | 中文 -本参考定义 profile、一次性运行、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。 +本参考定义 profile、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。 ## Profile 启动 -`dsh --profile ` 启动位于 `$DSH_HOME/profiles/` 的 profile。生效配置树在空根节点之上按以下顺序逐层组合:profile manifest(元数据清单)的 `dsh.profile.bundles` 列表所列的各个组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(各 profile 共享的机器本地偏好,因此优先级高于逐 profile 的层)、按 argv 顺序的各个 `--patch ` overlay,以及启动器 flag patch。后应用的层按行胜出;patch 替换目标行完整的 `config` 值,而不是深度合并各键,并且可以插入新行。配置解析、schema 校验、模块解析或插件启动失败会得到报告并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。 +`dsh --profile ` 启动位于 `$DSH_HOME/profiles/` 的 profile。生效配置树在空根节点之上按以下顺序逐层组合:profile manifest(元数据清单)的 `dsh.profile.bundles` 列表所列的各个组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(各 profile 共享的机器本地偏好,因此优先级高于逐 profile 的层)、以及按 argv 顺序的各个 `--patch ` overlay。后应用的层按行胜出;patch 替换目标行完整的 `config` 值,而不是深度合并各键,并且可以插入新行。配置解析、schema 校验、模块解析或插件启动失败会得到报告并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。 组合包名称先从 dsh 安装解析,再从 profile 目录解析。因此内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`)总是来自与正在运行的 `dsh` 相同的安装;树外组合包来自 profile 由 pnpm 管理的 `node_modules`。任何 patch 行中的裸插件 `name` 通过 profile 目录的 Node 父目录逐级查找解析,该查找可达到持续维护的安装后备目录 `$DSH_HOME/profiles/node_modules`(安装的应用和组合包所依赖的每个包对应一个符号链接,每次启动时修复)。 -`web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + headless)。加载时,与安装所管理的 headless 元组(base + web-app + headless)完全一致的列表会规范化为随附模板;包含额外项、缺少项或调整过顺序的组合包列表由用户拥有,保持不变。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 +`web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + headless)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 -Profile 启动不接受位置参数任务。因此,挂载了一次性运行器行(`headless-runner`)的 profile 会显式报错,并提示规范命令 `dsh run --profile ""`,而不会触发该行原始的必填字段错误。 +### 应用参数 + +启动器自己的 flag 写在最前面,并在它不认识的第一个 token 处结束;从那里开始的一切都通过 `ctx.cmdlineArgs` 原样交给启动起来的 profile,任何注入它的应用插件都可以解析([`dsh-cmdline`](../../../packages/boot/cmdline/README.md))。因此 `dsh --profile web --port 8080` 到达的是 web 应用的 `--port`,`dsh --profile web --help` 打印的是该应用的 help 且什么也不启动,而 `dsh --help`(没有可以交付的 profile)打印的是启动器自己的 help。`-V`/`--version` 写在应用参数边界之前时会打印启动器的版本。 + +一套组合只挂载一次。普通插件注入 `cmdlineArgs`、解析本应用参数,并把结果作为服务提供出去;由 flag 配置的每一行都会注入该服务,Loader 会等服务激活后再求值该行配置(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值。该优先级要求配置行保留这一表达式;若用户 patch 用字面量替换整份 `config`,运行时读取也会随之消失。help 和被拒绝的参数会请求退出——拒绝时以非零状态,help 时以 0——且不会激活依赖提供方服务的行。在线编辑 `cordis.patch.yml` 会针对仍然在线的服务重新求值表达式,因此不会重置已在服务的端口。 + +启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令。`ctx.cmdlineArgs.get()` 是共享的不可变读取:多个插件可以解析同一份快照,没有读取方的 profile 则会忽略自己的应用参数。 + +随附的各应用持有这些命令行: + +| Profile | 参数 | +|---|---| +| `web` | `--host`、`--port`、`--dev`、可重复的 `--trusted-host` | +| `headless` | 任务文本,作为位置参数 | + +一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对 Session 执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。 可在不启动的情况下检查组合出的配置树: @@ -21,13 +36,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。 - -## 一次性运行 - -`dsh run [--profile ] [--patch ...] ` 会用空格拼接任务参数,拒绝缺失或空白任务,并让 `--profile` 默认为 `headless`。可重复使用的 `--patch` overlay 与 profile 启动的 overlay 位于同一层。所选的自定义 profile 必须挂载 `headless-runner`;否则启动器会在启动前失败,并在诊断中指明缺少该行。 - -启动器把任务文本 patch 进运行器行。Loader 结算后,运行器读取共享的 `ctx.agentDefaultModel` 默认值,通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对 Session 执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。 +`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。dump 从不运行应用命令行提供方,因此它展示的是任何应用参数被解析之前的组合配置树,并拒绝携带应用参数的调用。 ## 插件管理 @@ -43,12 +52,13 @@ Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构 ## Web 别名 -`dsh web` 是 `--profile web` 的硬编码别名,并额外接受 Web flag 系列。`--host`、`--port` 和可重复的 `--trusted-host` 值会成为作用在组合行之上的 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 把 web-runtime 行切换到开发模式并插入客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 +`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--dev` 把 web-runtime 行切换到开发模式并启用组合包以禁用状态交付的客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 ```sh dsh web dsh web --patch ./extra.cordis.yml dsh web --dump-config +dsh web --help ``` 生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`。绑定所有接口时,还会信任机器自动发现的 LAN IP 字面量;`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。 @@ -59,9 +69,7 @@ dsh web --dump-config 新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取、网络访问和进程可见性不受限制。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。 -`DSH_TOOLS_MODE` 为进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。[`config/core-web.cordis.yml`](../config/core-web.cordis.yml) 是可选的 RL 兼容 `--patch` overlay:它固定使用 `native` 模式,仅将 `DSH_SYSTEM_PROMPT` 或 `You are a helpful software engineer assistant.` 渲染为系统提示词,禁用 Workspace 指令与所有 Web 运行时提示词贡献,并且在保留随附宿主、浏览器、workspace、持久化和权限组合的同时,仅暴露持久 `bash` 和 `str_replace_editor`。 - -`DSH_SYSTEM_PROMPT` 会传给系统提示词的 [`persona`](../../../packages/core/system-prompt/README.md#config):完整的 `{{…}}` 分组遵循该约定的严格变量插值规则,且无法转义为字面花括号;任何已设置的值(包括空字符串)都具有权威性,因此空值会移除系统提示词,只有未设置该变量时才会选择后备值。 +`DSH_TOOLS_MODE` 为进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合持久 `bash` 和 `str_replace_editor`。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 ## 共享部署行为 diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 97f5222398..27d92dcf66 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -1,31 +1,30 @@ /** - * Commander adapter for the `dsh` command-line entry. The default command - * boots a named profile (`--profile `), optionally with extra `--patch` - * overlays. `run` owns one-shot task execution, defaulting to the headless - * profile; `web` is a hardcoded alias for `--profile web` that adds the Web - * flag family; `plugin` manages a profile's plugin dependencies by forwarding - * to pnpm. Commander owns help, version, and parse errors. + * Commander adapter for the `dsh` command line. + * + * The launcher parses only what it owns — which profile to boot, which extra + * patch overlays to apply, and the config dumps — and hands **everything after + * its own flags** to the booted tree verbatim, where injected app plugins parse + * their own flag families and print their own `--help` (see + * `@deepseek-ai/dsh-cmdline`). Launcher flags therefore come first: the first + * token this parser does not recognize starts the inner arguments, so + * `dsh --profile tui --resume abc` boots the tui profile with `--resume abc`, + * and `dsh --profile web -h` prints the web app's help, not this one's. + * + * `web` is a hardcoded alias for `--profile web`; `plugin` manages a profile's + * plugin dependencies by forwarding to pnpm. * @module @deepseek-ai/dsh/args */ import { Command, CommanderError } from 'commander' -/** Boot a named profile. */ +/** Boot a named profile and hand it the invocation's inner arguments. */ interface ProfileInvocation { mode: 'profile' profile: string /** Extra patch-list overlays applied after the profile's own layer, in argv order. */ patches: string[] -} - -/** Run one task through a profile mounting the headless runner. */ -interface RunInvocation { - mode: 'run' - profile: string - /** Extra patch-list overlays applied after the profile's own layer, in argv order. */ - patches: string[] - /** Non-blank task text joined from the variadic positional arguments. */ - task: string + /** Everything after the launcher's own flags, verbatim, for injected app plugins. */ + args: string[] } /** Print a composed profile tree and exit without booting. */ @@ -37,21 +36,6 @@ interface DumpConfigInvocation { patches: string[] } -/** - * Browser UI: `dsh web` (alias of `--profile web`). Host and port remain - * unvalidated pass-throughs to the webserver schema; absent values leave the - * shipped web bundle values intact. - */ -interface WebInvocation { - mode: 'web' - patches: string[] - host?: string - port?: number - dev: boolean - /** Extra authorities for the /api browser-trust fence. */ - trustedHosts?: string[] -} - /** Manage a profile's plugins: forward `args` to pnpm inside the profile directory. */ interface PluginInvocation { mode: 'plugin' @@ -61,31 +45,63 @@ interface PluginInvocation { } /** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */ -export type DshInvocation = ProfileInvocation | RunInvocation | DumpConfigInvocation | WebInvocation | PluginInvocation +export type DshInvocation = ProfileInvocation | DumpConfigInvocation | PluginInvocation -/** Raw web-subcommand options straight from Commander. */ -interface WebOptions { +/** Launcher flags shared by the default command and the `web` alias. */ +interface BootOptions { patch?: string[] - host?: string - port?: string - dev?: boolean - trustedHost?: string[] dumpConfig?: boolean dumpDefaultConfig?: boolean } -/** Raw run-subcommand options straight from Commander. */ -interface RunOptions { - profile: string - patch?: string[] -} - /** * Repeatable single-value collector: `--patch a.yml --patch b.yml`. Never - * variadic — a variadic `--patch` would swallow a following positional task. + * variadic — a variadic `--patch` would swallow the inner arguments. */ const collect = (value: string, previous: string[] = []): string[] => [...previous, value] +/** The launcher's own help text; each app prints its own. */ +const HELP_EXAMPLES = ` +Examples: + dsh --profile web boot the web profile (same as: dsh web) + dsh --profile headless "run the tests" answer one task, print the result, and exit + dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay + dsh --profile tui --resume arguments after the launcher flags reach the app + dsh --profile web --help the web app's own flags and help + dsh plugin --profile tui add install a plugin into the tui profile +` + +/** + * Resolve a boot or dump invocation from the launcher flags and the leftover + * inner arguments. + * @param program - the command whose options were parsed (the root, or the `web` alias). + * @param profile - the profile these flags boot. + * @param options - the launcher flags commander collected. + * @param args - the leftover arguments, in argv order. + * @returns the resolved invocation. + */ +function resolveBoot(program: Command, profile: string, options: BootOptions, args: string[]): DshInvocation { + const patches = options.patch ?? [] + if (patches.includes('')) program.error('error: --patch needs a path') + if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) { + return { mode: 'profile', profile, patches, args } + } + if (options.dumpConfig === true && options.dumpDefaultConfig === true) { + program.error('error: --dump-config and --dump-default-config are mutually exclusive') + } + // The dump is boot-free: it never runs app command-line providers, so it + // cannot show what those flags would decide, and printing a tree that differs + // from the same invocation's boot would mislead. + if (args.length > 0) { + program.error(`error: config dumps take no app arguments, got ${args.map(argument => JSON.stringify(argument)).join(' ')}`) + } + const defaultOnly = options.dumpDefaultConfig === true + if (defaultOnly && patches.length > 0) { + program.error('error: --dump-default-config prints the bundle layers and takes no --patch') + } + return { mode: 'dump-config', profile, defaultOnly, patches } +} + /** * Resolve argv into one invocation, or print and exit for help, version, or an * error. @@ -95,121 +111,61 @@ const collect = (value: string, previous: string[] = []): string[] => [...previo */ export function parseDshArgs(argv: readonly string[], version: string): DshInvocation { let resolved: DshInvocation | undefined - const program = new Command() + // Annotated, not inferred: the actions below call back into `program`, and an + // inferred type would be circular through its own chain. + const program: Command = new Command() + program .name('dsh') .version(version, '-V, --version', 'output the version number') .description('dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle patch layers under your own overrides.') - .addHelpText('after', ` -Examples: - dsh --profile web boot the web profile (same as: dsh web) - dsh run "run the tests" answer one task, print the result, and exit - dsh run --profile custom "run the tests" run one task through a custom one-shot profile - dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay - dsh plugin --profile tui add install a plugin into the tui profile - dsh web --port 8080 the web alias with its flag family -`) + .addHelpText('after', HELP_EXAMPLES) .exitOverride() + // The launcher's flags come first and end at the first token it does not + // know; everything from there on belongs to the booted app, including + // its -h. `dsh -h` with no profile still prints this help, below. + .helpOption(false) + .allowUnknownOption() + .passThroughOptions() .enablePositionalOptions() + .argument('[args...]', 'arguments for the booted profile\'s app (see: dsh --profile --help)') .option('--profile ', 'the profile under $DSH_HOME/profiles to boot') .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) .option('--dump-config', 'print the composed profile tree and exit') .option('--dump-default-config', 'print the profile tree without its user layer or --patch overlays and exit') - .action((options: { - profile?: string - patch?: string[] - dumpConfig?: boolean - dumpDefaultConfig?: boolean - }) => { - const profile = options.profile ?? program.error('error: --profile is required') - if (profile === '') program.error('error: --profile needs a name') - const patches = options.patch ?? [] - if (patches.includes('')) program.error('error: --patch needs a path') - if (options.dumpConfig === true || options.dumpDefaultConfig === true) { - if (options.dumpConfig === true && options.dumpDefaultConfig === true) { - program.error('error: --dump-config and --dump-default-config are mutually exclusive') - } - const defaultOnly = options.dumpDefaultConfig === true - if (defaultOnly && patches.length > 0) { - program.error('error: --dump-default-config prints the bundle layers and takes no --patch') - } - resolved = { mode: 'dump-config', profile, defaultOnly, patches } - return + .action((args: string[], options: BootOptions & { profile?: string }) => { + // With the app owning -h, the launcher's own help is what a bare + // `dsh -h` (no profile to hand it to) must print. + if (options.profile === undefined) { + if (args.some(argument => argument === '-h' || argument === '--help')) program.help() + program.error('error: --profile is required') } - resolved = { mode: 'profile', profile, patches } + const profile = options.profile + if (profile === '') program.error('error: --profile needs a name') + resolved = resolveBoot(program, profile, options, args) }) /** Reject parent options supplied before a subcommand. */ const rejectParentOptions = (command: string): void => { - const parent = program.opts<{ - profile?: string - patch?: string[] - dumpConfig?: boolean - dumpDefaultConfig?: boolean - }>() + const parent = program.opts() if (parent.profile !== undefined || parent.patch !== undefined || parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined) { program.error(`error: ${command} takes none of parent --profile, --patch, --dump-config, or --dump-default-config`) } } - const run = program.command('run').description('run one task through a profile mounting the headless runner') - run - .option('--profile ', 'one-shot profile under $DSH_HOME/profiles', 'headless') - .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) - .argument('', 'task text') - .action((task: string[], options: RunOptions) => { - rejectParentOptions('run') - const profile = options.profile - if (profile === '') program.error('error: --profile needs a name') - const patches = options.patch ?? [] - if (patches.includes('')) program.error('error: --patch needs a path') - const joined = task.join(' ') - if (joined.trim() === '') program.error('error: run needs a non-blank task') - resolved = { mode: 'run', profile, patches, task: joined } - }) - - const web = program.command('web').description('serve the browser UI (alias of --profile web) on the configured host and port') + const web = program.command('web').description('boot the web profile (alias of --profile web); the web app\'s own flags follow') web + .helpOption(false) + .allowUnknownOption() + .passThroughOptions() + .enablePositionalOptions() + .argument('[args...]', 'arguments for the web app (see: dsh web --help)') .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) - .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') - .option('--port ', 'listen port; pass 0 to let the OS pick a free one') - .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') - .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .option('--dump-config', 'print the composed web-profile tree (with the user layer and any --patch) and exit') .option('--dump-default-config', 'print the web profile\'s bundle layers (no user layer) and exit') - .action((options: WebOptions) => { + .action((args: string[], options: BootOptions) => { rejectParentOptions('web') - const patches = options.patch ?? [] - if (patches.includes('')) program.error('error: --patch needs a path') - if (options.dumpConfig === true || options.dumpDefaultConfig === true) { - if (options.dumpConfig === true && options.dumpDefaultConfig === true) { - program.error('error: --dump-config and --dump-default-config are mutually exclusive') - } - const defaultOnly = options.dumpDefaultConfig === true - if (defaultOnly && patches.length > 0) { - program.error('error: --dump-default-config prints the bundle layers and takes no --patch') - } - // The dump is boot-free and does not derive flag patches; silently - // dropping them would print a tree that differs from the same - // invocation's boot. - if (options.host !== undefined || options.port !== undefined || options.dev === true - || options.trustedHost !== undefined) { - program.error('error: config dumps take no web flags (--host/--port/--dev/--trusted-host)') - } - resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches } - return - } - if (options.port !== undefined && !/^\d+$/.test(options.port)) { - program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) - } - resolved = { - mode: 'web', - patches, - ...options.host !== undefined && { host: options.host }, - ...options.port !== undefined && { port: Number(options.port) }, - dev: options.dev === true, - ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost }, - } + resolved = resolveBoot(web, 'web', options, args) }) const plugin = program.command('plugin').description('manage a profile\'s plugins by forwarding the remaining arguments to pnpm in the profile directory') diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index b332a64615..9aa44f8b22 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -33,24 +33,10 @@ switch (invocation.mode) { environment: loadLayeredEnv('dsh'), profile: invocation.profile, patchFiles: invocation.patches, + args: invocation.args, }) break } - case 'run': { - const { runProfile } = await import('./profile-boot.ts') - await runProfile({ - environment: loadLayeredEnv('dsh'), - profile: invocation.profile, - patchFiles: invocation.patches, - task: invocation.task, - }) - break - } - case 'web': { - const { runWeb } = await import('./web.ts') - await runWeb(invocation, loadLayeredEnv('dsh')) - break - } case 'plugin': { const { runPlugin } = await import('./plugin.ts') process.exit(runPlugin(invocation.profile, invocation.args)) diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 8c37af124a..f3c356f199 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -1,9 +1,13 @@ /** * Shared profile boot for every `dsh` surface: resolve the profile, stack its - * patch layers (bundle layers in `dsh.profile.bundles` order, the profile's own - * `cordis.patch.yml`, `--patch` overlays, flag-derived patches, the telemetry - * switch), mount the tree over the profile's empty root config, keep the - * profile patch layer live, and wire fail-loud plus bounded shutdown. + * patch layers (bundle layers in `dsh.profile.bundles` order, the profile's + * own `cordis.patch.yml`, `--patch` overlays, the telemetry switch), mount the + * tree over the profile's empty root config, keep the profile patch layer + * live, and wire fail-loud plus bounded shutdown. + * + * App flags are not the launcher's business: the invocation's inner arguments + * are provided to the tree through `ctx.cmdlineArgs`, where any injected app + * plugin may read the same immutable snapshot. * @module @deepseek-ai/dsh/profile-boot */ @@ -12,7 +16,7 @@ import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { FiberState, type Context } from '@deepseek-ai/cordis' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import { dshHomePath } from '@deepseek-ai/dsh-paths' +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { boot, composeEntries, @@ -25,7 +29,7 @@ import { watchUserPatches, type Profile, } from '@deepseek-ai/dsh-app-boot' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' /** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */ const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url)) @@ -33,6 +37,7 @@ const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', im /** Harness-home directory holding locally authored agent presets. */ const USER_PRESET_DIR = '.agent-presets' import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' +import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import type { HeadlessIo } from '@deepseek-ai/dsh-headless' import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' import { resolveWindowsShellLayer } from './windows-shell.ts' @@ -55,7 +60,7 @@ export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.me /** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */ const TELEMETRY_ROW_ID = 'telemetry-otel' -/** The one-shot runner row a `dsh run` task requires and configures. */ +/** The one-shot runner row: its presence means this composition exits by itself. */ const HEADLESS_ROW_ID = 'headless-runner' /** The empty root entry list every profile tree patches over. */ @@ -104,9 +109,6 @@ export function prepareProfile(name: string, userLayer = true): Profile { return profile } -/** Read-only row index of a profile composition before launcher flag patches. */ -export type ProfileRows = ReadonlyMap - /** One profile's patch layers (application order) and the row index of its pre-flag composition. */ interface ComposedProfile { profile: Profile @@ -116,14 +118,13 @@ interface ComposedProfile { windowsShellPatches: PatchOptions[] /** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */ homePatches: PatchOptions[] - /** Layers above the user layers on a live reload: --patch overlays, flag patches, the telemetry switch. */ - overlayAndFlags: PatchOptions[] + /** Layers above the user layers on a live reload: `--patch` overlays and the telemetry switch. */ + overlays: PatchOptions[] /** - * id → row of the pre-flag composition (bundles + user layers + overlays), - * for flag merges and row checks. Flag patches must not insert rows the - * launcher consults here (they only override values and insert dev glue). + * id → row of the composed tree (bundles + user layers + overlays), for the + * launcher's own row checks. */ - rows: ProfileRows + rows: ReadonlyMap } /** The full patch stack of one composed profile, in application order. */ @@ -133,7 +134,7 @@ function allPatches(composed: ComposedProfile): PatchOptions[] { ...composed.windowsShellPatches, ...composed.profile.patches, ...composed.homePatches, - ...composed.overlayAndFlags, + ...composed.overlays, ] } @@ -143,36 +144,28 @@ function allPatches(composed: ComposedProfile): PatchOptions[] { * is Windows), the profile's user layer, the home-level user layer * (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply to * every profile, so it outranks the per-profile layer), `--patch` overlays, - * then flag patches derived from the composed rows, then the telemetry - * switch. + * then the telemetry switch. * @param name - the profile name. * @param patchFiles - `--patch` overlay paths, in argv order. - * @param deriveFlagPatches - launcher hook turning composed rows into flag patches. * @returns the profile, its patch layers, and the composed row index. */ function composeProfile( name: string, patchFiles: readonly string[], - deriveFlagPatches: (rows: ComposedProfile['rows']) => PatchOptions[] = () => [], ): ComposedProfile { const profile = prepareProfile(name) const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? [] const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file))) const bundlePatches = profile.layers.flatMap(layer => layer.patches) const windowsShellPatches = resolveWindowsShellLayer(process.platform, profile.layers, NAME)?.patches ?? [] - const rows = new Map() + const rows = new Map() for (const row of composeEntries([bundlePatches, windowsShellPatches, profile.patches, homePatches, overlays])) { if (typeof row.id === 'string') rows.set(row.id, row) } - const overlayAndFlags = [...overlays, ...deriveFlagPatches(rows)] - // The agent-preset roots are an assembly fact of every dsh launcher, not a - // patch author's choice: the shipped set sits beside this app's config and - // the user's own under the Harness home. Resolved per boot ($DSH_HOME may - // differ per run) and only patched when the composed tree actually mounts - // the roster — a one-shot `dsh run` composes agents from the same roster - // `dsh web` offers. + const composedOverlays = [...overlays] + // Preset roots belong to every dsh composition that mounts the roster. if (rows.has('agent-presets')) { - overlayAndFlags.push({ + composedOverlays.push({ id: 'agent-presets', config: { ...(rows.get('agent-presets')?.config ?? {}) as Record, @@ -184,24 +177,20 @@ function composeProfile( }) } const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) - if (telemetryPatch !== undefined) overlayAndFlags.push(telemetryPatch) - return { profile, bundlePatches, windowsShellPatches, homePatches, overlayAndFlags, rows } + if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch) + return { profile, bundlePatches, windowsShellPatches, homePatches, overlays: composedOverlays, rows } } /** Options for {@link runProfile}. */ export interface RunProfileOptions { + /** This run's frozen environment snapshot, provided before any entry mounts. */ + environment: EnvironmentSnapshot /** The profile name to boot. */ profile: string /** `--patch` overlay paths, in argv order. */ patchFiles: readonly string[] - /** Launcher hook turning the pre-flag composed rows into flag patches (the web alias's flag family). */ - deriveFlagPatches?: (rows: ProfileRows) => PatchOptions[] - /** `dsh run` task text; requires the composition to mount the headless runner row. */ - task?: string - /** Surface setup registered after Loader installation and before any config-tree entry mounts. */ - prepare?: (ctx: Context, rows: ProfileRows) => Promise | void - /** This run's frozen environment snapshot, provided to the tree before any entry mounts. */ - environment: EnvironmentSnapshot + /** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */ + args: readonly string[] } /** Re-throw setup failures unless this invocation's signal already owns shutdown. */ @@ -211,29 +200,16 @@ function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void /** * Boot one profile invocation end to end and leave process lifetime to the - * mounted plugins (or to the one-shot runner when `task` is present). - * @param options - profile name, overlays, flag patches, and the optional task. + * mounted plugins (or to a one-shot runner the composition mounts). + * @param options - environment snapshot, profile name, overlays, and the booted app's own arguments. * @returns the settled root context and the shutdown controller. */ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> { - const composed = composeProfile(options.profile, options.patchFiles, options.deriveFlagPatches) - if (options.task !== undefined) { - if (!composed.rows.has(HEADLESS_ROW_ID)) { - throw new Error( - `dsh: profile ${JSON.stringify(options.profile)} takes no task — its composition mounts no "${HEADLESS_ROW_ID}" row ` - + '(the headless profile does)', - ) - } - composed.overlayAndFlags.push({ id: HEADLESS_ROW_ID, config: { task: options.task } }) - } else if (composed.rows.has(HEADLESS_ROW_ID)) { - // The inverse misuse: a one-shot composition booted without its task - // would otherwise die in the runner row's schema with a raw "required" - // error naming no fix. - throw new Error( - `dsh: profile ${JSON.stringify(options.profile)} mounts the one-shot runner and needs a task: ` - + `dsh run --profile ${options.profile} ""`, - ) - } + const composed = composeProfile(options.profile, options.patchFiles) + // A one-shot composition ends by itself, which changes what a signal means + // and makes watching the user's patch layer pointless. + const headlessRow = composed.rows.get(HEADLESS_ROW_ID) + const oneShot = headlessRow !== undefined && headlessRow.disabled !== true const app: { current?: Context } = {} const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() }) @@ -243,9 +219,8 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con shutdown.interrupt(code) } // Signals own teardown throughout the startup window, not only after boot() - // settles: an inserted entry point can publish readiness before sibling rows - // finish mounting. - process.on('SIGTERM', () => { interrupt(options.task === undefined ? 0 : 143) }) + // settles: an inserted provider can publish before sibling rows finish mounting. + process.on('SIGTERM', () => { interrupt(oneShot ? 143 : 0) }) process.on('SIGINT', () => { interrupt(130) }) installFailLoud(NAME, process, async () => { await app.current?.fiber.dispose() @@ -253,7 +228,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME) // Recomposition for the live user layers: bundle layers below, overlays - // and flag patches above, so a user edit can never displace them. BOTH + // above, so a user edit can never displace them. Parsed app arguments are + // not in here at all — they live in app-provided services that survive a + // recomposition. BOTH // user files are re-read per generation (the HMR watcher hands us only the // changed file's patches, which one of the reads duplicates — fresh reads // keep the two watchers from stitching in each other's stale copy). @@ -267,19 +244,25 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con ...composed.windowsShellPatches, ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [], ...loadOptionalPatches(NAME, homePatchPath()) ?? [], - ...composed.overlayAndFlags, + ...composed.overlays, ]) // One-shot runs exit through the runner; watching would only hold the // process open after its exit request. - const watchProfilePatch = options.task === undefined + const watchProfilePatch = !oneShot // Cloned for the same insert-aliasing reason as composeLive: the boot // application must not mutate the objects later reloads recompose from. - const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => { + const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => { app.current = hostCtx - // Before any config-tree entry mounts, so a plugin that resolves a - // user-facing value at construction already sees this run's layers. + // Before any config-tree entry mounts, so plugins resolve all launch-time + // environment values from the same immutable provenance snapshot. hostCtx.provide(DSH_ENVIRONMENT_KEY, options.environment) - if (options.task !== undefined) { + // The command line and bounded exit request are launcher facts available + // to every app plugin that injects the argument snapshot. + provideCmdline(hostCtx, { + args: options.args, + exit: code => void shutdown.shutdown(code), + }) + if (oneShot) { const io: HeadlessIo = { stdout: process.stdout, stderr: process.stderr, @@ -287,11 +270,10 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con } hostCtx.provide('headlessIo', io) } - await options.prepare?.(hostCtx, composed.rows) }) app.current = ctx - // A surface can dispose the whole tree while startup or this post-boot - // watcher setup is still in flight. Loader presence and fiber state own + // A surface can dispose the whole tree while boot or this post-boot watcher + // setup is still in flight. Loader presence and fiber state own // liveness; the local signal fact distinguishes that expected exit race // from a real HMR error. if (watchProfilePatch diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts deleted file mode 100644 index 8d056d100b..0000000000 --- a/apps/cli/src/web.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * `dsh web` — the browser-surface alias over the profile boot: `--profile web` - * plus the Web flag family (`--host/--port/--dev/--trusted-host`), each flag - * becoming a patch over the composed profile - * tree. All web runtime glue (dist serving, prompt section, URL line) lives - * in the `@deepseek-ai/dsh-web-app` bundle; this launcher only derives - * flag patches and the LAN-trust snapshot. - * @module @deepseek-ai/dsh/web - */ - -import { networkInterfaces } from 'node:os' -import { fileURLToPath } from 'node:url' -import type { Context } from '@deepseek-ai/cordis' -import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' -import type { EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' -import { runProfile, type ProfileRows } from './profile-boot.ts' - -const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) - -/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation. */ -const ALL_INTERFACES_HOST = '0.0.0.0' - -/** - * Non-internal IPv4 interface addresses of this machine — the IP-literal - * authorities an all-interfaces bind is reachable by on the LAN. - * @returns the addresses in interface order (possibly empty). - */ -function lanIPv4Addresses(): string[] { - return Object.values(networkInterfaces()).flat() - .filter((iface): iface is NonNullable => iface !== undefined && iface.family === 'IPv4' && !iface.internal) - .map(iface => iface.address) -} - -/** - * One LAN-trust resolution for one invocation, sampled exactly once: the - * machine's LAN IP literals when the effective bind is all-interfaces, and - * the `trustedHosts` value built from them plus the explicit extras. The - * single sample is deliberate — display must advertise only addresses the - * fence was configured with, so the web-app row receives this same snapshot. - * Derived entries are port-less IP literals: DNS rebinding needs an - * attacker-controlled name, so an IP-literal Host is safe on any port, and - * the bound port may be OS-assigned, unknowable pre-boot. - * @param bindHost - the effective webserver bind host (CLI flag, else the composed row value). - * @param extra - `--trusted-host` values, in argv order. - * @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty). - */ -export function resolveLanTrust( - bindHost: string | undefined, - extra: readonly string[], -): { lanAddresses: string[]; trustedHosts: string[] } { - const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : [] - return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } -} - -/** The `dsh web` flag family, already parsed by the argument adapter. */ -export interface WebFlags { - patches: string[] - host?: string - port?: number - dev: boolean - trustedHosts?: string[] -} - -/** - * Derive the web alias's flag patches over an already-composed profile tree. - * Patches replace a row's whole config, so each patched row's composed values - * are re-read and merged under the overrides. - * @param rows - the composed row index from {@link composeProfile}. - * @param flags - the parsed flag family. - * @returns the flag patch list, in application order. - */ -function deriveWebFlagPatches( - rows: ProfileRows, - flags: WebFlags, -): PatchOptions[] { - const overrides = new Map>() - const put = (entryId: string, key: string, value: unknown): void => { - const bag = overrides.get(entryId) ?? {} - bag[key] = value - overrides.set(entryId, bag) - } - if (flags.host !== undefined) put('webserver', 'host', flags.host) - if (flags.port !== undefined) put('webserver', 'port', flags.port) - const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host - const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? []) - if (trustedHosts.length > 0) { - // Additive over the composed value: a cordis.patch.yml-configured fence - // authority must survive the derived LAN literals and flag extras — a - // silent drop of security-relevant fence configuration. - const composedTrusted = (rows.get('connection')?.config as { trustedHosts?: string[] } | undefined)?.trustedHosts ?? [] - put('connection', 'trustedHosts', [...composedTrusted, ...trustedHosts]) - } - // mode and lanAddresses are launcher-derived on every boot (--dev also - // inserts the client-hmr row), never pass-throughs of composed values. - put('web-runtime', 'mode', flags.dev ? 'development' : 'production') - put('web-runtime', 'lanAddresses', lanAddresses) - // The agent-preset roots are patched by the shared profile boot: they are - // an assembly fact of every dsh launcher, and `dsh run` composes agents - // from the same roster this alias offers. - const patches = [...overrides.entries()].map(([id, bag]): PatchOptions => { - const composed = rows.get(id) - if (composed === undefined) throw new Error(`dsh: patch target row "${id}" not found in the web profile composition`) - return { id, config: { ...(composed.config ?? {}) as Record, ...bag } } - }) - if (flags.dev) patches.push({ insert: [{ id: 'client-hmr', name: '@deepseek-ai/dsh-client-hmr' }] }) - return patches -} - -/** - * Whether the composed Web runtime keeps its model- and shell-visible surface - * context. The bundle schema defaults the field to true, so only an explicit - * false suppresses both the bundle contributions and the launcher-owned - * source-checkout section. - * @param rows - the composed Web profile rows before launcher flag patches. - * @returns true unless the web-runtime row explicitly disables surface context. - */ -export function webSurfaceContextEnabled(rows: ProfileRows): boolean { - return (rows.get('web-runtime')?.config as { surfaceContext?: boolean } | undefined)?.surfaceContext !== false -} - -/** - * Serve the browser UI from the web profile. Host/port flags are passed - * through only when given (absent, the composed profile values - * stand); `web-runtime.mode` and `lanAddresses` are launcher-derived on - * every boot. The URL line is printed by the web-app bundle's runtime row - * after Loader settlement. - * @param flags - the parsed `dsh web` flag family. - * @param environment - this run's frozen environment snapshot. - */ -export async function runWeb(flags: WebFlags, environment: EnvironmentSnapshot): Promise { - await runProfile({ - environment, - profile: 'web', - patchFiles: flags.patches, - deriveFlagPatches: rows => deriveWebFlagPatches(rows, flags), - prepare: (ctx: Context, rows: ProfileRows) => { - if (!webSurfaceContextEnabled(rows)) return - ctx.inject(['systemPrompt'], (promptCtx) => { - addHarnessSourceSection(promptCtx, SOURCE_ROOT) - }) - }, - }) -} diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index e30739214d..89a16921b2 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -21,22 +21,28 @@ function exitCode(argv: string[]): number { afterEach(() => { vi.restoreAllMocks() }) describe('parseDshArgs', () => { - it('routes profile boots, one-shot runs, and the web alias', () => { - expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [] }) + it('routes profile boots and the web alias, handing the rest to the app', () => { + expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [], args: [] }) expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--patch', 'b.yml'])) - .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'] }) - expect(parse(['run', 'run', 'the', 'tests'])) - .toEqual({ mode: 'run', profile: 'headless', patches: [], task: 'run the tests' }) - expect(parse(['run', '--profile', 'custom', '--patch', 'a.yml', '--patch', 'b.yml', 'run', 'the', 'tests'])) - .toEqual({ mode: 'run', profile: 'custom', patches: ['a.yml', 'b.yml'], task: 'run the tests' }) - expect(parse(['run', '--', '--profile', 'is', 'task', 'text'])) - .toEqual({ mode: 'run', profile: 'headless', patches: [], task: '--profile is task text' }) - expect(parse(['web'])).toEqual({ mode: 'web', dev: false, patches: [] }) - expect(parse(['web', '--patch', 'web.yml'])).toEqual({ mode: 'web', dev: false, patches: ['web.yml'] }) + .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'], args: [] }) + expect(parse(['web'])).toEqual({ mode: 'profile', profile: 'web', patches: [], args: [] }) + expect(parse(['web', '--patch', 'web.yml'])) + .toEqual({ mode: 'profile', profile: 'web', patches: ['web.yml'], args: [] }) + }) + + it('ends the launcher flags at the first token it does not own', () => { + // App flags, including its -h, and positionals reach the app verbatim. + expect(parse(['--profile', 'tui', '--resume', 'abc'])) + .toEqual({ mode: 'profile', profile: 'tui', patches: [], args: ['--resume', 'abc'] }) + expect(parse(['--profile', 'web', '-h'])) + .toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['-h'] }) expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev'])) - .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, patches: [] }) - expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) - .toEqual({ mode: 'web', dev: false, patches: [], trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) + .toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['--host', '0.0.0.0', '--port', '8080', '--dev'] }) + expect(parse(['--profile', 'headless', 'run', 'the', 'tests'])) + .toEqual({ mode: 'profile', profile: 'headless', patches: [], args: ['run', 'the', 'tests'] }) + // Launcher flags placed after that boundary belong to the app too. + expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--resume', 'b', '--patch', 'late.yml'])) + .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml'], args: ['--resume', 'b', '--patch', 'late.yml'] }) }) it('routes the plugin pnpm forwarder', () => { @@ -64,18 +70,12 @@ describe('parseDshArgs', () => { .toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: true, patches: [] }) }) - it('rejects missing profile, flags outside the current grammar, and contradictory inputs', () => { + it('rejects missing profile, removed flags, and contradictory inputs', () => { expect(exitCode([])).toBe(1) - expect(exitCode(['tui'])).toBe(1) // a bare word is a task without --profile - expect(exitCode(['--config', 'c.yml'])).toBe(1) // outside the current grammar - expect(exitCode(['-p', 'task'])).toBe(1) // outside the current grammar - expect(exitCode(['--profile', 'headless', 'task'])).toBe(1) // tasks belong to `run` - expect(exitCode(['run'])).toBe(1) - expect(exitCode(['run', ''])).toBe(1) - expect(exitCode(['run', '--profile', '', 'task'])).toBe(1) - expect(exitCode(['run', '--patch=', 'task'])).toBe(1) - expect(exitCode(['--profile', 'headless', 'run', 'task'])).toBe(1) - expect(exitCode(['--patch', 'parent.yml', 'run', 'task'])).toBe(1) + expect(exitCode(['tui'])).toBe(1) // an app argument without --profile has no app to reach + expect(exitCode(['--config', 'c.yml'])).toBe(1) // removed + expect(exitCode(['-p', 'task'])).toBe(1) // removed + expect(exitCode(['run', 'task'])).toBe(1) // app-owned task replaced the launcher subcommand expect(exitCode(['--profile', ''])).toBe(1) expect(exitCode(['--profile', 'x', '--patch='])).toBe(1) expect(exitCode(['--dump-config'])).toBe(1) @@ -87,21 +87,20 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1) expect(exitCode(['web', '--dump-default-config', '--patch', 'w.yml'])).toBe(1) expect(exitCode(['web', '--patch='])).toBe(1) - // Boot-free dumps derive no flag patches; silently dropping the flags - // would print a tree that differs from the same invocation's boot. + // A dump never runs app command-line providers, so it cannot show what + // those flags would decide; printing a tree that differs from the same + // invocation's boot would mislead. expect(exitCode(['web', '--dump-config', '--port', '8080'])).toBe(1) - expect(exitCode(['web', '--dump-config', '--dev'])).toBe(1) - // A non-numeric port fails at the flag, not deep in the webserver schema. - expect(exitCode(['web', '--port', 'abc'])).toBe(1) + expect(exitCode(['--profile', 'web', '--dump-config', '-h'])).toBe(1) expect(exitCode(['plugin', 'add', 'x'])).toBe(1) // --profile required expect(exitCode(['plugin', '--profile', 'tui'])).toBe(1) // nothing to forward expect(exitCode(['plugin', '--profile', ''])).toBe(1) expect(exitCode(['--profile', 'x', 'plugin', 'add', 'y'])).toBe(1) }) - it('exits 0 for help and version', () => { + it('keeps its own help for an invocation with no app to hand it to', () => { expect(exitCode(['--help'])).toBe(0) - expect(exitCode(['run', '--help'])).toBe(0) + expect(exitCode(['-h'])).toBe(0) expect(exitCode(['--version'])).toBe(0) }) }) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index b81f837cf8..015a729d5a 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -9,7 +9,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' /** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') -const coreWebOverlay = fileURLToPath(new URL('../config/core-web.cordis.yml', import.meta.url)) const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url)) async function runBuiltBin( @@ -129,8 +128,8 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture { return { home, ready, settled, disposed, interrupt } } -function startProfileLifecycle(fixture: ProfileLifecycleFixture) { - return execa(process.execPath, [dshBin, '--profile', 'lifecycle'], { +function startProfileLifecycle(fixture: ProfileLifecycleFixture, args: readonly string[] = []) { + return execa(process.execPath, [dshBin, '--profile', 'lifecycle', ...args], { cwd: fixture.home, input: '', reject: false, @@ -145,8 +144,8 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture) { } function requestProfileShutdown( - child: ReturnType, - fixture: ProfileLifecycleFixture, + child: Pick, 'kill'>, + fixture: Pick, ): void { if (process.platform === 'win32') { writeFileSync(fixture.interrupt, 'interrupt') @@ -194,8 +193,121 @@ function createEnvironmentProbeProfile(home: string, project: string): void { ].join('\n')) } +interface StartupFixture { + home: string + ready: string + echo: string + interrupt: string + /** An always-running row's echo, used to observe that a user patch reload landed. */ + witness: string +} + +/** + * A custom profile whose ordinary provider plugin injects `cmdlineArgs`, plus + * a row that reads its app-owned service through a `!!js` config expression. + * Both plugin modules resolve + * `@deepseek-ai/dsh-cmdline` and `commander` through the profile module + * fallback, exactly as an installed out-of-tree bundle does. + */ +function createStartupFixture(): StartupFixture { + const home = mkdtempSync(join(tmpdir(), 'dsh-profile-startup-')) + const profileDir = join(home, 'profiles', 'startup') + // Written straight into the installed location: a row module resolves its + // own imports from where it is installed, and only inside the profile does + // Node's parent walk reach the installation fallback these plugins need. + const bundleDir = join(profileDir, 'node_modules', 'dsh-startup-bundle') + mkdirSync(bundleDir, { recursive: true }) + writeFileSync(join(bundleDir, 'startup.mjs'), [ + "import { Command } from 'commander'", + "import { parseCmdline } from '@deepseek-ai/dsh-cmdline'", + "export const name = 'fixture-startup'", + "export const inject = ['cmdlineArgs']", + 'export function apply(ctx) {', + " const program = new Command().name('fixture').option('--generation ', 'echoed generation')", + ' const values = parseCmdline(ctx, program, parsed => ({ generation: parsed.opts().generation }))', + ' if (values !== undefined) ctx.provide(\'fixtureStartup\', values)', + '}', + '', + ].join('\n')) + writeFileSync(join(bundleDir, 'waiting.mjs'), [ + "import { existsSync, writeFileSync } from 'node:fs'", + "import { join } from 'node:path'", + "export const name = 'startup-fixture'", + 'export function apply(ctx, config = {}) {', + ' let interrupted = false', + ' const heartbeat = setInterval(() => {', + ' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return', + ' interrupted = true', + " process.emit('SIGTERM')", + ' }, 20)', + " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))", + " writeFileSync(process.env.RAW_READY_FILE, 'ready')", + ' ctx.effect(() => () => { clearInterval(heartbeat) })', + '}', + '', + ].join('\n')) + writeFileSync(join(bundleDir, 'witness.mjs'), [ + "import { writeFileSync } from 'node:fs'", + "import { join } from 'node:path'", + "export const name = 'reload-witness'", + 'export function apply(ctx, config = {}) {', + " writeFileSync(join(process.env.DSH_HOME, 'witness'), String(config.generation ?? 'bundle-default'))", + '}', + '', + ].join('\n')) + writeFileSync(join(bundleDir, 'cordis.patch.yml'), [ + '- insert:', + ' - id: startup-fixture', + ` name: ${pathToFileURL(join(bundleDir, 'waiting.mjs')).href}`, + ' inject: [fixtureStartup]', + ' config:', + // Lazy interpolation runs only after the provider's service is injected. + " generation: !!js ctx.fixtureStartup.generation ?? 'bundle-default'", + ' - id: fixture-startup', + ` name: ${pathToFileURL(join(bundleDir, 'startup.mjs')).href}`, + ' - id: reload-witness', + ` name: ${pathToFileURL(join(bundleDir, 'witness.mjs')).href}`, + '', + ].join('\n')) + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({ + name: 'dsh-startup-bundle', + version: '0.0.0', + type: 'module', + dsh: { bundle: { patch: './cordis.patch.yml' } }, + }, undefined, 2)) + writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ + name: 'dsh-profile-startup', + private: true, + dependencies: {}, + dsh: { profile: { bundles: ['dsh-startup-bundle'] } }, + }, undefined, 2)) + writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n') + return { + home, + ready: join(home, 'ready'), + echo: join(home, 'config-echo'), + interrupt: join(home, 'interrupt'), + witness: join(home, 'witness'), + } +} + +function startStartupProfile(fixture: StartupFixture, args: readonly string[]) { + return execa(process.execPath, [dshBin, '--profile', 'startup', ...args], { + cwd: fixture.home, + input: '', + reject: false, + timeout: 25_000, + killSignal: 'SIGKILL', + env: { + DSH_HOME: fixture.home, + RAW_READY_FILE: fixture.ready, + RAW_INTERRUPT_FILE: fixture.interrupt, + }, + }) +} + describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => { - it('requires --profile and rejects inputs outside the current grammar', async () => { + it('requires --profile and rejects removed commands', async () => { const bare = await runBuiltBin() expect(bare.code).toBe(1) expect(bare.stdout).toBe('') @@ -203,46 +315,63 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const help = await runBuiltBin(['--help']) expect(help.code).toBe(0) expect(help.stdout).toContain('dsh --profile web') - expect(help.stdout).toContain('dsh run "run the tests"') expect(help.stdout).toContain('dsh plugin --profile') expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu) - for (const outsideGrammar of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['--profile', 'headless', 'task']]) { - const result = await runBuiltBin(outsideGrammar) + for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['run', 'task']]) { + const result = await runBuiltBin(removed) expect(result.code).toBe(1) } }, 30_000) - it('prints run help without initializing the selected profile', async () => { - const parent = mkdtempSync(join(tmpdir(), 'dsh-run-help-')) - const home = join(parent, 'not-created') + it('routes help and usage errors without activating startup-dependent rows', async () => { + const home = mkdtempSync(join(tmpdir(), 'dsh-app-help-')) try { - const result = await runBuiltBin(['run', '--help'], { DSH_HOME: home }) - expect(result.code).toBe(0) - expect(result.stderr).toBe('') - expect(result.stdout).toContain('Usage: dsh run [options] ') - expect(existsSync(home)).toBe(false) - } finally { - rmSync(parent, { recursive: true, force: true }) - } - }) + const web = await runBuiltBin(['--profile', 'web', '--help'], { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + }) + expect(web.code).toBe(0) + expect(web.stderr).toBe('') + expect(web.stdout).toContain('Usage: dsh --profile web') + expect(web.stdout).toContain('--port ') + expect(web.stdout).not.toContain('dsh web: http://') - it('runs the default headless profile through the published run command', async () => { - const apiKey = 'built-dsh-run-key' + const headlessHelp = await runBuiltBin(['--profile', 'headless', '--help'], { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + }) + expect(headlessHelp.code).toBe(0) + expect(headlessHelp.stderr).toBe('') + expect(headlessHelp.stdout).toContain('Usage: dsh --profile headless') + + const missingTask = await runBuiltBin(['--profile', 'headless'], { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + }) + expect(missingTask.code).toBe(1) + expect(missingTask.stderr).toContain('a task is required') + } finally { + rmSync(home, { recursive: true, force: true }) + } + }, 30_000) + + it('runs the headless profile through its app-owned task positional', async () => { + const apiKey = 'built-dsh-headless-key' const server = await startMockLlmServer({ sequence: ['success'], apiKey, - successText: 'published dsh run reached the mock', + successText: 'published headless profile reached the mock', }) - const home = mkdtempSync(join(tmpdir(), 'dsh-built-run-')) + const home = mkdtempSync(join(tmpdir(), 'dsh-built-headless-')) try { - const result = await runBuiltBin(['run', 'answer', 'from', 'the', 'published', 'entry'], { + const result = await runBuiltBin(['--profile', 'headless', 'answer', 'from', 'the', 'published', 'entry'], { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: apiKey, DEEPSEEK_BASE_URL: server.baseURL, }) expect(result.code, result.stderr).toBe(0) - expect(result.stdout).toBe('published dsh run reached the mock') + expect(result.stdout).toBe('published headless profile reached the mock') expect(result.stderr).toBe('') expect(server.requests.length).toBeGreaterThan(0) expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true) @@ -318,9 +447,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', }, 30_000) it('reports a patch-overlay boot failure without hanging', async () => { - // An HMR main-watcher initial scan that refreshes the include - // mid-initial-apply deadlocks the failing apply's rollback against the - // refresh drain: dsh exits 13 with no diagnostic instead of settling + // The HMR main watcher's initial scan once refreshed the include + // mid-initial-apply, deadlocking the failing apply's rollback against the + // refresh drain: dsh exited 13 with no diagnostic instead of settling // ([Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md)). const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-')) try { @@ -337,9 +466,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, 30_000) - it('applies a custom profile bundle and disposes it on a startup-time signal', async () => { + it('lets a profile without a parser ignore app arguments and dispose on a startup-time signal', async () => { const fixture = createProfileLifecycleFixture() - const child = startProfileLifecycle(fixture) + const child = startProfileLifecycle(fixture, ['--unclaimed']) try { await waitForFile(fixture.ready) requestProfileShutdown(child, fixture) @@ -405,6 +534,83 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, 30_000) + it('hands the app arguments to the profile, which applies them before its rows start', async () => { + const fixture = createStartupFixture() + const child = startStartupProfile(fixture, ['--generation', 'flagged']) + try { + await waitForFile(fixture.ready) + // The consumer started once, already carrying the flag value: the + // launcher never saw --generation, and the app provider resolved it first. + expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged') + requestProfileShutdown(child, fixture) + expect((await child).exitCode).toBe(0) + } finally { + child.kill('SIGKILL') + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) + + it('starts a consumer on its composed value when the invocation carries no app arguments', async () => { + const fixture = createStartupFixture() + const child = startStartupProfile(fixture, []) + try { + await waitForFile(fixture.ready) + expect(readFileSync(fixture.echo, 'utf8')).toBe('bundle-default') + requestProfileShutdown(child, fixture) + expect((await child).exitCode).toBe(0) + } finally { + child.kill('SIGKILL') + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) + + it('keeps the app arguments across a user patch reload', async () => { + // A live edit recomposes every row while the provider service remains + // active, so each config expression reads the same invocation value (a + // served port does not move back to its composed fallback). + const fixture = createStartupFixture() + const profilePatch = join(fixture.home, 'profiles', 'startup', 'cordis.patch.yml') + const child = startStartupProfile(fixture, ['--generation', 'flagged']) + try { + // Both rows: the waiting one carries the flag value, and the witness is + // what a reload will re-mount. They start independently, so neither + // marker implies the other. + await waitForFile(fixture.ready) + await waitForFile(fixture.witness) + expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged') + // An edit to an unrelated row: the witness re-mounts, which is how this + // test knows the whole tree was recomposed. + rmSync(fixture.witness) + writeFileSync(profilePatch, [ + '- id: reload-witness', + ' config:', + ' generation: reloaded', + '', + ].join('\n')) + await waitForFile(fixture.witness) + expect(readFileSync(fixture.witness, 'utf8')).toBe('reloaded') + expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged') + requestProfileShutdown(child, fixture) + expect((await child).exitCode).toBe(0) + } finally { + child.kill('SIGKILL') + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) + + it("prints the app's own help, starts none of its rows, and exits", async () => { + const fixture = createStartupFixture() + try { + const result = await startStartupProfile(fixture, ['--help']) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('Usage: fixture') + expect(result.stdout).toContain('--generation') + expect(existsSync(fixture.ready)).toBe(false) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) + it('anchors a relative add spec to the invoking directory, not the profile', async () => { // `dsh plugin --profile x add .` from a plugin checkout must install THAT // checkout — pnpm's cwd is the profile directory, so an un-anchored `.` @@ -491,18 +697,17 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'") }, 30_000) - it('prints a headless profile with no Host, HTTP, or browser rows', async () => { + it('prints the headless profile without Host or browser layers', async () => { const { stdout, code, stderr } = await runBuiltBin( ['--profile', 'headless', '--dump-default-config'], { DSH_HOME: home }, ) expect(code).toBe(0) expect(stderr).toBe('') - expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-default-model'") expect(stdout).toContain("name: '@deepseek-ai/dsh-headless'") - expect(stdout).not.toContain("name: '@deepseek-ai/dsh-host-") + expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-host-/) expect(stdout).not.toContain("name: '@deepseek-ai/dsh-web-app'") - expect(stdout).not.toContain("name: '@deepseek-ai/dsh-client-") + expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-client-/) }, 30_000) it('composes the profile user layer and a --patch overlay in order', async () => { @@ -543,16 +748,5 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`) expect(stderr).toContain('patch: entry "absent-row" not found') }, 30_000) - - it('shows the RL Web patch disabling runtime surface context', async () => { - const { stdout, code, stderr } = await runBuiltBin( - ['web', '--patch', coreWebOverlay, '--dump-config'], - { DSH_HOME: home }, - ) - expect(code).toBe(0) - expect(stderr).toBe('') - expect(stdout).toContain("name: '@deepseek-ai/dsh-web-app'") - expect(stdout).toContain('surfaceContext: false') - }, 30_000) }) }) diff --git a/apps/cli/tests/headless-shutdown.e2e.ts b/apps/cli/tests/headless-shutdown.e2e.ts index b0bcbb7cae..42f2aad709 100644 --- a/apps/cli/tests/headless-shutdown.e2e.ts +++ b/apps/cli/tests/headless-shutdown.e2e.ts @@ -83,7 +83,7 @@ async function runHeadlessPtySmoke(): Promise { ].join('\n')) const launch = resolveExampleLaunch({ srcBin: dshBinScript, - configArgs: ['run', 'never complete'], + configArgs: ['--profile', 'headless', 'never complete'], tsconfigPath, env: { DSH_HOME: home, diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 27e88a1738..85c5227bd4 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -5,14 +5,16 @@ import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { boot, healProfilesModuleFallback, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import { SessionId } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import { beforeAll, describe, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent' import { CallId } from '@deepseek-ai/dsh-llm' +import type { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import type {} from '@deepseek-ai/dsh-skill' import type {} from '@deepseek-ai/dsh-tools' @@ -23,6 +25,15 @@ const BASE_PATCH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml') const WEB_PATCH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml') /** The installation anchor whose dependency surface the preset module fallback mirrors. */ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json') +const MINIMAL_PROMPT = 'You are a helpful software engineer assistant.' +const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell +* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. +* You don't have access to the internet via this tool. +* You do have access to a mirror of common linux and python packages via apt and pip. +* State is persistent across command calls and discussions with the user. +* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. +* Please avoid commands that may produce a very large amount of output. +* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.` /** * Boot the shipped Web composition, minus the rows that would bind a port, @@ -90,12 +101,26 @@ async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promis await mkdir(profileDir, { recursive: true }) const rootConfig = join(profileDir, 'cordis.yml') await writeFile(rootConfig, '[]\n') - return await boot('dsh-test', rootConfig, patches) + return await boot('dsh-test', rootConfig, patches, (bootCtx) => { + provideCmdline(bootCtx, { args: [], exit: () => {} }) + }) } const toolNames = (ctx: Context, agent?: Agent): string[] => ctx.tools.schemas(agent).map(schema => schema.name).sort() +function enablePresetTool(composition: string, id: string): string { + const row = ` - id: ${id}\n` + const start = composition.indexOf(row) + if (start < 0) throw new Error(`missing preset row ${id}`) + const end = composition.indexOf('\n - id:', start + row.length) + const disabled = composition.indexOf(' disabled: true\n', start) + if (disabled < 0 || (end >= 0 && disabled > end)) { + throw new Error(`preset row ${id} is not disabled`) + } + return composition.slice(0, disabled) + composition.slice(disabled + ' disabled: true\n'.length) +} + let ctx: Context beforeAll(async () => { const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-web-presets-')), 'settings.yaml') @@ -135,7 +160,7 @@ describe('the shipped Web composition', () => { expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([ 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'interrupt_agent', 'list_agents', 'ralph', 'read', 'send_message', 'skill', - 'str_replace_editor', 'subagent', 'subagent_fork', 'task_kill', + 'subagent', 'subagent_fork', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_search', 'workflow', 'write', ]) @@ -144,14 +169,30 @@ describe('the shipped Web composition', () => { } }) - it('composes exactly two tools from `minimal`', async () => { + it('composes the exact RL prompt and two tools from `minimal`', async () => { const handle = await ctx.agents.create({ sessionId: SessionId('preset-minimal'), setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), }) try { - // Exactly what the preset lists — nothing arrives from the host. - expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor']) + const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent }) + expect(assembly.sections).toEqual([ + { name: 'deployment:persona', text: MINIMAL_PROMPT }, + ]) + expect(assembly.tools.map(tool => tool.name)).toEqual(['bash', 'str_replace_editor']) + expect(assembly.tools.find(tool => tool.name === 'bash')?.description).toBe(MINIMAL_BASH_DESCRIPTION) + expect(JSON.stringify(assembly.tools.find(tool => tool.name === 'str_replace_editor')?.parameters)) + .toContain('Absolute path') + const compact = ctx.agentPresets.serviceFor(handle.agent, 'compact') + expect(compact).toBeDefined() + expect((compact as BasicCompactService).config).toMatchObject({ + thresholdRatio: 0.8, + retainTokens: 20480, + summarizationProvider: '', + summarizationModel: '', + maxTokens: 8192, + compactionRetries: 1, + }) } finally { await handle.dispose() } @@ -191,6 +232,7 @@ describe('the shipped Web composition', () => { expect(tools).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount'])) // And it keeps the standard agent's own tools rather than replacing them. expect(tools).toEqual(expect.arrayContaining(['bash', 'read', 'edit', 'skill'])) + expect(tools).not.toContain('str_replace_editor') // The preset's own authoring skill registers into ITS layer of the host // registry: the cordis agent's view carries it, the global view does not. @@ -217,9 +259,9 @@ describe('the shipped Web composition', () => { // the capabilities — so the assembly is what carries the claim. const assembly = await ctx.systemPrompt.assemble({ scope: coded.agent }) expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code']) - expect(toolNames(ctx, coded.agent)).toContain('str_replace_editor') + expect(toolNames(ctx, coded.agent)).not.toContain('str_replace_editor') const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? '' - expect(sdk).toContain('str_replace_editor') + expect(sdk).not.toContain('str_replace_editor') expect(sdk).toContain('web_search') // The presentation is this agent's alone: the deployment default is @@ -340,18 +382,96 @@ describe('the shipped Web composition', () => { expect(await readFile(path, 'utf8')).toBe(before) }) +}) - it('gives each session its own persona', async () => { - const handle = await ctx.agents.create({ - sessionId: SessionId('preset-persona'), - setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), +describe('product subagent rows in user presets', () => { + let productCtx: Context + const ids = ['products-none', 'products-codex', 'products-claude', 'products-both'] as const + + beforeAll(async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-')) + const userRoot = join(root, 'presets') + const settingsFile = join(root, 'settings.yaml') + const standard = await readFile(join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml'), 'utf8') + await writeFile(settingsFile, '{}\n') + for (const id of ids) { + let composition = standard + if (id === 'products-codex' || id === 'products-both') { + composition = enablePresetTool(composition, 'tool-subagent-codex') + } + if (id === 'products-claude' || id === 'products-both') { + composition = enablePresetTool(composition, 'tool-subagent-claude-code') + } + const directory = join(userRoot, id) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'agent.cordis.yml'), composition) + } + productCtx = await bootWeb(settingsFile, [{ + id: 'agent-presets', + config: { + default: 'standard', + roots: [ + { path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }, + { path: userRoot, trust: 'user' }, + ], + }, + }]) + }, 120_000) + + afterAll(async () => { + await productCtx.fiber.dispose() + }) + + it('composes none, either product, or both without changing the shared host registry', async () => { + const expected = new Map([ + ['products-none', []], + ['products-codex', ['subagent_codex']], + ['products-claude', ['subagent_claude_code']], + ['products-both', ['subagent_claude_code', 'subagent_codex']], + ]) + expect(productCtx.subagents.list()).toEqual(expect.arrayContaining([ + 'spawn', 'fork', 'codex', 'claude-code', + ])) + + for (const [id, productTools] of expected) { + const handle = await productCtx.agents.create({ + sessionId: SessionId(`preset-${id}`), + setup: agentCtx => productCtx.agentPresets.mount(agentCtx, id).then(() => undefined), + }) + try { + const tools = toolNames(productCtx, handle.agent) + expect(tools.filter(name => name === 'subagent_codex' || name === 'subagent_claude_code')) + .toEqual(productTools) + } finally { + await handle.dispose() + } + } + }) + + it('applies a product-row edit only to later sessions on the preset', async () => { + const preset = await productCtx.agentPresets.resolve('products-none') + const original = await readFile(preset.path, 'utf8') + const existing = await productCtx.agents.create({ + sessionId: SessionId('preset-product-generation-existing'), + setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined), }) try { - const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent }) - expect(assembly.sections.find(section => section.name === 'deployment:persona')?.text) - .toContain('You are a coding agent powered by') + expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex') + await writeFile(preset.path, enablePresetTool(original, 'tool-subagent-codex')) + + const later = await productCtx.agents.create({ + sessionId: SessionId('preset-product-generation-later'), + setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined), + }) + try { + expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex') + expect(toolNames(productCtx, later.agent)).toContain('subagent_codex') + } finally { + await later.dispose() + } } finally { - await handle.dispose() + await existing.dispose() + await writeFile(preset.path, original) } }) }) diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index d830e8fba6..cecf7a4bb9 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../packages/boot/app-boot" }, + { + "path": "../../packages/boot/cmdline" + }, { "path": "../../packages/bundle/base" }, @@ -47,6 +50,9 @@ { "path": "../../packages/core/tools" }, + { + "path": "../../packages/util/environment" + }, { "path": "../../packages/util/paths" }, diff --git a/apps/web/package.json b/apps/web/package.json index 6f4bce8dc2..1bf97cb17c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,6 +28,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", + "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@types/node": "^22.0.0", "@types/react": "~18.3.1", diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index e8ff5aa538..bee65d5ceb 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -161,7 +161,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { expect(composition).toBe(await readFile(join(SHIPPED_PRESETS, 'minimal', 'agent.cordis.yml'), 'utf8')) const metadata = await readFile(join(userRoot, 'my-agent', 'preset.yml'), 'utf8') expect(metadata).toContain('name: 我的模式') - expect(metadata).toContain('description: 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。') + expect(metadata).toContain('description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。') expect(metadata).not.toContain('order:') }, 60_000) diff --git a/apps/web/tests/core-web-profile.snapshot.ts b/apps/web/tests/core-web-profile.snapshot.ts deleted file mode 100644 index 1178390837..0000000000 --- a/apps/web/tests/core-web-profile.snapshot.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import type { AgentHandle } from '@deepseek-ai/dsh-agent' -import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm' -import { SessionId } from '@deepseek-ai/dsh-session' -import { assertFixtureInventory, launchWebScaffold, type WebScaffold } from './scaffold.ts' - -const CORE_WEB_OVERLAY = fileURLToPath(new URL('../../cli/config/core-web.cordis.yml', import.meta.url)) -const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/core-web-profile', import.meta.url)) -const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') -const PROMPT = 'Reply exactly CORE_WEB_REQUEST_OK and stop.' - -describe('core Web profile', () => { - let scaffold: WebScaffold - let agentHandle: AgentHandle - - beforeAll(async () => { - const systemPrompt = process.env.DSH_SYSTEM_PROMPT - Reflect.deleteProperty(process.env, 'DSH_SYSTEM_PROMPT') - try { - scaffold = await launchWebScaffold({ extraOverlayPath: CORE_WEB_OVERLAY, replayFixture: FIXTURE }) - } finally { - if (systemPrompt !== undefined) process.env.DSH_SYSTEM_PROMPT = systemPrompt - } - agentHandle = await scaffold.ctx.agents.create({ - sessionId: SessionId('core-web-profile-smoke'), - meta: { cwd: scaffold.workspaceCwd }, - agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }) - }) - - afterAll(async () => { - const failures: unknown[] = [] - await agentHandle?.dispose().catch((error: unknown) => failures.push(error)) - await scaffold?.close().catch((error: unknown) => failures.push(error)) - if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'core Web profile smoke teardown failed') - }) - - it('sends the RL prompt and tool schemas through a real request, then executes both tools', async () => { - agentHandle.agent.followup(createUserMessage({ - content: [{ type: 'text', text: PROMPT }], - source: { kind: 'user' }, - })) - await agentHandle.agent.whenIdle() - - const requestHeader = agentHandle.agent.session.requestHeader() - if (requestHeader === undefined) throw new Error('the core Web agent issued no model request') - - const seedPath = join(scaffold.workspaceCwd, 'profile-smoke.txt') - await writeFile(seedPath, 'CORE_WEB_EDITOR_OK\n') - const signal = new AbortController().signal - const bash = await scaffold.ctx.tools.execute({ - signal, - callId: CallId('core-web-bash-smoke'), - name: 'bash', - arguments: { command: "printf 'CORE_WEB_BASH_OK\\n'" }, - agent: agentHandle.agent, - }) - const editor = await scaffold.ctx.tools.execute({ - signal, - callId: CallId('core-web-editor-smoke'), - name: 'str_replace_editor', - arguments: { command: 'view', path: seedPath }, - agent: agentHandle.agent, - }) - - const text = (result: typeof bash): string => result.content - .filter(block => block.type === 'text') - .map(block => block.text) - .join('') - .replaceAll(scaffold.workspaceCwd, '{{cwd}}') - .trimEnd() - - expect({ - prompt: requestHeader.system, - tools: requestHeader.tools?.map(tool => tool.name), - bash: text(bash), - editor: text(editor), - }).toMatchInlineSnapshot(` - { - "bash": "CORE_WEB_BASH_OK", - "editor": "Here's the content of {{cwd}}/profile-smoke.txt with line numbers (which has a total of 2 lines): - 1 CORE_WEB_EDITOR_OK - 2", - "prompt": "You are a helpful software engineer assistant.", - "tools": [ - "bash", - "str_replace_editor", - ], - } - `) - expect(requestHeader.tools).toEqual(scaffold.ctx.tools.schemas(agentHandle.agent)) - - const entries = [...scaffold.ctx.loader.entries()] - expect(entries.find(entry => entry.options.id === 'persistent-bash')?.fiber).toBeDefined() - expect(entries.find(entry => entry.options.id === 'pty-local')?.fiber).toBeDefined() - expect(entries.find(entry => entry.options.id === 'str-replace-editor')?.fiber).toBeDefined() - expect(entries.find(entry => entry.options.id === 'web-runtime')?.fiber).toBeDefined() - expect(entries.find(entry => entry.options.id === 'workspace-context')?.fiber).toBeUndefined() - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl']) - }) - - it('uses DSH_SYSTEM_PROMPT as the complete prompt when configured', async () => { - const previous = process.env.DSH_SYSTEM_PROMPT - process.env.DSH_SYSTEM_PROMPT = 'RL prompt override' - let overrideScaffold: WebScaffold | undefined - let overrideAgent: AgentHandle | undefined - try { - overrideScaffold = await launchWebScaffold({ extraOverlayPath: CORE_WEB_OVERLAY, replayFixture: FIXTURE }) - overrideAgent = await overrideScaffold.ctx.agents.create({ - sessionId: SessionId('core-web-profile-override'), - meta: { cwd: overrideScaffold.workspaceCwd }, - agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }) - overrideAgent.agent.followup(createUserMessage({ - content: [{ type: 'text', text: PROMPT }], - source: { kind: 'user' }, - })) - await overrideAgent.agent.whenIdle() - expect(overrideAgent.agent.session.requestHeader()?.system).toBe('RL prompt override') - } finally { - try { - await overrideAgent?.dispose() - } finally { - try { - await overrideScaffold?.close() - } finally { - if (previous === undefined) Reflect.deleteProperty(process.env, 'DSH_SYSTEM_PROMPT') - else process.env.DSH_SYSTEM_PROMPT = previous - } - } - } - }) -}) diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts new file mode 100644 index 0000000000..0c8c6fa765 --- /dev/null +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -0,0 +1,115 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import type { AgentHandle } from '@deepseek-ai/dsh-agent' +import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-system-prompt' +import { assertFixtureInventory, launchWebScaffold, type WebScaffold } from './scaffold.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/minimal-preset', import.meta.url)) +const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') +const PROMPT = 'Reply exactly MINIMAL_PRESET_REQUEST_OK and stop.' + +describe('minimal agent preset', () => { + let scaffold: WebScaffold + let agentHandle: AgentHandle + let disposeInjectedPrompt: () => void + + beforeAll(async () => { + scaffold = await launchWebScaffold({ replayFixture: FIXTURE }) + disposeInjectedPrompt = scaffold.ctx.systemPrompt.section({ + name: 'test:injected-prompt', + order: 999, + text: 'THIS TEXT MUST NOT REACH THE MODEL.', + }) + agentHandle = await scaffold.ctx.agents.create({ + sessionId: SessionId('minimal-preset-smoke'), + meta: { cwd: scaffold.workspaceCwd, agentPreset: 'minimal' }, + agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + }) + + afterAll(async () => { + const failures: unknown[] = [] + await agentHandle?.dispose().catch((error: unknown) => failures.push(error)) + try { + disposeInjectedPrompt?.() + } catch (error: unknown) { + failures.push(error) + } + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'minimal preset smoke teardown failed') + }) + + it('sends the exact RL prompt and schemas, then executes the persistent shell and editor', async () => { + agentHandle.agent.followup(createUserMessage({ + content: [{ type: 'text', text: PROMPT }], + source: { kind: 'user' }, + })) + await agentHandle.agent.whenIdle() + + const requestHeader = agentHandle.agent.session.requestHeader() + if (requestHeader === undefined) throw new Error('the minimal agent issued no model request') + + const stateDir = join(scaffold.workspaceCwd, 'persistent-state') + await mkdir(stateDir) + const signal = new AbortController().signal + await scaffold.ctx.tools.execute({ + signal, + callId: CallId('minimal-bash-state-setup'), + name: 'bash', + arguments: { command: `cd ${JSON.stringify(stateDir)} && export DSH_MINIMAL_STATE=PERSISTED` }, + agent: agentHandle.agent, + }) + const bash = await scaffold.ctx.tools.execute({ + signal, + callId: CallId('minimal-bash-state-read'), + name: 'bash', + arguments: { command: 'printf \'%s:%s\n\' "$DSH_MINIMAL_STATE" "$PWD"' }, + agent: agentHandle.agent, + }) + const seedPath = join(scaffold.workspaceCwd, 'preset-smoke.txt') + await writeFile(seedPath, 'MINIMAL_EDITOR_OK\n') + const editor = await scaffold.ctx.tools.execute({ + signal, + callId: CallId('minimal-editor-smoke'), + name: 'str_replace_editor', + arguments: { command: 'view', path: seedPath }, + agent: agentHandle.agent, + }) + + const text = (result: typeof bash): string => result.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') + .replaceAll(scaffold.workspaceCwd, '{{cwd}}') + .trimEnd() + + expect({ + prompt: requestHeader.system, + tools: requestHeader.tools?.map(tool => tool.name), + bash: text(bash), + editor: text(editor), + }).toMatchInlineSnapshot(` + { + "bash": "PERSISTED:{{cwd}}/persistent-state", + "editor": "Here's the content of {{cwd}}/preset-smoke.txt with line numbers (which has a total of 2 lines): + 1 MINIMAL_EDITOR_OK + 2", + "prompt": "You are a helpful software engineer assistant.", + "tools": [ + "bash", + "str_replace_editor", + ], + } + `) + expect(requestHeader.tools?.toSorted((left, right) => left.name.localeCompare(right.name))) + .toEqual(scaffold.ctx.tools.schemas(agentHandle.agent).toSorted((left, right) => left.name.localeCompare(right.name))) + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl']) + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 0104a7bee7..a93828282e 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -35,7 +35,6 @@ import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import Group from '@deepseek-ai/cordis-plugin-group' import { scrubRequestHeaders, stabilizeFixtureMessageIds } from '@deepseek-ai/dsh-acp-snapshot' import { - addHarnessSourceSection, assertEntriesLoaded, composeEntries, healProfilesModuleFallback, @@ -65,6 +64,7 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' // Empty type imports carry the httpServer/agents/sessionPersistence Context merges. import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-agent' +import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import { REPO_ROOT, requireDist } from './support.ts' /** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the other snapshot suites). */ @@ -459,6 +459,16 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { + throw new Error(`web e2e scaffold: the web app requested exit ${String(code)} with no arguments to reject`) + }, + }) await ctx.plugin(Loader) ctx.loader.builtins.include = Include // `cordis:group` beside it, exactly as `boot()` registers it: a group row is @@ -469,9 +479,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { addHarnessSourceSection(promptCtx, REPO_ROOT) }) - } await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(rootConfig).href, patches }, diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index faf01ede62..8e49fded4f 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -38,7 +38,6 @@ const EXPECTED_TOOLS = [ 'read', 'send_message', 'skill', - 'str_replace_editor', 'subagent', 'subagent_fork', 'task_kill', diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index b0acf074b4..ae5805dc8a 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -485,10 +485,13 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke child = spawn( process.execPath, [ - '--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port), + '--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', + // Launcher flags come first: the first token the launcher does not own + // starts the web app's own arguments. // Pin the in-browser picker: the shipped `-auto` row would resolve to // the native OS chooser on this bind, and no page can drive that. '--patch', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + '--port', String(port), ], { cwd: sessionsDir, diff --git a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md index 95eb144531..fc96442a85 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md @@ -43,7 +43,7 @@ - text: 复制 - listitem: - 'button "设为默认: 极简模式"': - - text: 极简模式 内置 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 + - text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。 - code: minimal - 'button "查看: 极简模式"': - img @@ -65,7 +65,7 @@ - list: - listitem: - 'button "设为默认: 我的模式"': - - text: 我的模式 自定义 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 + - text: 我的模式 自定义 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。 - code: my-agent - 'button "查看路径: 我的模式"': - img diff --git a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md index 00db53c81b..50eb304fff 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md @@ -43,7 +43,7 @@ - text: 复制 - listitem: - 'button "设为默认: 极简模式"': - - text: 极简模式 内置 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 + - text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。 - code: minimal - 'button "查看: 极简模式"': - img diff --git a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md index dfb1d3f1e1..d9f8db63db 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md @@ -43,7 +43,7 @@ - text: 复制 - listitem: - 'button "设为默认: 极简模式"': - - text: 极简模式 内置 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 + - text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。 - code: minimal - 'button "查看: 极简模式"': - img diff --git a/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md index 78ec056f56..7e0d1ae032 100644 --- a/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md +++ b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md @@ -3,5 +3,5 @@ - text: Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows. - img - menuitem "Code mode All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program." - - menuitem "Minimal mode Two-tool coding agent with only bash and str_replace_editor, for benchmarks and minimal reproductions." + - menuitem "Minimal mode Two-tool coding agent with persistent bash and str_replace_editor." - menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance." diff --git a/apps/web/tests/snapshots/core-web-profile/session.jsonl b/apps/web/tests/snapshots/minimal-preset/session.jsonl similarity index 73% rename from apps/web/tests/snapshots/core-web-profile/session.jsonl rename to apps/web/tests/snapshots/minimal-preset/session.jsonl index 04f0d62d15..49977be802 100644 --- a/apps/web/tests/snapshots/core-web-profile/session.jsonl +++ b/apps/web/tests/snapshots/minimal-preset/session.jsonl @@ -1,7 +1,7 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785974400000,"cwd":"{{cwd}}"} -{"type":"user/message","seq":0,"time":1785974400001,"data":{"content":[{"type":"text","text":"Reply exactly CORE_WEB_REQUEST_OK and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785974400000,"cwd":"{{cwd}}","agentPreset":"minimal"} +{"type":"user/message","seq":0,"time":1785974400001,"data":{"content":[{"type":"text","text":"Reply exactly MINIMAL_PRESET_REQUEST_OK and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} {"type":"assistant/chunk","seq":1,"time":1785974400002,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":2,"time":1785974400003,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CORE_WEB_REQUEST_OK"}}} -{"type":"assistant/chunk","seq":3,"time":1785974400004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORE_WEB_REQUEST_OK"}}}} +{"type":"assistant/chunk","seq":2,"time":1785974400003,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"MINIMAL_PRESET_REQUEST_OK"}}} +{"type":"assistant/chunk","seq":3,"time":1785974400004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"MINIMAL_PRESET_REQUEST_OK"}}}} {"type":"assistant/chunk","seq":4,"time":1785974400005,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":4}}}} {"type":"assistant/chunk","seq":5,"time":1785974400006,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 8106350a9e..aedd31e3e4 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -24,7 +24,7 @@ "exclude": [ "tests/scaffold.ts", "tests/scaffold-hermetic.e2e.ts", - "tests/core-web-profile.snapshot.ts", + "tests/minimal-preset.snapshot.ts", "tests/live-interactions.e2e.ts", "tests/question-composer.e2e.ts", "tests/approval-composer.e2e.ts", diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 4ead4672ca..fbec90a3c2 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: eea17f195230a7ade581205d8a919ba285cee781 -config-catalog.zh.md: 03252ea8d8cb8624c4324251878180ec69bd4690 +config-catalog.md: 119f83a524c44187c321f6bf5f86c4580c748eb5 +config-catalog.zh.md: b3b776edea79b554be10a91338800dd179073c97 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index eea17f1952..119f83a524 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -572,7 +572,7 @@ Source: [`packages/goal/goal/src/index.ts:116`](../packages/goal/goal/src/index. Requires: `agentDefaultModel` · `agents` · `sessions` ```ts config-catalog -/** Plugin config: the task, patched in by the launcher. */ +/** Plugin config: the task resolved from this app's injected provider service. */ export interface Config { /** The prompt text for the single run. */ task: string @@ -1161,6 +1161,8 @@ export interface Config { * variables. Empty text drops the section at render, matching the registry. */ text: string + /** Make this persona the complete system prompt, suppressing every other section. */ + complete?: boolean } ``` @@ -1987,7 +1989,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:177`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:186`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-time-context` @@ -2518,33 +2520,28 @@ Source: [`packages/web/web/src/index.ts:55`](../packages/web/web/src/index.ts) Requires: `httpServer` ```ts config-catalog -/** Plugin config: the surface facts the launcher patches over this bundle's defaults. */ +/** Plugin config: composed deployment settings plus per-invocation command-line values. */ export interface Config { /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */ mode: WebMode - /** Print the URL line on activation; a headless layer over this bundle turns it off. */ + /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** * Register the model-visible surface context (the `app:web-surface` prompt * section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot - * layer turns it off: its user is not interacting through the GUI, so the + * non-interactive layer can turn it off when its user is not in the GUI, so the * orientation text would be false. */ surfaceContext: boolean - /** - * LAN IPv4 addresses sampled once by the launcher when the effective bind - * is all-interfaces — the exact snapshot the /api trust fence was - * configured with, so the printed LAN URL can never name an address the - * fence rejects. Empty on a loopback bind. - */ - lanAddresses: string[] + /** Explicit `--trusted-host` authorities from this invocation. */ + trustedHosts: string[] } /** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */ export type WebMode = 'production' | 'development' ``` -Source: [`packages/bundle/web-app/src/index.ts:32`](../packages/bundle/web-app/src/index.ts) +Source: [`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` @@ -2791,6 +2788,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) - `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) - `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) +- `@deepseek-ai/dsh-cmdline` ([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts)) - `@deepseek-ai/dsh-environment` ([`packages/util/environment/src/index.ts`](../packages/util/environment/src/index.ts)) - `@deepseek-ai/dsh-helper` ([`packages/scaffold/helper/src/index.ts`](../packages/scaffold/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 03252ea8d8..b3b776edea 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -574,7 +574,7 @@ export interface Config { 需要:`agentDefaultModel` · `agents` · `sessions` ```ts config-catalog -/** Plugin config: the task, patched in by the launcher. */ +/** Plugin config: the task resolved from this app's injected provider service. */ export interface Config { /** The prompt text for the single run. */ task: string @@ -1163,6 +1163,8 @@ export interface Config { * variables. Empty text drops the section at render, matching the registry. */ text: string + /** Make this persona the complete system prompt, suppressing every other section. */ + complete?: boolean } ``` @@ -1989,7 +1991,7 @@ export interface Config { } ``` -来源:[`packages/core/system-prompt/src/index.ts:177`](../packages/core/system-prompt/src/index.ts) +来源:[`packages/core/system-prompt/src/index.ts:186`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-time-context` @@ -2519,33 +2521,28 @@ export interface WebServiceConfig { 需要:`httpServer` ```ts config-catalog -/** Plugin config: the surface facts the launcher patches over this bundle's defaults. */ +/** Plugin config: composed deployment settings plus per-invocation command-line values. */ export interface Config { /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */ mode: WebMode - /** Print the URL line on activation; a headless layer over this bundle turns it off. */ + /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** * Register the model-visible surface context (the `app:web-surface` prompt * section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot - * layer turns it off: its user is not interacting through the GUI, so the + * non-interactive layer can turn it off when its user is not in the GUI, so the * orientation text would be false. */ surfaceContext: boolean - /** - * LAN IPv4 addresses sampled once by the launcher when the effective bind - * is all-interfaces — the exact snapshot the /api trust fence was - * configured with, so the printed LAN URL can never name an address the - * fence rejects. Empty on a loopback bind. - */ - lanAddresses: string[] + /** Explicit `--trusted-host` authorities from this invocation. */ + trustedHosts: string[] } /** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */ export type WebMode = 'production' | 'development' ``` -来源:[`packages/bundle/web-app/src/index.ts:32`](../packages/bundle/web-app/src/index.ts) +来源:[`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` @@ -2791,6 +2788,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-slots`([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) - `@deepseek-ai/dsh-client-web`([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) - `@deepseek-ai/dsh-client-web-react`([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) +- `@deepseek-ai/dsh-cmdline`([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts)) - `@deepseek-ai/dsh-environment`([`packages/util/environment/src/index.ts`](../packages/util/environment/src/index.ts)) - `@deepseek-ai/dsh-helper`([`packages/scaffold/helper/src/index.ts`](../packages/scaffold/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol`([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) diff --git a/docs/cordis-api/fiber.i18n.yaml b/docs/cordis-api/fiber.i18n.yaml index 6c01366dc1..537be01dbc 100644 --- a/docs/cordis-api/fiber.i18n.yaml +++ b/docs/cordis-api/fiber.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-api/fiber.md -fiber.md: 36d2861ac6a53e8186a92d86c65ba228d4b59ee5 -fiber.zh.md: fafa559ca911677c43893862190009d82c39c56b +fiber.md: 182b77390b29b8a90504437d0ccc2dfeba23921a +fiber.zh.md: 9ed3e52618586dc3815b9d913439d11a227fb64b diff --git a/docs/cordis-api/fiber.md b/docs/cordis-api/fiber.md index 36d2861ac6..182b77390b 100644 --- a/docs/cordis-api/fiber.md +++ b/docs/cordis-api/fiber.md @@ -34,7 +34,7 @@ Register a cleanup-aware effect on this fiber. **Returns** a disposer that tears the effect down and settles once done. -[Source](../../vendor/cordis/src/fiber.ts#L420) +[Source](../../vendor/cordis/src/fiber.ts#L415) ### ctx.fiber @@ -97,7 +97,7 @@ public state Current lifecycle state; transitions emit `internal/status`. -[Source](../../vendor/cordis/src/fiber.ts#L192) +[Source](../../vendor/cordis/src/fiber.ts#L194) ### fiber.dispose @@ -108,7 +108,7 @@ public readonly dispose: () => Promise Dispose this fiber: unload the plugin, then settle once cleanup finished. -[Source](../../vendor/cordis/src/fiber.ts#L194) +[Source](../../vendor/cordis/src/fiber.ts#L196) ### fiber.store @@ -119,7 +119,7 @@ public store: Dict | undefined Snapshot of required service implementations while loaded; `undefined` otherwise. -[Source](../../vendor/cordis/src/fiber.ts#L196) +[Source](../../vendor/cordis/src/fiber.ts#L198) ### fiber.inertia @@ -130,7 +130,7 @@ public inertia: Promise | undefined The in-flight load/unload transition, if one is currently running. -[Source](../../vendor/cordis/src/fiber.ts#L198) +[Source](../../vendor/cordis/src/fiber.ts#L200) ### fiber.name @@ -141,7 +141,7 @@ get name() The plugin's display name, inherited from the nearest named ancestor, else `'root'`. -[Source](../../vendor/cordis/src/fiber.ts#L341) +[Source](../../vendor/cordis/src/fiber.ts#L336) ### fiber.assertActive() @@ -159,7 +159,7 @@ Throw if the fiber has already been disposed. **Returns** nothing when the fiber is still active. -[Source](../../vendor/cordis/src/fiber.ts#L356) +[Source](../../vendor/cordis/src/fiber.ts#L351) ### fiber.effect(execute, label?) @@ -190,7 +190,7 @@ Register a cleanup-aware effect on this fiber. **Returns** a disposer that tears the effect down and settles once done. -[Source](../../vendor/cordis/src/fiber.ts#L420) +[Source](../../vendor/cordis/src/fiber.ts#L415) ### fiber.getEffects() @@ -207,7 +207,7 @@ Return metadata for currently registered effects. **Returns** one `EffectMeta` tree per labeled live effect. -[Source](../../vendor/cordis/src/fiber.ts#L573) +[Source](../../vendor/cordis/src/fiber.ts#L568) ### fiber.await() @@ -225,7 +225,7 @@ Wait for current lifecycle work and rethrow startup errors. **Returns** this fiber, once it has settled into a stable state. -[Source](../../vendor/cordis/src/fiber.ts#L702) +[Source](../../vendor/cordis/src/fiber.ts#L704) ### fiber.restart() @@ -243,7 +243,7 @@ Dispose and immediately reload this plugin with its current config. **Returns** a promise resolving once the reload settled. -[Source](../../vendor/cordis/src/fiber.ts#L716) +[Source](../../vendor/cordis/src/fiber.ts#L718) ### fiber.update(config, noSave?) @@ -271,7 +271,7 @@ Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto o **Returns** the update waterfall result; the default restart returns a promise. -[Source](../../vendor/cordis/src/fiber.ts#L734) +[Source](../../vendor/cordis/src/fiber.ts#L736) ## Effect diff --git a/docs/cordis-api/fiber.zh.md b/docs/cordis-api/fiber.zh.md index fafa559ca9..9ed3e52618 100644 --- a/docs/cordis-api/fiber.zh.md +++ b/docs/cordis-api/fiber.zh.md @@ -36,7 +36,7 @@ effect(execute: () => Effect, label?: string): AsyncDisposable> **返回**一个用于撤销该作用的清理函数,并在清理完成后结算。 -[源码](../../vendor/cordis/src/fiber.ts#L420) +[源码](../../vendor/cordis/src/fiber.ts#L415) ### ctx.fiber @@ -99,7 +99,7 @@ public state 当前生命周期状态;状态转换会发出 `internal/status`。 -[源码](../../vendor/cordis/src/fiber.ts#L192) +[源码](../../vendor/cordis/src/fiber.ts#L194) ### fiber.dispose @@ -110,7 +110,7 @@ public readonly dispose: () => Promise dispose 此 fiber:卸载插件,并在清理完成后结算。 -[源码](../../vendor/cordis/src/fiber.ts#L194) +[源码](../../vendor/cordis/src/fiber.ts#L196) ### fiber.store @@ -121,7 +121,7 @@ public store: Dict | undefined 加载期间所需服务实现的快照;其他情况下为 `undefined`。 -[源码](../../vendor/cordis/src/fiber.ts#L196) +[源码](../../vendor/cordis/src/fiber.ts#L198) ### fiber.inertia @@ -132,7 +132,7 @@ public inertia: Promise | undefined 当前正在进行的加载或卸载转换;如果没有此类转换,则为 undefined。 -[源码](../../vendor/cordis/src/fiber.ts#L198) +[源码](../../vendor/cordis/src/fiber.ts#L200) ### fiber.name @@ -143,7 +143,7 @@ get name() 插件的显示名称,继承自最近的具名祖先;如果不存在,则为 `'root'`。 -[源码](../../vendor/cordis/src/fiber.ts#L341) +[源码](../../vendor/cordis/src/fiber.ts#L336) ### fiber.assertActive() @@ -161,7 +161,7 @@ assertActive() **返回**:fiber 仍处于活动状态时不返回任何内容。 -[源码](../../vendor/cordis/src/fiber.ts#L356) +[源码](../../vendor/cordis/src/fiber.ts#L351) ### fiber.effect(execute, label?) @@ -192,7 +192,7 @@ effect(execute: () => Effect, label?: string): AsyncDisposable> **返回**一个用于撤销该作用的清理函数,并在清理完成后结算。 -[源码](../../vendor/cordis/src/fiber.ts#L420) +[源码](../../vendor/cordis/src/fiber.ts#L415) ### fiber.getEffects() @@ -209,7 +209,7 @@ getEffects() **返回**:每个带标签的活动作用对应一棵 `EffectMeta` 树。 -[源码](../../vendor/cordis/src/fiber.ts#L573) +[源码](../../vendor/cordis/src/fiber.ts#L568) ### fiber.await() @@ -227,7 +227,7 @@ async await() **返回**:进入稳定状态后的此 fiber。 -[源码](../../vendor/cordis/src/fiber.ts#L702) +[源码](../../vendor/cordis/src/fiber.ts#L704) ### fiber.restart() @@ -245,7 +245,7 @@ dispose 此插件,并立即使用其当前配置重新加载。 **返回**一个在重新加载完成后兑现的 promise。 -[源码](../../vendor/cordis/src/fiber.ts#L716) +[源码](../../vendor/cordis/src/fiber.ts#L718) ### fiber.update(config, noSave?) @@ -273,7 +273,7 @@ update(config: any, noSave = false) **返回**更新 waterfall 的结果;默认的重新启动操作返回一个 promise。 -[源码](../../vendor/cordis/src/fiber.ts#L734) +[源码](../../vendor/cordis/src/fiber.ts#L736) ## Effect diff --git a/docs/cordis-primer.i18n.yaml b/docs/cordis-primer.i18n.yaml index ad9cfe716e..180ba85c01 100644 --- a/docs/cordis-primer.i18n.yaml +++ b/docs/cordis-primer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-primer.md -cordis-primer.md: 93725949a9490f757edebcf3e8391db9e73321b1 -cordis-primer.zh.md: fd2a327b526b210986bc1574013fca2c0cec5dda +cordis-primer.md: d1e7c5fd8eaaa89fe448d238359389d945cd6346 +cordis-primer.zh.md: d6ce0f2024f65b006c9505daffaa06a08bb56875 diff --git a/docs/cordis-primer.md b/docs/cordis-primer.md index 93725949a9..d1e7c5fd8e 100644 --- a/docs/cordis-primer.md +++ b/docs/cordis-primer.md @@ -35,7 +35,7 @@ For single-decision events, short-circuiting is the design. A policy listener ca ## Loader Configuration -`@deepseek-ai/cordis-plugin-include` parses `!!js` into expression nodes, but the Loader interpolates only an entry's `config` before mounting the plugin. Entry metadata (`id`, `name`, `group`, `disabled`, `inject`, `intercept`, and `isolate`) remains literal; `disabled: !!js ...` is therefore a truthy object that always disables the entry. Use explicit config overlays when environment selection changes which plugins are mounted. +`@deepseek-ai/cordis-plugin-include` parses `!!js` into expression nodes. Loader interpolates only an entry's `config`, after declared injections activate, against that plugin context (`ctx.serviceName`); Include preserves nested row expressions until target activation. Entry metadata (`id`, `name`, `group`, `disabled`, `inject`, `intercept`, `isolate`) stays literal, so `disabled: !!js ...` always disables the entry. Use overlays when the environment selects plugins. ## Practical Rules diff --git a/docs/cordis-primer.zh.md b/docs/cordis-primer.zh.md index fd2a327b52..d6ce0f2024 100644 --- a/docs/cordis-primer.zh.md +++ b/docs/cordis-primer.zh.md @@ -39,7 +39,7 @@ Cordis 是 DeepSeek Harness SDK 底层以 vendor 方式引入的插件框架。 ## Loader 配置 -`@deepseek-ai/cordis-plugin-include` 将 `!!js` 解析为表达式节点,但 Loader 仅在挂载插件前对条目的 `config` 做插值。条目元数据(`id`、`name`、`group`、`disabled`、`inject`、`intercept` 和 `isolate`)保持字面值;因此 `disabled: !!js ...` 是一个 truthy 对象,会始终禁用该条目。需要根据环境选择挂载哪些插件时,请使用显式的配置覆盖层。 +`@deepseek-ai/cordis-plugin-include` 将 `!!js` 解析为表达式节点。Loader 只在声明的注入激活后,基于该插件上下文(`ctx.serviceName`)插值条目的 `config`;Include 会保留嵌套行表达式,直到目标行激活。条目元数据(`id`、`name`、`group`、`disabled`、`inject`、`intercept`、`isolate`)保持字面值,因此 `disabled: !!js ...` 始终禁用该条目。由环境选择插件时,请使用 overlay。 ## 实践规则 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index dd4c547509..9810ae4cfc 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 9960a022288b2f0525f706b86929e1cbf11acce2 -event-producer-consumer.zh.md: a8b41d902bff8c9a7301ce87a42864ce47f99da1 +event-producer-consumer.md: 6d74db3f6b7309372cf7a691a39c09f3fdd05c76 +event-producer-consumer.zh.md: 1879e74f3e8c01143292b82a1745f4438f64e0ac diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 9960a02228..6d74db3f6b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -41,8 +41,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:155`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | -| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | -| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | +| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:193`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index a8b41d902b..1879e74f3e 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -43,8 +43,8 @@ | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:155`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | -| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | -| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | +| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:193`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 88d83cb3e3..a97f3a5a8d 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: f9bea6bed61af6cad9d5a82df5f54bd8bf54c89c -module-graph.zh.md: 373ea43a6bc517d4ed8220d94a6b2e331d804708 +module-graph.md: 8a57d745df400dbd2df985490c5ec815ee2d8f98 +module-graph.zh.md: 08bd86d650400a55823e519a91448ad1c109060e diff --git a/docs/module-graph.md b/docs/module-graph.md index f9bea6bed6..8a57d745df 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -133,6 +133,7 @@ flowchart TD end subgraph group_boot["packages/boot"] pkg_app_boot["app-boot"] + pkg_cmdline["cmdline"] end subgraph group_bundle["packages/bundle"] pkg_base["base"] @@ -314,6 +315,7 @@ flowchart TD pkg_timeout --> pkg_invariants pkg_scope --> pkg_invariants pkg_llm_mock_server --> pkg_invariants + pkg_cmdline --> pkg_invariants pkg_base --> pkg_invariants pkg_client_modules --> pkg_invariants pkg_client_schema_form --> pkg_invariants @@ -1266,6 +1268,7 @@ flowchart TD | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | | [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | +| [`cmdline`](../packages/boot/cmdline) | `boot` | [`invariants`](../packages/support/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-schema-form`](../packages/client/schema-form) | `client` | [`invariants`](../packages/support/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 373ea43a6b..08bd86d650 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -135,6 +135,7 @@ flowchart TD end subgraph group_boot["packages/boot"] pkg_app_boot["app-boot"] + pkg_cmdline["cmdline"] end subgraph group_bundle["packages/bundle"] pkg_base["base"] @@ -316,6 +317,7 @@ flowchart TD pkg_timeout --> pkg_invariants pkg_scope --> pkg_invariants pkg_llm_mock_server --> pkg_invariants + pkg_cmdline --> pkg_invariants pkg_base --> pkg_invariants pkg_client_modules --> pkg_invariants pkg_client_schema_form --> pkg_invariants @@ -1268,6 +1270,7 @@ flowchart TD | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | | [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | +| [`cmdline`](../packages/boot/cmdline) | `boot` | [`invariants`](../packages/support/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-schema-form`](../packages/client/schema-form) | `client` | [`invariants`](../packages/support/invariants) | diff --git a/docs/subsystems/system-prompt.i18n.yaml b/docs/subsystems/system-prompt.i18n.yaml index c24ae31019..a63870cd80 100644 --- a/docs/subsystems/system-prompt.i18n.yaml +++ b/docs/subsystems/system-prompt.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/system-prompt.md -system-prompt.md: bdc0e994fb8e784a19814574c405d8cc3dce2d11 -system-prompt.zh.md: db6932b18f4721020fed567d49727f863eb06608 +system-prompt.md: 56617ef9d3d8da89673a4624abcef73e58d72cab +system-prompt.zh.md: cafea4f9689879b3fd8d0e1fff7249fcb02a7c12 diff --git a/docs/subsystems/system-prompt.md b/docs/subsystems/system-prompt.md index bdc0e994fb..56617ef9d3 100644 --- a/docs/subsystems/system-prompt.md +++ b/docs/subsystems/system-prompt.md @@ -39,7 +39,7 @@ interface ToolProviderResult { ## Prompt sections -`PromptSection` is a readonly same-process registration contract. Its text may be static or resolved from the current assembly context. +`PromptSection` is a readonly same-process registration contract. Its text may be static or resolved from the current assembly context. One effective `complete` section becomes the sole prompt section after cooperative assembly. ```ts type-equiv /** One contributed section of the system prompt (registry input). */ @@ -58,6 +58,13 @@ interface PromptSection { * interpolated later, by {@link renderPrompt}. */ readonly text: string | ((context: AssembleContext) => string) + /** + * Treat this contribution as the complete system prompt. Assembly still + * runs the cooperative waterfall so tools, contexts, and variables can be + * resolved, then restores this exact section as the sole prompt section. + * More than one effective complete section makes assembly fail. + */ + readonly complete?: boolean } ``` @@ -132,14 +139,16 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine /** * Assemble global and scoped providers, detach tool parameters, apply * canonical ordering, then run the assembly waterfall. Scoped sections and - * variables shadow globals; the returned waterfall value is authoritative. + * variables shadow globals. The returned waterfall value is authoritative + * except that an effective complete section is restored afterwards as the + * sole prompt section. * @param context - the optional scope and plugin-defined assembly fields. - * @returns the authoritative post-waterfall assembly. + * @returns the post-waterfall assembly with any complete prompt enforced. */ async assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:325`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:334`](../../packages/core/system-prompt/src/index.ts) @@ -149,7 +158,7 @@ Source: [`packages/core/system-prompt/src/index.ts:325`](../../packages/core/sys #### `system-prompt/assemble` — waterfall -Expert waterfall over the assembled sections, contexts, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. +Expert waterfall over the assembled sections, contexts, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. A registered complete section is restored after this waterfall, so listeners cannot add to or replace that scope's system prompt. ```ts cordis-catalog /** @@ -157,7 +166,9 @@ Expert waterfall over the assembled sections, contexts, tools, and variables. Sc * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners * receive only that scope's assemblies. The returned value is authoritative. * A supplied signal controls only this explicit assembly request and must not - * be retained to control later turns. + * be retained to control later turns. A registered complete section is + * restored after this waterfall, so listeners cannot add to or replace + * that scope's system prompt. * @param assembly - the mutable assembly built from registered providers. * @param context - the caller's per-assembly context. * @mode waterfall @@ -167,7 +178,7 @@ Expert waterfall over the assembled sections, contexts, tools, and variables. Sc Types: [Scoped](scope.md) -Source: [`packages/core/system-prompt/src/index.ts:29`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:31`](../../packages/core/system-prompt/src/index.ts) @@ -184,5 +195,5 @@ Emitted when any prompt provider changes. This registry notification is unfilter 'system-prompt/change'(): void ``` -Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:37`](../../packages/core/system-prompt/src/index.ts) diff --git a/docs/subsystems/system-prompt.zh.md b/docs/subsystems/system-prompt.zh.md index db6932b18f..cafea4f968 100644 --- a/docs/subsystems/system-prompt.zh.md +++ b/docs/subsystems/system-prompt.zh.md @@ -39,7 +39,7 @@ interface ToolProviderResult { ## 提示词段落 -`PromptSection` 是一份只读的同进程注册约定。其文本可以是静态的,也可以从当前组装上下文动态解析。 +`PromptSection` 是一份只读的同进程注册约定。其文本可以是静态的,也可以从当前组装上下文动态解析。协作式组装完成后,一个有效的 `complete` 段会成为唯一的提示词段落。 ```ts type-equiv /** One contributed section of the system prompt (registry input). */ @@ -58,6 +58,13 @@ interface PromptSection { * interpolated later, by {@link renderPrompt}. */ readonly text: string | ((context: AssembleContext) => string) + /** + * Treat this contribution as the complete system prompt. Assembly still + * runs the cooperative waterfall so tools, contexts, and variables can be + * resolved, then restores this exact section as the sole prompt section. + * More than one effective complete section makes assembly fail. + */ + readonly complete?: boolean } ``` @@ -132,14 +139,16 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine /** * Assemble global and scoped providers, detach tool parameters, apply * canonical ordering, then run the assembly waterfall. Scoped sections and - * variables shadow globals; the returned waterfall value is authoritative. + * variables shadow globals. The returned waterfall value is authoritative + * except that an effective complete section is restored afterwards as the + * sole prompt section. * @param context - the optional scope and plugin-defined assembly fields. - * @returns the authoritative post-waterfall assembly. + * @returns the post-waterfall assembly with any complete prompt enforced. */ async assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:325`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:334`](../../packages/core/system-prompt/src/index.ts) @@ -149,7 +158,7 @@ Source: [`packages/core/system-prompt/src/index.ts:325`](../../packages/core/sys #### `system-prompt/assemble` — waterfall -Expert waterfall over the assembled sections, contexts, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. +Expert waterfall over the assembled sections, contexts, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. A registered complete section is restored after this waterfall, so listeners cannot add to or replace that scope's system prompt. ```ts cordis-catalog /** @@ -157,7 +166,9 @@ Expert waterfall over the assembled sections, contexts, tools, and variables. Sc * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners * receive only that scope's assemblies. The returned value is authoritative. * A supplied signal controls only this explicit assembly request and must not - * be retained to control later turns. + * be retained to control later turns. A registered complete section is + * restored after this waterfall, so listeners cannot add to or replace + * that scope's system prompt. * @param assembly - the mutable assembly built from registered providers. * @param context - the caller's per-assembly context. * @mode waterfall @@ -167,7 +178,7 @@ Expert waterfall over the assembled sections, contexts, tools, and variables. Sc Types: [Scoped](scope.md) -Source: [`packages/core/system-prompt/src/index.ts:29`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:31`](../../packages/core/system-prompt/src/index.ts) @@ -184,5 +195,5 @@ Emitted when any prompt provider changes. This registry notification is unfilter 'system-prompt/change'(): void ``` -Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:37`](../../packages/core/system-prompt/src/index.ts) diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 2b608ec739..c3743e38c0 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/testing.md -testing.md: f5e8a478ec86c29c52f4127c51682c1c44fd23a7 -testing.zh.md: bd1fa7d23263d7c6e3bed65ef4ed09576ca47cc1 +testing.md: f330bb1e02f3613c63f3989a8f9128f737bf5c52 +testing.zh.md: db6facb4fa4bf07eda0a6ee7e558c8c60d4c331e diff --git a/docs/testing.md b/docs/testing.md index f5e8a478ec..f330bb1e02 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,7 +9,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Unit** (`pnpm run test`): vitest over package and example specs under their `tests/**` directories plus repository script specs under `scripts/**/*.spec.ts`; tests stay with the code area they exercise. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent tests for contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. Per-file 100% on `packages/bash/pwsh-local/src` needs a real `pwsh`: without one its executor suites self-skip and `vitest.config.ts` exempts the file so pwsh-less hosts stay green, while CI runners ship pwsh and enforce the full bar. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). -- **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless backend scenarios boot their explicit example composition through an unexported JSONL test driver, while `apps/cli` separately owns product `dsh run` acceptance. Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless backend scenarios boot their explicit example composition through an unexported JSONL test driver, while `apps/cli` separately owns product `dsh --profile headless` acceptance. Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). - **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): Chromium compares replayed browser output with `apps/web/tests/snapshots/`. CI forces read-only `DSH_SNAPSHOT=replay`, never writing expected outputs; record/refresh stay local and every diff is reviewed ([web e2e lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI gate decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)). `test:web` [builds first](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md) for plugin CSS. Committed session-format JSONL uses the canonical packed-row layout, and the keyless snapshot gate discovers every such fixture by its `session` header; the [temporary migrator](../scripts/migrate-packed-session-fixtures.ts) rewrites older fixture layouts. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index bd1fa7d232..db6facb4fa 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -9,7 +9,7 @@ - **单元测试**(`pnpm run test`):vitest 运行包(package)和示例各自的 `tests/**` 目录下的测试,以及匹配 `scripts/**/*.spec.ts` 的仓库脚本测试;测试文件与其所覆盖的代码区域放在一起。每个注册表都有一个 HMR(热模块替换)安全测试(dispose(资源释放)贡献的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及针对约定回归的永久测试(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 - **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。`packages/bash/pwsh-local/src` 的按文件 100% 覆盖需要真实的 `pwsh`:缺少它时其 executor 套件会自动跳过,`vitest.config.ts` 会豁免该文件以使无 pwsh 的主机保持绿色,而 CI runner 自带 pwsh,仍按完整标准执行门禁。 - **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md))。 -- **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输约定与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 后端场景通过未导出的 JSONL 测试 driver 启动各自显式的示例组装,而 `apps/cli` 则单独负责产品 CLI(命令行界面)`dsh run` 的验收。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 +- **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输约定与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 后端场景通过未导出的 JSONL 测试 driver 启动各自显式的示例组装,而 `apps/cli` 则单独负责产品 CLI(命令行界面)`dsh --profile headless` 的验收。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 - **Web 浏览器快照**(`pnpm run test:web`;必需的 Linux PR(Pull Request)门禁):Chromium 将回放后的浏览器输出与 `apps/web/tests/snapshots/` 比较。CI 强制只读的 `DSH_SNAPSHOT=replay`,绝不写入预期输出;record/refresh 留在本地,每处 diff 都须评审([web e2e 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md)、[CI 门禁决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md))。`test:web` 会[先构建](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)以交付插件 CSS。 签入仓库的会话格式 JSONL 使用规范打包行布局,无密钥快照门禁会通过 `session` header 发现每一份此类 fixture;[临时迁移器](../scripts/migrate-packed-session-fixtures.ts)会改写旧版 fixture 布局。 diff --git a/docs/user/develop/basic/publish.i18n.yaml b/docs/user/develop/basic/publish.i18n.yaml index d849ac4ae0..91dba947bb 100644 --- a/docs/user/develop/basic/publish.i18n.yaml +++ b/docs/user/develop/basic/publish.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/publish.md -publish.md: 7657654b1467c14b22e0eb6372c2bc4e77db2f38 -publish.zh.md: 7af2ae3a06cc74597d5cbd6fddd46fbab069e287 +publish.md: 8437c7ea5c4cb966f9f3d68977949c78986ec9a5 +publish.zh.md: 4409dbfda060a84b316029d87ec985209cfa286a diff --git a/docs/user/develop/basic/publish.md b/docs/user/develop/basic/publish.md index 7657654b14..8437c7ea5c 100644 --- a/docs/user/develop/basic/publish.md +++ b/docs/user/develop/basic/publish.md @@ -98,7 +98,8 @@ The effective configuration composes over an empty root by applying, in order: 2. The profile's own `cordis.patch.yml`. 3. The home-level `$DSH_HOME/cordis.patch.yml` — machine-local preferences shared by every profile. 4. Each `--patch ` overlay, in argv order. -5. Launcher flag patches (for example `dsh web --port`). + +App arguments are not another patch layer. A surface bundle can resolve them through an ordinary app-owned service, described below. Later layers win per row, and a patch replaces a row's entire `config` value rather than deep-merging keys. Two consequences for bundle authors: @@ -107,6 +108,29 @@ Later layers win per row, and a patch replaces a row's entire `config` value rat In-box bundle names always resolve from the dsh installation itself; pnpm manages only out-of-tree packages, so your bundle can rely on `@deepseek-ai/dsh-base` being present and current. +## Give a surface bundle its own command line + +A bundle that defines a runnable app mounts an ordinary provider plugin: + +```yaml +- id: hello-startup + name: 'dsh-hello-plugin/startup' +``` + +The plugin exports `inject = ['cmdlineArgs']`, calls `parseCmdline` from [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) with its own commander program, and provides the returned value as its app-owned service. The launcher hands every plugin the same immutable arguments after launcher flags, so app-specific flags need no launcher change and multiple plugins may parse the snapshot. The Loader row needs no launcher marker or special kind. + +Rows configured by those arguments inject the provider's service and read it from their own `!!js` options, with the deployment value beside it as the fallback: + +```yaml +- id: my-app + name: '@example/my-app' + inject: [myAppStartup] + config: + port: !!js ctx.myAppStartup.port ?? 8080 +``` + +On `--help`, the provider publishes no service, so those rows never activate. Loader mounts the composition once, waits for each row's ordinary injections, and only then evaluates that row's `!!js` config against its injected context. + ## Installing from GitHub: the build-script catch Publishing to a registry is not required — users can install straight from a git host: diff --git a/docs/user/develop/basic/publish.zh.md b/docs/user/develop/basic/publish.zh.md index 7af2ae3a06..4409dbfda0 100644 --- a/docs/user/develop/basic/publish.zh.md +++ b/docs/user/develop/basic/publish.zh.md @@ -2,14 +2,14 @@ [English](publish.md) | 中文 -前几篇教程通过 `--patch` overlay 加载本地插件。本教程把它打包成可安装的**组合包**,用 `dsh plugin add` 安装进一个 **profile**,并解释决定组合后配置的层顺序。请先完成[插件配置](./config.md)。 +前几篇教程通过 `--patch` overlay 加载本地插件。本教程把它打包成可安装的**组合包**(bundle),用 `dsh plugin add` 安装进一个 **profile**,并解释决定组合后配置的层顺序。请先完成[插件配置](./config.md)。 -## 两个概念,两种 manifest(元数据清单) +## 两个概念,两种 manifest -安装机制建立在两个概念之上。二者都由一份 `package.json` 描述,但它们在 `dsh` 键下携带的 manifest 种类不同,回答的问题也不同: +安装机制建立在两个概念之上。二者都由一份 `package.json` 描述,但它们在 `dsh` 键下携带的 manifest(元数据清单)种类不同,回答的问题也不同: -- **组合包**是附带一个配置层的 npm 包。它的 manifest 声明 `dsh.bundle`,回答的是「这个包贡献什么?」:一个插入或覆盖插件行的 patch 文件。 -- **profile** 是位于 `$DSH_HOME/profiles/` 下、描述一份可启动组合的目录。它的 manifest 声明 `dsh.profile`,回答的是「这套配置由哪些组合包按什么顺序组成?」。 +- **组合包**是附带一个配置层的 npm 包。它的 manifest 声明 `dsh.bundle`,回答的是"这个包贡献什么?":一个插入或覆盖插件行的 patch 文件。 +- **profile** 是位于 `$DSH_HOME/profiles/` 下、描述一份可启动组合的目录。它的 manifest 声明 `dsh.profile`,回答的是"这套配置由哪些组合包按什么顺序组成?"。 组合包是你编写并分发的东西;profile 是用户用 `dsh --profile ` 启动的东西。没有东西同时是两者。 @@ -98,7 +98,8 @@ dsh --profile demo 2. profile 自己的 `cordis.patch.yml`。 3. home 级的 `$DSH_HOME/cordis.patch.yml`——各 profile 共享的机器本地偏好。 4. 每个 `--patch ` overlay,按 argv 顺序。 -5. 启动器 flag patch(例如 `dsh web --port`)。 + +应用参数不是另一层 patch。表层组合包可以通过下文所述的普通应用自有服务解析它们。 后应用的层按行胜出,且 patch 会替换目标行的整个 `config` 值,而不是深度合并各键。这给组合包作者带来两个推论: @@ -107,6 +108,29 @@ dsh --profile demo 内置组合包名称始终从 dsh 安装目录本身解析;pnpm 只管理树外的包,所以你的组合包可以放心依赖 `@deepseek-ai/dsh-base` 存在且与安装保持一致。 +## 让表层组合包持有自己的命令行 + +定义了可运行应用的组合包挂载一个普通提供方插件: + +```yaml +- id: hello-startup + name: 'dsh-hello-plugin/startup' +``` + +该插件导出 `inject = ['cmdlineArgs']`,使用自己的 commander program 调用 [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) 中的 `parseCmdline`,再把返回值作为应用自有服务提供出去。启动器把自身 flag 之后的同一份不可变参数交给每个插件,因此添加应用专属 flag 无需修改启动器,多个插件也可以解析该快照。Loader 行不需要启动器标记或特殊类型。 + +受这些参数配置的行会注入提供方服务,并在自己的 `!!js` 选项中读取它,同时把部署取值写在旁边作为回退: + +```yaml +- id: my-app + name: '@example/my-app' + inject: [myAppStartup] + config: + port: !!js ctx.myAppStartup.port ?? 8080 +``` + +遇到 `--help` 时,提供方不会发布该服务,所以这些行不会激活。Loader 只挂载一次组合,等待每一行的普通注入,再基于其已注入的上下文求值该行的 `!!js` 配置。 + ## 从 GitHub 安装:构建脚本这道坎 发布到注册表不是必须的——用户可以直接从 git 托管安装: @@ -127,7 +151,7 @@ dsh plugin --profile demo add github:you/hello-plugin 然后重新执行 `add`。 -请如实看待这项授权:**允许该包的代码在安装时于你的机器上执行**,且不在 agent(智能体)运行的任何沙箱之内。只对源码可信的包授权,并锁定 commit(`github:you/hello-plugin#`),让后续推送无法悄悄改变实际运行的内容。 +请如实看待这项授权:**允许该包的代码在安装时于你的机器上执行**,且不在 agent 运行的任何沙箱之内。只对源码可信的包授权,并锁定 commit(`github:you/hello-plugin#`),让后续推送无法悄悄改变实际运行的内容。 如果不想让用户做这项授权,就改为分发构建产物——以下两种形式都不需要任何构建权限: diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index 7feb2c7f07..00a8867092 100644 --- a/docs/user/guide/config.i18n.yaml +++ b/docs/user/guide/config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/config.md -config.md: cd778065801ae58a46703ae3447f835f80abf062 -config.zh.md: 6f6d37bfe8f7ad29c154d65c1763279655006435 +config.md: 1d3ad5ce36d4b360ba5156b6be28a6caae4a23d4 +config.zh.md: 7f8bfaa77066f2976a5667e3ac402814a7afdf96 diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md index cd77806580..1d3ad5ce36 100644 --- a/docs/user/guide/config.md +++ b/docs/user/guide/config.md @@ -18,6 +18,10 @@ A minimal configuration is a list of plugin entries: ```yaml - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + models: + - deepseek-v4-flash - id: bash name: '@deepseek-ai/dsh-bash-local' @@ -47,16 +51,17 @@ Cordis starts sibling entries concurrently. A plugin declares required services ## CLI patch layers -`dsh --profile ` composes the profile's bundle patch layers (its manifest's `dsh.profile.bundles` list, in order) over an empty root, then the profile's own `~/.dsh/profiles//cordis.patch.yml`, then each `--patch ` overlay, then CLI-flag patches. Later layers win per row. +`dsh --profile ` composes the profile's bundle patch layers (its manifest's `dsh.profile.bundles` list, in order) over an empty root, then the profile's own `~/.dsh/profiles//cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and each `--patch ` overlay. Later layers win per row. App flags are not another patch layer: an ordinary bundle plugin injects `cmdlineArgs` and provides parsed values as its own service, while rows that inject and retain a `!!js` read of that service give the invocation value precedence. -A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKeyEnv` and `baseURL`, so restate every key the row must retain. +A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKey` and `baseURL`, so restate every key the row must retain. ## JavaScript values and environment variables -The Cordis loader evaluates runtime expressions tagged with `!!js` for non-secret runtime values. Bundled LLM adapters carry credential references such as `apiKeyEnv`; the value belongs in an environment layer or `$DSH_HOME/.credentials.yaml`, not Cordis configuration. +The Cordis loader evaluates runtime expressions tagged with `!!js`. Keep API keys and other secrets in the gitignored `.env` file at the repository root, never in committed configuration. ```yaml config: + apiKey: !!js process.env.DEEPSEEK_API_KEY cwd: !!js process.cwd() ``` diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md index 6f6d37bfe8..7f8bfaa770 100644 --- a/docs/user/guide/config.zh.md +++ b/docs/user/guide/config.zh.md @@ -18,6 +18,10 @@ Harness 使用 `cordis.yml` 描述 agent(智能体)加载哪些插件以及 ```yaml - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + models: + - deepseek-v4-flash - id: bash name: '@deepseek-ai/dsh-bash-local' @@ -47,16 +51,17 @@ Cordis 会并发启动同级配置项。插件通过 `inject` 声明必需服务 ## CLI 补丁层 -`dsh --profile ` 按该 profile 的 manifest(元数据清单)中 `dsh.profile.bundles` 列表的顺序,在空根之上组合各组合包补丁层,随后依次应用该 profile 自己的 `~/.dsh/profiles//cordis.patch.yml`、home 级 `$DSH_HOME/cordis.patch.yml`、每个 `--patch ` overlay,最后是 CLI(命令行界面)标志补丁。同一行以较后的层为准。 +`dsh --profile ` 按该 profile 的 manifest(元数据清单)中 `dsh.profile.bundles` 列表的顺序,在空根之上组合各组合包补丁层,随后依次应用该 profile 自己的 `~/.dsh/profiles//cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 与每个 `--patch ` overlay。同一行以较后的层为准。应用 flag 并不是另一层 patch:组合包中的普通插件注入 `cmdlineArgs`,再把解析值作为自身服务提供;注入该服务并保留其 `!!js` 读取的行会让本次调用的取值优先。 -补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKeyEnv` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 +补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKey` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 ## JavaScript 值和环境变量 -Cordis loader 会求值以 `!!js` 标记的运行时表达式,用于非机密的运行时值。仓库内置的 LLM(大语言模型)适配器携带 `apiKeyEnv` 等凭据引用;对应的值应放在环境层或 `$DSH_HOME/.credentials.yaml`,而不是 Cordis 配置中。 +Cordis loader 使用 `!!js` 标签读取运行时表达式。API key 等凭据应放在仓库根目录、已被 Git 忽略的 `.env` 中,不能提交到配置文件。 ```yaml config: + apiKey: !!js process.env.DEEPSEEK_API_KEY cwd: !!js process.cwd() ``` @@ -64,4 +69,4 @@ config: ## 精确配置参考 -每个插件当前支持的字段、类型和默认值见自动生成的[插件配置目录](../../config-catalog.md)。理解插件如何组合可继续阅读[架构说明](../../architecture.md)和[能力 seam](../../capability-seams.md);要创建自己的配置,优先复制并修改[示例目录说明](../../../examples/README.md)中最接近的例子。 +每个插件当前支持的字段、类型和默认值见自动生成的[插件配置目录](../../config-catalog.md)。理解插件如何组合可继续阅读[架构说明](../../architecture.md)和[能力接口](../../capability-seams.md);要创建自己的配置,优先复制并修改[示例目录说明](../../../examples/README.md)中最接近的例子。 diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml new file mode 100644 index 0000000000..04cfa163e7 --- /dev/null +++ b/docs/user/guide/python-sdk.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/user/guide/python-sdk.md +python-sdk.md: c48bc95c9334cfd16a925d12726c20b2f968c753 +python-sdk.zh.md: dc31c391a180a742c7dc10807f6ed2ef8d11927d diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md new file mode 100644 index 0000000000..c48bc95c93 --- /dev/null +++ b/docs/user/guide/python-sdk.md @@ -0,0 +1,130 @@ +# Get started with the Python SDK + +English | [中文](python-sdk.zh.md) + +This tutorial installs the Python SDK, runs a checked-in Cordis composition without the Web UI, and uses the same API in your own program. It uses the compact [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) configuration as a complete example with a fixed system prompt, tool catalog, persistent-shell behavior, and compaction policy. + +## Prerequisites + +- Python 3.10 or newer +- Linux x64, Linux arm64, or macOS arm64 +- A DeepSeek-compatible API endpoint and credential +- An isolated workspace that the agent may modify + +## Install the SDK + +Choose either the public package or a source build. Both install the `deepseek-harness-sdk` distribution and expose the `deepseek_harness` Python module. + +### Install from PyPI + +Create a virtual environment and install the SDK with its same-version bundled runtime: + +```sh +python -m venv .venv +. .venv/bin/activate +python -m pip install deepseek-harness-sdk +``` + +### Build from source + +A source build additionally requires Git, Node.js ^22.19 or >= 24, Corepack-enabled pnpm 11, and `uv`. The following commands build the runtime for the current supported host platform, build both wheels, and install them into the active virtual environment: + +```sh +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git deepseek-harness +cd deepseek-harness +python -m pip install uv==0.11.23 +corepack enable +pnpm install + +case "$(uname -s):$(uname -m)" in + Linux:x86_64) runtime_platform=linux-x64 ;; + Linux:aarch64|Linux:arm64) runtime_platform=linux-arm64 ;; + Darwin:arm64) runtime_platform=macos-arm64 ;; + *) echo "unsupported platform" >&2; exit 1 ;; +esac + +pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets="node24-$runtime_platform" +version="$(node -p "require('./package.json').version")" +python scripts/build-python-release.py --package sdk --output-dir dist-python +python scripts/build-python-release.py \ + --package runtime \ + --platform "$runtime_platform" \ + --runtime-exe "dist-exe/dsh-jsonrpc-agent-pkg-$runtime_platform" \ + --output-dir dist-python +python -m pip install --find-links dist-python "deepseek-harness-sdk==$version" +``` + +The runtime wheel contains the JSON-RPC executable and every plugin used by the complete [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml), so neither installation path needs Node.js after installation. + +## Run the checked-in example + +Set the credential in the environment. Set `DEEPSEEK_BASE_URL` as well when the model is served by an OpenAI-compatible proxy rather than the default DeepSeek endpoint. + +```sh +export DEEPSEEK_API_KEY=sk-your-key-here +# export DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 +``` + +Run one task from the repository checkout: + +```sh +python examples/jsonrpc-agent/minimal.py \ + --workspace /absolute/path/to/workspace \ + --session-root /absolute/path/to/sessions \ + --session-id example-001 \ + "Inspect the repository and fix the failing tests." +``` + +The script prints the final assistant response. The session root receives a JSONL session log containing the assembled model request and every tool call. + +## Use the SDK in your own program + +The example is a thin wrapper around this SDK call: + +```python +from pathlib import Path + +from deepseek_harness import DeepSeekHarness + +config = Path("examples/jsonrpc-agent/minimal.cordis.yml").resolve() +workspace = Path("/absolute/path/to/workspace").resolve() +sessions = Path("/absolute/path/to/sessions").resolve() + +with DeepSeekHarness( + provider="deepseek-official", + model="deepseek-v4-flash", + max_tokens=49_152, + cwd=str(workspace), + session_root=str(sessions), + cordis=str(config), +) as harness: + result = harness.run( + "Inspect the repository and fix the failing tests.", + session_id="example-001", + ) + +print(result.final_response) +``` + +`DeepSeekHarness` starts the bundled JSON-RPC runtime lazily and reuses it until the context manager exits. Reusing the same harness and session id across calls also preserves the session-owned Bash process, including its working directory, exported variables, and shell functions. + +## Understand the example configuration + +| Surface | Fixed value | +|---|---| +| System prompt | `You are a helpful software engineer assistant.` | +| Model-facing tools | Persistent `bash` and `str_replace_editor` only | +| Bash timeout | 300 seconds | +| Editor output limit | 16,000 characters | +| Compaction | Trigger ratio `0.8`, retain `20,480` tokens, summary cap `8,192` tokens, one retry | +| Session persistence | Uncompressed JSONL under `DSH_SESSION_ROOT` | + +The configuration omits harness identity, workspace prompt text, skills, one-shot Bash, task tools, and every other model-facing plugin. Filesystem policy facts are logged as runtime user context rather than appended to the system prompt. The editor requires absolute paths as an unconditional current contract, so the obsolete `requireAbsolutePath` option is absent. + +## Choose workspace and session IDs + +`cwd` selects the workspace available to the agent, while `session_root` stores session logs and state. Use a fresh session id for an independent task; reuse an id only when the next call should continue the same conversation and persistent shell state. + +The composition uses `danger-full-access`. Run it only inside a disposable checkout or container: Bash and the editor can modify any path allowed to the runtime process. The persistent PTY backend requires a POSIX terminal substrate and is not a Windows agent surface. + +For the complete SDK lifecycle and result contract, see the [Python SDK reference](../../../python/sdk/README.md). For Cordis composition syntax, see [Configuration](./config.md). diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md new file mode 100644 index 0000000000..dc31c391a1 --- /dev/null +++ b/docs/user/guide/python-sdk.zh.md @@ -0,0 +1,130 @@ +# Python SDK 快速上手 + +[English](python-sdk.md) | 中文 + +本教程介绍如何安装 Python SDK、在不使用 Web UI 的情况下运行仓库内置 Cordis 组合,以及如何在自己的程序中调用同一套 API。教程使用精简且完整的 [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) 作为示例,其中固定了系统提示词、工具目录、持久 shell 行为和压缩(compaction)策略。 + +## 前置要求 + +- Python 3.10 或更高版本 +- Linux x64、Linux arm64 或 macOS arm64 +- DeepSeek 兼容的 API 端点与凭据 +- agent 可以修改的隔离 workspace + +## 安装 SDK + +可以选择安装公开包或从源码构建。两种方式都会安装 `deepseek-harness-sdk` 分发包,并提供 `deepseek_harness` Python 模块。 + +### 从 PyPI 安装 + +请创建虚拟环境,并安装 SDK 及其同版本内置运行时: + +```sh +python -m venv .venv +. .venv/bin/activate +python -m pip install deepseek-harness-sdk +``` + +### 从源码构建 + +从源码构建还需要 Git、Node.js ^22.19 或 >= 24、通过 Corepack 启用的 pnpm 11,以及 `uv`。以下命令为当前受支持的宿主平台构建运行时和两个 wheel 包,并将它们安装进当前虚拟环境: + +```sh +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git deepseek-harness +cd deepseek-harness +python -m pip install uv==0.11.23 +corepack enable +pnpm install + +case "$(uname -s):$(uname -m)" in + Linux:x86_64) runtime_platform=linux-x64 ;; + Linux:aarch64|Linux:arm64) runtime_platform=linux-arm64 ;; + Darwin:arm64) runtime_platform=macos-arm64 ;; + *) echo "unsupported platform" >&2; exit 1 ;; +esac + +pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets="node24-$runtime_platform" +version="$(node -p "require('./package.json').version")" +python scripts/build-python-release.py --package sdk --output-dir dist-python +python scripts/build-python-release.py \ + --package runtime \ + --platform "$runtime_platform" \ + --runtime-exe "dist-exe/dsh-jsonrpc-agent-pkg-$runtime_platform" \ + --output-dir dist-python +python -m pip install --find-links dist-python "deepseek-harness-sdk==$version" +``` + +运行时 wheel 包含 JSON-RPC 可执行文件,以及完整 [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) 使用的每个插件,因此两种安装方式完成后都不再需要 Node.js。 + +## 运行仓库内置示例 + +请在环境中设置凭据。如果模型不是由默认 DeepSeek 端点提供,而是通过 OpenAI 兼容代理提供,还需要设置 `DEEPSEEK_BASE_URL`。 + +```sh +export DEEPSEEK_API_KEY=sk-your-key-here +# export DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 +``` + +从仓库 checkout 运行一个任务: + +```sh +python examples/jsonrpc-agent/minimal.py \ + --workspace /absolute/path/to/workspace \ + --session-root /absolute/path/to/sessions \ + --session-id example-001 \ + "Inspect the repository and fix the failing tests." +``` + +脚本会打印 assistant 的最终回复。会话根目录会收到 JSONL 会话日志,其中包含组装后的模型请求与每次工具调用。 + +## 在自己的程序中使用 SDK + +该示例是以下 SDK 调用的轻量包装层: + +```python +from pathlib import Path + +from deepseek_harness import DeepSeekHarness + +config = Path("examples/jsonrpc-agent/minimal.cordis.yml").resolve() +workspace = Path("/absolute/path/to/workspace").resolve() +sessions = Path("/absolute/path/to/sessions").resolve() + +with DeepSeekHarness( + provider="deepseek-official", + model="deepseek-v4-flash", + max_tokens=49_152, + cwd=str(workspace), + session_root=str(sessions), + cordis=str(config), +) as harness: + result = harness.run( + "Inspect the repository and fix the failing tests.", + session_id="example-001", + ) + +print(result.final_response) +``` + +`DeepSeekHarness` 会延迟启动内置 JSON-RPC 运行时,并持续复用,直至退出上下文管理器。在多次调用中复用同一个 harness 和 session id,还会保留该会话拥有的 Bash 进程,包括其工作目录、已导出的变量与 shell 函数。 + +## 了解示例配置 + +| 方面 | 固定值 | +|---|---| +| 系统提示词 | `You are a helpful software engineer assistant.` | +| 面向模型的工具 | 仅持久 `bash` 与 `str_replace_editor` | +| Bash 超时 | 300 秒 | +| 编辑器输出上限 | 16,000 个字符 | +| 压缩 | 触发比例 `0.8`、保留 `20,480` 个 token、摘要上限 `8,192` 个 token、重试 1 次 | +| 会话持久化 | `DSH_SESSION_ROOT` 下未压缩的 JSONL | + +该配置省略了 harness 身份、workspace 提示词文本、skill(技能)、一次性 Bash、任务工具和其他所有面向模型的插件。文件系统策略事实记录为运行时用户上下文,而不会追加到系统提示词中。编辑器无条件要求绝对路径,因此配置中没有已经废弃的 `requireAbsolutePath` 选项。 + +## 选择 workspace 与 session id + +`cwd` 用于选择 agent 可访问的 workspace,`session_root` 用于保存会话日志和状态。独立任务应使用新的 session id;只有下一次调用需要延续同一段对话和持久 shell 状态时,才复用原有 id。 + +该组合使用 `danger-full-access`。只能在可丢弃的 checkout 或容器内运行:Bash 与编辑器可以修改运行时进程有权访问的任何路径。持久 PTY 后端需要 POSIX 终端环境,因此该模式不适用于 Windows agent。 + +完整的 SDK 生命周期与结果约定见 [Python SDK 参考](../../../python/sdk/README.md)。Cordis 组合语法见[配置](./config.md)。 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index dc9b7cb25b..f31426730a 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/quickstart.md -quickstart.md: ce196641f205324334533c025b4ac1dc791f857d -quickstart.zh.md: 3a5d6d0748ec0c7ec83c74570d0fad1e8d66a97c +quickstart.md: 8e883efd470fef329a7308f1017cab0b7afcdd67 +quickstart.zh.md: e0e3f4e4754bca588831b6d204f77d1c97a140b3 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index ce196641f2..8e883efd47 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -36,10 +36,10 @@ DEEPSEEK_API_KEY=sk-your-key-here Run a non-interactive task and print its final answer: ```sh -pnpm run dsh run "summarize the architecture of this workspace" +pnpm run dsh --profile headless "summarize the architecture of this workspace" ``` -`dsh run` creates and persists a fresh session, prints the final assistant answer, and exits. It starts no Web server or listening port, and a successful run leaves stderr empty. +`dsh --profile headless` creates and persists a fresh session, prints the final assistant answer, and exits. It starts no Web server or listening port, and a successful run leaves stderr empty. ## Step 3: use the Web UI @@ -53,10 +53,11 @@ Open `http://127.0.0.1:3080`. The agent can read and write files, run commands, ## What happened -`dsh run` boots the `headless` profile: [`dsh-base`](../../../packages/bundle/base/cordis.patch.yml) and [`dsh-headless`](../../../packages/bundle/headless/cordis.patch.yml) compose over an empty root, then the runner drives the core Agent and Session services directly. `dsh web` instead composes `dsh-base` with [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml), which owns the Host, HTTP, and browser layers. Both read the same default DeepSeek model route from `dsh-base`. +`dsh --profile headless` boots the `headless` profile: [`dsh-base`](../../../packages/bundle/base/cordis.patch.yml) and [`dsh-headless`](../../../packages/bundle/headless/cordis.patch.yml) compose over an empty root, then the runner drives the core Agent and Session services directly. `dsh web` instead composes `dsh-base` with [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml), which owns the Host, HTTP, and browser layers. Both read the same default DeepSeek model route from `dsh-base`. ## Next steps +- [Get started with the Python SDK](./python-sdk.md) — install the SDK and run a complete Cordis configuration without the Web UI - [Configure models](./providers.md) — reach providers beyond DeepSeek, and custom gateways - [Configuration](./config.md) — understand the `cordis.yml` format - [Develop a plugin](../develop/basic/) — build your own tool or backend diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index 3a5d6d0748..e0e3f4e475 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -36,10 +36,10 @@ DEEPSEEK_API_KEY=sk-your-key-here 运行一个非交互式任务并打印最终回答: ```sh -pnpm run dsh run "summarize the architecture of this workspace" +pnpm run dsh --profile headless "summarize the architecture of this workspace" ``` -`dsh run` 创建并持久化一个新会话,打印最终助手回答,然后退出。它不会启动 Web 服务器或监听端口;成功运行时 stderr 为空。 +`dsh --profile headless` 创建并持久化一个新会话,打印最终助手回答,然后退出。它不会启动 Web 服务器或监听端口;成功运行时 stderr 为空。 ## 第三步:使用 Web UI @@ -53,10 +53,11 @@ pnpm run dsh web ## 运行原理 -`dsh run` 启动 `headless` profile:[`dsh-base`](../../../packages/bundle/base/cordis.patch.yml) 和 [`dsh-headless`](../../../packages/bundle/headless/cordis.patch.yml) 在空根之上组合,随后 runner 直接驱动 core Agent 与 Session 服务。`dsh web` 则由 `dsh-base` 与 [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml) 组合,后者拥有 Host、HTTP 与浏览器层。二者都从 `dsh-base` 读取同一个默认 DeepSeek 模型路由。 +`dsh --profile headless` 启动 `headless` profile:[`dsh-base`](../../../packages/bundle/base/cordis.patch.yml) 和 [`dsh-headless`](../../../packages/bundle/headless/cordis.patch.yml) 在空根之上组合,随后 runner 直接驱动 core Agent 与 Session 服务。`dsh web` 则由 `dsh-base` 与 [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml) 组合,后者拥有 Host、HTTP 与浏览器层。二者都从 `dsh-base` 读取同一个默认 DeepSeek 模型路由。 ## 下一步 +- [Python SDK 快速上手](./python-sdk.md) — 安装 SDK,并在不使用 Web UI 的情况下运行完整 Cordis 配置 - [配置模型](./providers.md) — 接入 DeepSeek 之外的提供方与自定义网关 - [配置文件](./config.md) — 了解 `cordis.yml` 的格式 - [开发插件](../develop/basic/) — 编写自己的工具或后端 diff --git a/examples/acp-agent/product-subagent-both.cordis.snapshot.yml b/examples/acp-agent/product-subagent-both.cordis.snapshot.yml new file mode 100644 index 0000000000..73d22eae27 --- /dev/null +++ b/examples/acp-agent/product-subagent-both.cordis.snapshot.yml @@ -0,0 +1,38 @@ +# Keyless twin of product-subagent-both.cordis.yml: preserve both product +# tools while replacing only the external model adapter. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + - id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed diff --git a/examples/acp-agent/product-subagent-both.cordis.yml b/examples/acp-agent/product-subagent-both.cordis.yml new file mode 100644 index 0000000000..ce9d054058 --- /dev/null +++ b/examples/acp-agent/product-subagent-both.cordis.yml @@ -0,0 +1,27 @@ +# Add both native product providers and the same independent foreground tool +# rows an Agent Preset may contribute. Loading the composition starts neither +# product; the scenario pins both model-visible schemas. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + - id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed diff --git a/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml b/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml new file mode 100644 index 0000000000..8b8cd8604c --- /dev/null +++ b/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml @@ -0,0 +1,29 @@ +# Keyless twin of product-subagent-codex.cordis.yml: keep the same product +# provider/tool composition and replace only the external model adapter. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed diff --git a/examples/acp-agent/product-subagent-codex.cordis.yml b/examples/acp-agent/product-subagent-codex.cordis.yml new file mode 100644 index 0000000000..55f9985b7d --- /dev/null +++ b/examples/acp-agent/product-subagent-codex.cordis.yml @@ -0,0 +1,18 @@ +# Add the native Codex product provider and its preset-shaped foreground tool to +# the real ACP composition. The model is told not to call it; the scenario pins +# the assembled request schema without starting Codex. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 73b9e859de..69b7b7336d 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -55,6 +55,8 @@ const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) const PARTIAL_LANDLOCK_CONFIG = fileURLToPath(new URL('../partial-landlock.cordis.yml', import.meta.url)) const PWSH_CONFIG = fileURLToPath(new URL('./pwsh.cordis.yml', import.meta.url)) +const PRODUCT_SUBAGENT_CODEX_CONFIG = fileURLToPath(new URL('../product-subagent-codex.cordis.yml', import.meta.url)) +const PRODUCT_SUBAGENT_BOTH_CONFIG = fileURLToPath(new URL('../product-subagent-both.cordis.yml', import.meta.url)) const FS_DIFF_BOUND_CONFIG = fileURLToPath(new URL('./fs-diff-bound.cordis.yml', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' @@ -126,6 +128,27 @@ const SCENARIOS: Scenario[] = [ // text-turn is the default header pin and owns the prompt and tool-schema // sidecars reused by alternate classes with identical component sequences. { name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true }, + // Product-subagent scenarios are authored schema-isolation fixtures: they + // reuse the stable text-turn transcript so only Loader-composed headers and + // tool sidecars vary. Model output and usage are not evidence here, so record + // mode must not replace them with live-API output. + { + name: 'product-subagent-codex', + hasModelTurn: true, + recorded: false, + pinsHeader: true, + headerClass: 'product-subagent-codex', + configPath: PRODUCT_SUBAGENT_CODEX_CONFIG, + }, + { + name: 'product-subagent-both', + hasModelTurn: true, + recorded: false, + pinsHeader: true, + headerClass: 'product-subagent-both', + systemPromptSource: 'product-subagent-codex', + configPath: PRODUCT_SUBAGENT_BOTH_CONFIG, + }, { name: 'session-title-after-turn', hasModelTurn: true, diff --git a/examples/acp-agent/tests/snapshots/product-subagent-both/input.json b/examples/acp-agent/tests/snapshots/product-subagent-both/input.json new file mode 100644 index 0000000000..5fe0259a4e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-both/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-both/session.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-both/session.jsonl new file mode 100644 index 0000000000..84ac27e70e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-both/session.jsonl @@ -0,0 +1,22 @@ +{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1785498761270,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3e25dc34-48e0-4738-8401-1a8d181d37e5"}]}} +{"type":"turn/start","seq":1,"time":1785821359466,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821359466,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498761313,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3e25dc34-48e0-4738-8401-1a8d181d37e5"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730415287,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"4b8d9730-0b7b-4e14-8a30-3d852f808f0e"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730415287,"data":{"title":"Reply with exactly the word:","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498761318,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730415288,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":9,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783600630852,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}} +{"type":"assistant/chunk","seq":30,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":31,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}} +{"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} +{"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} +{"type":"assistant/chunk","seq":35,"time":1785498761338,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":36,"time":1785730415297,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":37,"time":1785730415298,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"f1418376-f303-4017-acd7-92899c841c8a"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36],"surfaceOp":"append"} +{"type":"step/end","seq":38,"time":1785730415298,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":39,"time":1785730415298,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-both/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-both/stdout.expected.jsonl new file mode 100644 index 0000000000..acfccdd778 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-both/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json new file mode 100644 index 0000000000..76f60e28d4 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json @@ -0,0 +1,569 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "interrupt_agent", + "description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The agent id of the running agent to interrupt." + } + }, + "required": [ + "agent_id" + ] + } + }, + { + "name": "list_agents", + "description": "List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.", + "parameters": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "children (default) lists direct children only; descendants walks the complete tree below you.", + "enum": [ + "children", + "descendants" + ] + } + } + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "send_message", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_claude_code", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_codex", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/input.json b/examples/acp-agent/tests/snapshots/product-subagent-codex/input.json new file mode 100644 index 0000000000..5fe0259a4e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/session.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-codex/session.jsonl new file mode 100644 index 0000000000..bd47dfa24f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/session.jsonl @@ -0,0 +1,22 @@ +{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1785498761270,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3e25dc34-48e0-4738-8401-1a8d181d37e5"}]}} +{"type":"turn/start","seq":1,"time":1785821359466,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821359466,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498761313,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3e25dc34-48e0-4738-8401-1a8d181d37e5"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730415287,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"4b8d9730-0b7b-4e14-8a30-3d852f808f0e"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730415287,"data":{"title":"Reply with exactly the word:","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498761318,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730415288,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":9,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783600630852,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}} +{"type":"assistant/chunk","seq":30,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":31,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}} +{"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} +{"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} +{"type":"assistant/chunk","seq":35,"time":1785498761338,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":36,"time":1785730415297,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":37,"time":1785730415298,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"c883cf16-01fe-4afc-b37c-d255bb450d21"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36],"surfaceOp":"append"} +{"type":"step/end","seq":38,"time":1785730415298,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":39,"time":1785730415298,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-codex/stdout.expected.jsonl new file mode 100644 index 0000000000..acfccdd778 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/product-subagent-codex/system-prompt.expected.md new file mode 100644 index 0000000000..a6ffe7d4d7 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/system-prompt.expected.md @@ -0,0 +1,22 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a coding assistant powered by the deepseek-v4-pro model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. + +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json new file mode 100644 index 0000000000..84c4671579 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json @@ -0,0 +1,548 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "interrupt_agent", + "description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The agent id of the running agent to interrupt." + } + }, + "required": [ + "agent_id" + ] + } + }, + { + "name": "list_agents", + "description": "List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.", + "parameters": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "children (default) lists direct children only; descendants walks the complete tree below you.", + "enum": [ + "children", + "descendants" + ] + } + } + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "send_message", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_codex", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/examples/headless-agent/README.i18n.yaml b/examples/headless-agent/README.i18n.yaml index 90dd1c4f2a..0871ba2c15 100644 --- a/examples/headless-agent/README.i18n.yaml +++ b/examples/headless-agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/headless-agent/README.md -README.md: f12a56920c79f3a7e257c4e56163f323e4312d11 -README.zh.md: 9e409735f03afc62cd788fa2a5d1afdef0fa6c2a +README.md: 08b36e9c8e558db3b30175b20bd6e0af160ccef5 +README.zh.md: 40d11ebeeb1631af1421b3c013a5e548fd3fd488 diff --git a/examples/headless-agent/README.md b/examples/headless-agent/README.md index f12a56920c..08b36e9c8e 100644 --- a/examples/headless-agent/README.md +++ b/examples/headless-agent/README.md @@ -10,10 +10,10 @@ This directory owns the replay and real-model test composition for a headless co # repo root .env (gitignored) or exported env: # DEEPSEEK_API_KEY=sk-… # DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run dsh run "fix the failing test in this workspace" +pnpm run dsh --profile headless "fix the failing test in this workspace" ``` -The product command is [`dsh run`](../../apps/cli/README.md): it accepts one nonblank task, creates and persists a fresh session, prints the final assistant text, and exits. The root `demo:headless` script is only an alias of that command. +The product command is [`dsh --profile headless`](../../apps/cli/README.md): it accepts one nonblank task, creates and persists a fresh session, prints the final assistant text, and exits. The root `demo:headless` script is only an alias of that command. Snapshot suites run this directory's configuration through [`tests/fixtures/headless-driver.ts`](tests/fixtures/headless-driver.ts), an unexported test-only process that emits canonical session events as JSONL before its result record. That stream is test infrastructure, not a supported CLI output format. Child sessions surface only through parent tool events and results. diff --git a/examples/headless-agent/README.zh.md b/examples/headless-agent/README.zh.md index 9e409735f0..40d11ebeeb 100644 --- a/examples/headless-agent/README.zh.md +++ b/examples/headless-agent/README.zh.md @@ -10,10 +10,10 @@ # repo root .env (gitignored) or exported env: # DEEPSEEK_API_KEY=sk-… # DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run dsh run "fix the failing test in this workspace" +pnpm run dsh --profile headless "fix the failing test in this workspace" ``` -产品命令是 [`dsh run`](../../apps/cli/README.md):它接受一项非空任务,创建并持久化新会话,打印最终 assistant 文本,然后退出。根目录的 `demo:headless` 脚本只是该命令的别名。 +产品命令是 [`dsh --profile headless`](../../apps/cli/README.md):它接受一项非空任务,创建并持久化新会话,打印最终 assistant 文本,然后退出。根目录的 `demo:headless` 脚本只是该命令的别名。 快照套件通过 [`tests/fixtures/headless-driver.ts`](tests/fixtures/headless-driver.ts) 运行本目录的配置。这个未导出且仅供测试使用的进程会在结果记录之前,以 JSONL 发出规范会话事件。该事件流属于测试基础设施,不是受支持的 CLI(命令行界面)输出格式。子会话只通过父会话的工具事件和结果对外显示。 diff --git a/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml b/examples/headless-agent/tests/fixtures/headless-profile.cordis.yml similarity index 100% rename from examples/headless-agent/tests/fixtures/dsh-run.cordis.yml rename to examples/headless-agent/tests/fixtures/headless-profile.cordis.yml diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index b09458ec3d..5fc337527e 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -52,9 +52,9 @@ const dshBinScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', impor const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url)) const deepseekDefaultsConfigPath = fileURLToPath(new URL('./fixtures/deepseek-defaults.cordis.yml', import.meta.url)) -const dshRunOverlayPath = fileURLToPath(new URL('./fixtures/dsh-run.cordis.yml', import.meta.url)) -const dshRunSessionExpected = join(snapshotsDir, 'dsh-run', 'session.expected.jsonl') -const dshRunFailureExpected = join(snapshotsDir, 'dsh-run', 'stderr.expected.txt') +const headlessOverlayPath = fileURLToPath(new URL('./fixtures/headless-profile.cordis.yml', import.meta.url)) +const headlessSessionExpected = join(snapshotsDir, 'headless-profile', 'session.expected.jsonl') +const headlessFailureExpected = join(snapshotsDir, 'headless-profile', 'stderr.expected.txt') const cliMockLlmPluginPath = fileURLToPath(new URL('./fixtures/cli-mock-llm.ts', import.meta.url)) const refreshing = process.env.DSH_SNAPSHOT === 'refresh' @@ -217,14 +217,14 @@ async function prepareCliMockFixture(cwd: string): Promise { } describe('headless stream-json snapshots', () => { - it('runs one task through the product dsh run command', async () => { - const task = 'Prove the product dsh run path with one real tool round trip.' + it('runs one task through the product headless profile command', async () => { + const task = 'Prove the product headless profile path with one real tool round trip.' const result = await runLoaderSmoke({ - label: 'product dsh run snapshot', - tempDirPrefix: 'headless-snapshot-dsh-run-', + label: 'product headless profile snapshot', + tempDirPrefix: 'headless-snapshot-profile-', binScript: dshBinScript, - configPath: dshRunOverlayPath, - binArgs: ['run', '--patch', dshRunOverlayPath, task], + configPath: headlessOverlayPath, + binArgs: ['--profile', 'headless', '--patch', headlessOverlayPath, task], tsconfigPath, env: { DSH_PERMISSION_MODE: 'danger-full-access', @@ -236,11 +236,11 @@ describe('headless stream-json snapshots', () => { const logs = await persistedLogs(cwd, join(cwd, '.dsh', 'sessions')) expect(logs).toHaveLength(1) const actual = logs[0] - if (actual === undefined) throw new Error('dsh run did not persist its session') + if (actual === undefined) throw new Error('the headless profile did not persist its session') const context = contextFromLogs([actual.content]) const session = scrubRequestHeaders(normalizeSessionLog(actual.content, context)) - if (refreshing) await writeFile(dshRunSessionExpected, session) - expect(session).toBe(await readFile(dshRunSessionExpected, 'utf8')) + if (refreshing) await writeFile(headlessSessionExpected, session) + expect(session).toBe(await readFile(headlessSessionExpected, 'utf8')) expect(session).toContain(task) expect(session).toContain('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP') }, @@ -250,13 +250,13 @@ describe('headless stream-json snapshots', () => { expect(result.stderr).toBe('') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('prints a terminal model failure through the product dsh run command', async () => { + it('prints a terminal model failure through the product headless profile command', async () => { const result = await runLoaderSmoke({ - label: 'product dsh run model failure snapshot', - tempDirPrefix: 'headless-snapshot-dsh-run-failure-', + label: 'product headless profile model failure snapshot', + tempDirPrefix: 'headless-snapshot-profile-failure-', binScript: dshBinScript, - configPath: dshRunOverlayPath, - binArgs: ['run', '--patch', dshRunOverlayPath, 'Trigger the keyless model failure.'], + configPath: headlessOverlayPath, + binArgs: ['--profile', 'headless', '--patch', headlessOverlayPath, 'Trigger the keyless model failure.'], tsconfigPath, expectedExitCode: 1, env: { @@ -268,7 +268,7 @@ describe('headless stream-json snapshots', () => { }) expect(result.stdout).toBe('\n') - await expect(result.stderr).toMatchFileSnapshot(dshRunFailureExpected) + await expect(result.stderr).toMatchFileSnapshot(headlessFailureExpected) }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('prints the original Loader activation error through the assembled one-shot app', async () => { diff --git a/examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl b/examples/headless-agent/tests/snapshots/headless-profile/session.expected.jsonl similarity index 91% rename from examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl rename to examples/headless-agent/tests/snapshots/headless-profile/session.expected.jsonl index 260ae241b0..65b6f393a6 100644 --- a/examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/headless-profile/session.expected.jsonl @@ -2,16 +2,16 @@ {"type":"permission/preset","seq":0,"time":0,"data":{"preset":"danger-full-access"}} {"type":"sandbox/mode","seq":1,"time":0,"data":{"mode":"danger-full-access"}} {"type":"approval/policy","seq":2,"time":0,"data":{"policy":"never"}} -{"type":"agent/inbox/spliced","seq":3,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Prove the product dsh run path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"agent/inbox/spliced","seq":3,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Prove the product headless profile path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} {"type":"turn/start","seq":4,"time":0,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Prove the product dsh run path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Prove the product headless profile path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":9,"time":0,"data":{"title":"Prove the product dsh run","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"session/title","seq":9,"time":0,"data":{"title":"Prove the product headless profile","messageSeqs":[7],"source":{"kind":"fallback"}}} {"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":11,"time":0,"data":{"provider":"cli-mock","model":"cli-mock"}} -{"type":"session/title-llm-request","seq":12,"time":0,"data":{"titleProvider":"session-title-first-message-llm","messageSeqs":[7],"route":{"provider":"cli-mock","model":"cli-mock"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"Prove the product dsh run path with one real tool round trip.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}} +{"type":"session/title-llm-request","seq":12,"time":0,"data":{"titleProvider":"session-title-first-message-llm","messageSeqs":[7],"route":{"provider":"cli-mock","model":"cli-mock"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"Prove the product headless profile path with one real tool round trip.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}} {"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"cli-smoke-call","name":"bash","argumentsDelta":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}} {"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}}} diff --git a/examples/headless-agent/tests/snapshots/dsh-run/stderr.expected.txt b/examples/headless-agent/tests/snapshots/headless-profile/stderr.expected.txt similarity index 100% rename from examples/headless-agent/tests/snapshots/dsh-run/stderr.expected.txt rename to examples/headless-agent/tests/snapshots/headless-profile/stderr.expected.txt diff --git a/examples/jsonrpc-agent/README.i18n.yaml b/examples/jsonrpc-agent/README.i18n.yaml index e1c36e959c..04ee95bfab 100644 --- a/examples/jsonrpc-agent/README.i18n.yaml +++ b/examples/jsonrpc-agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/jsonrpc-agent/README.md -README.md: bcc1027d2edb30ab374dfa2ed13ad8e6360d923b -README.zh.md: ce255e4dd70bf8c5c6edc51afbe03bb4c66560a0 +README.md: 5f60a64a4888c64e4fd68835f78e4a334ffed263 +README.zh.md: 8d2f9807ff259000b5a6823357f8c41b43bfa434 diff --git a/examples/jsonrpc-agent/README.md b/examples/jsonrpc-agent/README.md index bcc1027d2e..5f60a64a48 100644 --- a/examples/jsonrpc-agent/README.md +++ b/examples/jsonrpc-agent/README.md @@ -21,16 +21,16 @@ The surrounding runtime also loads JSONL session persistence and automatic conte | `DEEPSEEK_BASE_URL` | Host endpoint used by `dsh-llm-deepseek` | | `DSH_CWD` | Agent workspace for bash and filesystem tools | | `DSH_MAX_TOKENS_AS_SUCCESS` | `true` (default) accepts token-limited results; `false` reports them as errors | -| `DSH_SESSION_ROOT` | JSONL trajectory directory | +| `DSH_SESSION_ROOT` | JSONL session directory | | `DSH_SYSTEM_PROMPT` | Deployment-provided coding persona | Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CONFIG`. The bundled executable already carries every plugin named by this file; the target machine does not need Node.js. -## Persistent tools variant +## Minimal variant -[`persistent-tools.cordis.yml`](persistent-tools.cordis.yml) is a minimal runnable variant whose model-facing surface is exactly: +[`minimal.cordis.yml`](minimal.cordis.yml) is the complete standalone counterpart of the Web `minimal` preset. It fixes the system prompt and compaction policy, and its model-facing surface is exactly: - owner-scoped persistent `bash` - `str_replace_editor` with `view`, `create`, `str_replace`, and `insert` -It composes the local PTY, filesystem intent policy, and session sandbox policy. +It composes the local PTY, filesystem intent policy, session sandbox policy, and JSONL persistence needed by the bundled runtime. [`minimal.py`](minimal.py) runs it through the Python SDK; the [Python SDK tutorial](../../docs/user/guide/python-sdk.md) uses this configuration to cover setup, session management, and the security boundary. diff --git a/examples/jsonrpc-agent/README.zh.md b/examples/jsonrpc-agent/README.zh.md index ce255e4dd7..8d2f9807ff 100644 --- a/examples/jsonrpc-agent/README.zh.md +++ b/examples/jsonrpc-agent/README.zh.md @@ -21,16 +21,16 @@ | `DEEPSEEK_BASE_URL` | `dsh-llm-deepseek` 使用的宿主端点 | | `DSH_CWD` | bash 和文件系统工具使用的 agent workspace | | `DSH_MAX_TOKENS_AS_SUCCESS` | `true`(默认)接受受 token 上限限制的结果;`false` 将其报告为错误 | -| `DSH_SESSION_ROOT` | JSONL 轨迹目录 | +| `DSH_SESSION_ROOT` | JSONL 会话目录 | | `DSH_SYSTEM_PROMPT` | 由部署提供的编码人格 | 通过 Python SDK 的 `cordis` 选项或 `DSH_CORDIS_CONFIG` 传入配置路径。内置可执行文件已携带此文件中指定的每个插件;目标机器无需 Node.js。 -## 持久化工具变体 +## 极简变体 -[`persistent-tools.cordis.yml`](persistent-tools.cordis.yml) 是一个最小可运行变体,面向模型的能力严格只有: +[`minimal.cordis.yml`](minimal.cordis.yml) 是 Web `minimal` preset 的完整独立版本。它固定系统提示词与压缩策略,面向模型的能力严格只有: - 所有者作用域内持久化的 `bash` - 提供 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` -它组合了本地 PTY、文件系统意图策略与会话沙箱策略。 +它组合了内置运行时所需的本地 PTY、文件系统意图策略、会话沙箱策略与 JSONL 持久化。[`minimal.py`](minimal.py) 通过 Python SDK 运行该配置;[Python SDK 教程](../../docs/user/guide/python-sdk.md)以此配置介绍设置方式、会话管理与安全边界。 diff --git a/examples/jsonrpc-agent/minimal.cordis.yml b/examples/jsonrpc-agent/minimal.cordis.yml new file mode 100644 index 0000000000..a374d1655a --- /dev/null +++ b/examples/jsonrpc-agent/minimal.cordis.yml @@ -0,0 +1,91 @@ +# Complete unattended minimal-agent composition for the Python SDK. The model +# sees one fixed system prompt and only the owner-scoped persistent Bash and +# string-replace editor tools. + +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' + config: + maxTokensAsSuccess: false + +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + +- id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: !!js process.env.DSH_CWD ?? process.cwd() + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: pty + name: '@deepseek-ai/dsh-pty' + +- id: pty-local + name: '@deepseek-ai/dsh-pty-local' + config: + timeoutMs: 300000 + +# The sandbox-aware filesystem backend applies the same per-session policy as +# Bash. danger-full-access permits unrestricted workspace behavior while +# keeping one policy boundary for both tools. +- id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' + config: + cwd: !!js process.env.DSH_CWD ?? process.cwd() + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + includeHarnessIdentity: false + persona: You are a helpful software engineer assistant. + workspaceContext: false + skills: + enabled: false + toolBash: false + toolTasks: false + +- id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' + config: + timeoutMs: 300000 + description: |- + Run commands in a bash shell + * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. + * You don't have access to the internet via this tool. + * You do have access to a mirror of common linux and python packages via apt and pip. + * State is persistent across command calls and discussions with the user. + * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. + * Please avoid commands that may produce a very large amount of output. + * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. + +- id: str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 + +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' + compression: none + +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + +- id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + config: + thresholdRatio: 0.8 + retainTokens: 20480 + summarizationProvider: '' + summarizationModel: '' + maxTokens: 8192 + compactionRetries: 1 diff --git a/examples/jsonrpc-agent/minimal.py b/examples/jsonrpc-agent/minimal.py new file mode 100644 index 0000000000..c82f97c60a --- /dev/null +++ b/examples/jsonrpc-agent/minimal.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Run one minimal-agent turn through the bundled Python SDK runtime.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from deepseek_harness import DeepSeekHarness + + +CONFIG = Path(__file__).with_name("minimal.cordis.yml") + + +def main() -> None: + """Parse one task and print the agent's final response.""" + parser = argparse.ArgumentParser() + parser.add_argument("prompt", help="Task for the minimal agent") + parser.add_argument("--workspace", type=Path, default=Path.cwd()) + parser.add_argument("--session-root", type=Path, default=Path(".dsh-sessions")) + parser.add_argument("--session-id") + parser.add_argument("--provider", default="deepseek-official") + parser.add_argument("--model", default="deepseek-v4-flash") + parser.add_argument("--max-tokens", type=int) + args = parser.parse_args() + + workspace = args.workspace.resolve() + session_root = args.session_root.resolve() + with DeepSeekHarness( + provider=args.provider, + model=args.model, + max_tokens=args.max_tokens, + cwd=str(workspace), + session_root=str(session_root), + cordis=str(CONFIG.resolve()), + ) as harness: + result = harness.run(args.prompt, session_id=args.session_id) + print(result.final_response) + + +if __name__ == "__main__": + main() diff --git a/examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml b/examples/jsonrpc-agent/minimal.snapshot.cordis.yml similarity index 60% rename from examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml rename to examples/jsonrpc-agent/minimal.snapshot.cordis.yml index 2b6049382f..0f26fa6716 100644 --- a/examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml +++ b/examples/jsonrpc-agent/minimal.snapshot.cordis.yml @@ -1,12 +1,10 @@ -# Keyless replay keeps the persistent-tool composition intact and replaces -# only its live DeepSeek adapter with the fixture-backed provider. The catalog -# below claims the same `deepseek-official` route the agent asks for: an -# unowned route makes the SDK server mount the real adapter, which then demands -# a key this keyless lane has no way to supply. +# Keyless replay keeps the complete minimal composition intact and replaces +# only its live DeepSeek adapter with the fixture-backed provider. The replay +# catalog claims the same route initialized by the SDK. - id: base name: '@deepseek-ai/cordis-plugin-include' config: - path: ./persistent-tools.cordis.yml + path: ./minimal.cordis.yml patches: - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' diff --git a/examples/jsonrpc-agent/persistent-tools.cordis.yml b/examples/jsonrpc-agent/persistent-tools.cordis.yml deleted file mode 100644 index ebe0a00e61..0000000000 --- a/examples/jsonrpc-agent/persistent-tools.cordis.yml +++ /dev/null @@ -1,59 +0,0 @@ -# Minimal unattended composition for the persistent Bash and string-replace -# editor. It is runnable through the JSON-RPC example runtime and intentionally -# keeps the model-facing surface to exactly these two tools. - -- id: jsonrpc - name: '@deepseek-ai/dsh-jsonrpc' - -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - -- id: sandbox - name: '@deepseek-ai/dsh-sandbox-local' - -- id: sandbox-policy - name: '@deepseek-ai/dsh-sandbox-policy' - config: - mode: danger-full-access - workspaceRoot: !!js process.env.DSH_CWD ?? process.cwd() - -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - -- id: pty - name: '@deepseek-ai/dsh-pty' - -- id: pty-local - name: '@deepseek-ai/dsh-pty-local' - -- id: fs-sandbox - name: '@deepseek-ai/dsh-fs-sandbox' - config: - cwd: !!js process.env.DSH_CWD ?? process.cwd() - -- id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - -- id: agent-spine - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - includeHarnessIdentity: false - persona: 'You are a helpful software engineer assistant.' - workspaceContext: false - skills: - enabled: false - toolBash: false - toolTasks: false - -- id: persistent-bash - name: '@deepseek-ai/dsh-tool-bash-persistent' - -- id: str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' - -- id: sessions - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' - compression: none diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index 4a29d6eb6a..a45e12b192 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -33,11 +33,21 @@ const testsDir = dirOf(import.meta.url) const snapshotsDir = join(testsDir, 'snapshots') const liveConfig = join(testsDir, '..', 'cordis.yml') const replayConfig = join(testsDir, '..', 'cordis.snapshot.yml') -const persistentToolsLiveConfig = join(testsDir, '..', 'persistent-tools.cordis.yml') -const persistentToolsReplayConfig = join(testsDir, '..', 'persistent-tools.snapshot.cordis.yml') +const minimalLiveConfig = join(testsDir, '..', 'minimal.cordis.yml') +const minimalReplayConfig = join(testsDir, '..', 'minimal.snapshot.cordis.yml') const runtimeBin = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const MINIMAL_SYSTEM_PROMPT = 'You are a helpful software engineer assistant.' +const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell +* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. +* You don't have access to the internet via this tool. +* You do have access to a mirror of common linux and python packages via apt and pip. +* State is persistent across command calls and discussions with the user. +* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. +* Please avoid commands that may produce a very large amount of output. +* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.` + const mode = process.env.DSH_SNAPSHOT ?? 'replay' const recording = mode === 'record' const refreshing = mode === 'refresh' @@ -61,6 +71,10 @@ interface SdkScenario { expectedFiles?: Readonly> /** Assembled model-facing tool names and required argument keys. */ expectedTools?: Readonly> + /** Exact assembled system prompt for the root request. */ + expectedSystem?: string + /** Exact model-facing descriptions for selected tools. */ + expectedToolDescriptions?: Readonly> /** Stable policy-context clauses the real assembled request must include or omit. */ policyContext?: { includes: readonly string[]; excludes: readonly string[] } } @@ -89,9 +103,11 @@ const SCENARIOS: SdkScenario[] = [ prompt: 'Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9.', sessionId: 'persistent-tools-snapshot', children: 0, - configs: { live: persistentToolsLiveConfig, replay: persistentToolsReplayConfig }, + configs: { live: minimalLiveConfig, replay: minimalReplayConfig }, expectedFiles: { 'note.txt': 'target:\n\tnew\n' }, expectedTools: { bash: ['command'], str_replace_editor: ['command', 'path'] }, + expectedSystem: MINIMAL_SYSTEM_PROMPT, + expectedToolDescriptions: { bash: MINIMAL_BASH_DESCRIPTION }, policyContext: { includes: ['Current DSH file policy: danger-full-access.', 'file modifications by available operations'], excludes: ['write and edit tools', 'terminal sessions', 'one-shot bash commands'], @@ -125,16 +141,33 @@ async function persistedLogs(sessionsRoot: string): Promise { interface LoggedRequestHeader { type?: string - data?: { header?: { system?: unknown; tools?: Array<{ name: string; parameters: { required?: string[] } }> } } + data?: { header?: { system?: unknown; tools?: LoggedTool[] } } } -function assembledToolRequirements(log: PersistedLog): Record { +interface LoggedTool { + readonly name: string + readonly description?: unknown + readonly parameters: { readonly required?: string[] } +} + +function assembledTools(log: PersistedLog): LoggedTool[] { const event = log.content.trimEnd().split('\n') .map(line => JSON.parse(line) as LoggedRequestHeader) .find(candidate => candidate.type === 'request/header') const tools = event?.data?.header?.tools if (tools === undefined) throw new Error('session log has no request/header tools') - return Object.fromEntries(tools.map(tool => [tool.name, tool.parameters.required ?? []])) + return tools +} + +function assembledToolRequirements(log: PersistedLog): Record { + return Object.fromEntries(assembledTools(log).map(tool => [tool.name, tool.parameters.required ?? []])) +} + +function assembledToolDescriptions(log: PersistedLog): Record { + return Object.fromEntries(assembledTools(log).map((tool) => { + if (typeof tool.description !== 'string') throw new Error(`tool ${tool.name} has no description`) + return [tool.name, tool.description] + })) } function assembledSystem(log: PersistedLog): string { @@ -400,6 +433,16 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { if (parent === undefined) throw new Error(`${scenario.name} has no parent session log`) expect(assembledToolRequirements(parent)).toEqual(scenario.expectedTools) } + if (scenario.expectedSystem !== undefined) { + const parent = ordered[0] + if (parent === undefined) throw new Error(`${scenario.name} has no parent session log`) + expect(assembledSystem(parent)).toBe(scenario.expectedSystem) + } + if (scenario.expectedToolDescriptions !== undefined) { + const parent = ordered[0] + if (parent === undefined) throw new Error(`${scenario.name} has no parent session log`) + expect(assembledToolDescriptions(parent)).toMatchObject(scenario.expectedToolDescriptions) + } if (scenario.policyContext !== undefined) { const parent = ordered[0] if (parent === undefined) throw new Error(`${scenario.name} has no parent session log`) diff --git a/package.json b/package.json index 5b07ed60fa..77816c319d 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,7 @@ "hygiene": "pnpm run rescope-vendor:check && pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure && pnpm run verify-vendored-links", "publish:npm-baseline": "tsx scripts/publish-npm-baseline.ts", "dsh": "node --import tsx/esm apps/cli/src/bin.ts", - "demo:headless": "node --import tsx/esm apps/cli/src/bin.ts run", + "demo:headless": "node --import tsx/esm apps/cli/src/bin.ts --profile headless", "demo:code-mode": "node scripts/demo-code-mode.mjs", "demo:cordis": "node scripts/demo-cordis.mjs", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", diff --git a/packages/boot/README.i18n.yaml b/packages/boot/README.i18n.yaml index 66d3b7b63c..0de587115e 100644 --- a/packages/boot/README.i18n.yaml +++ b/packages/boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/README.md -README.md: 5e4e483b60adab0b22ddb5279f4cd8fb699b9c35 -README.zh.md: 95a3f98129a7d1fdfaffb3cac6fed77bab7cff56 +README.md: 79d653260ea4a9d9a4c71a593b41a6a7e17efa14 +README.zh.md: 839be164328ef168cd6ac18bf2f1dcb930dfce3e diff --git a/packages/boot/README.md b/packages/boot/README.md index 5e4e483b60..79d653260e 100644 --- a/packages/boot/README.md +++ b/packages/boot/README.md @@ -7,5 +7,6 @@ The channel-neutral boot library the app bins share: `apps/cli`, the [`scaffold/ | Package | Role | ctx key | |---|---|---| | `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | +| `cmdline/` | Launcher-to-app command-line handoff and app-owned startup parsing | `cmdlineArgs`, `appExit` | -The boot sequence and personal-config contract are documented in [`app-boot/README.md`](app-boot/README.md). +The boot sequence and personal-config contract are documented in [`app-boot/README.md`](app-boot/README.md); app-owned command lines are documented in [`cmdline/README.md`](cmdline/README.md). diff --git a/packages/boot/README.zh.md b/packages/boot/README.zh.md index 95a3f98129..839be16432 100644 --- a/packages/boot/README.zh.md +++ b/packages/boot/README.zh.md @@ -7,5 +7,6 @@ | 包 | 职责 | ctx 键 | |---|---|---| | `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、会明确报错的 Loader 保护机制、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) | +| `cmdline/` | 启动器到应用的命令行交接,以及由应用持有的启动解析 | `cmdlineArgs`、`appExit` | -启动序列与个人配置约定见 [`app-boot/README.md`](app-boot/README.md)。 +启动序列与个人配置约定见 [`app-boot/README.md`](app-boot/README.md);由应用持有的命令行见 [`cmdline/README.md`](cmdline/README.md)。 diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index cec63092de..d1f9a72df5 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: be03bceb39935fafb7acc7d3a99c1fe3af686f94 -README.zh.md: 10165486712fc078cdf1f4147522397a15c88955 +README.md: 1d56b2b6d22c08574f8e361955bee1dbe2aca601 +README.zh.md: 5429a1322d0311f03c7c43946753a290e28cd936 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index be03bceb39..1d56b2b6d2 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -15,10 +15,10 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md) and [`ds | `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | | `loadOptionalPatches(binName, file)` | Parse an optional patch-list file (a profile's `cordis.patch.yml`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | | `loadOverlayPatches(binName, file)` | Parse a required top-level YAML array containing the same include `PatchOptions` entries described above; a missing file also throws because the caller named it | -| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Register the statically imported `cordis:include` and `cordis:group` builtins, mount the include, and retain the exact root entry used by user patch-layer HMR | +| `mountRootInclude(ctx, absoluteConfigPath, patches?, bareModuleBaseUrl?)` | Register the statically imported `cordis:include` and `cordis:group` builtins, mount the include, and retain the exact root entry used by user patch-layer HMR; an optional module base anchors bare package names to the installed host while relative names stay config-relative | | `watchUserPatches(ctx, options)` | Register the named patch file with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current user layer) and returns an async disposer | | `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile machinery (see [Profiles](#profiles)) | -| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error | +| `boot(binName, absoluteConfigPath, patches?, prepare?, bareModuleBaseUrl?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error; the optional module base has the same resolution semantics as `mountRootInclude` | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline with the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts, and render YAML with `!!js` expressions verbatim; each run of rows that shares one source file and the same patch layers is preceded by a `# ==` comment naming that file and those layers, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), and read, parse, or field validation failures throw | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | @@ -29,7 +29,7 @@ The Loader mounts entries concurrently, so a surface can already own the termina `cordis:group` is registered beside `cordis:include` so a composition can give one `isolate` realm to a provider and its consumers together. Both load through the ambient module pipeline rather than the included tree's own specifier resolution, which is what lets a composition outside this workspace — an agent preset under the Harness home — use a group row at all. -Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every shipped raw/Web bare plugin to appear in the resolver manifest's `dependencies`. +Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. They resolve from the config directory by default; a closed runtime passes `bareModuleBaseUrl` to `boot` or `mountRootInclude` so its installed package tree remains authoritative even when the config lives inside another Node project. Relative specifiers always resolve against the config directory. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every shipped raw/Web bare plugin to appear in the resolver manifest's `dependencies`. This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution. @@ -42,7 +42,7 @@ User-level machine-local preferences also live in the Harness home: - **`.env`** — the product CLI's ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. `loadLayeredEnv` snapshots each value's source, rejects [bootstrap-only file variables](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision) case-insensitively, and materializes accepted values into `process.env` for Loader expressions and third-party libraries. Managed credentials live separately in [`.credentials.yaml`](../../credentials/credentials-local/README.md); a credential left in either `.env` remains a lower-priority fallback. - **`cordis.patch.yml`** (home level) and **`profiles//cordis.patch.yml`** — the user patch layers, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the composed tree is a stderr warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the layer with `[]`. -Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlay/flag patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh. +Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlays above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh. ## Model Experience diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 1016548671..5429a1322d 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -15,10 +15,10 @@ | `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | | `loadOptionalPatches(binName, file)` | 解析一份可选的 patch 列表文件(即 profile 的 `cordis.patch.yml`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | | `loadOverlayPatches(binName, file)` | 解析必需的顶层 YAML 数组,其中包含与上文相同的 include `PatchOptions` 条目;文件缺失也会抛出异常,因为该文件是调用方指名的 | -| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 注册静态导入的 `cordis:include` 与 `cordis:group` builtin,挂载 include,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项 | +| `mountRootInclude(ctx, absoluteConfigPath, patches?, bareModuleBaseUrl?)` | 注册静态导入的 `cordis:include` 与 `cordis:group` builtin,挂载 include,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项;可选模块基准会把裸包名锚定到已安装宿主,而相对名称仍以配置目录为基准 | | `watchUserPatches(ctx, options)` | 向现有 Cordis HMR 服务注册指名的 patch 文件;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前用户层)以事务方式重新组合完整 patch 列表,并返回异步清理函数 | | `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile 机制(见 [Profile](#profiles)) | -| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject | +| `boot(binName, absoluteConfigPath, patches?, prepare?, bareModuleBaseUrl?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject;可选模块基准与 `mountRootInclude` 的解析语义相同 | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`)离线合成基础配置与带标签的覆盖层,使结果与 `boot()` 挂载的内容一致,再渲染为 YAML,并原样保留 `!!js` 表达式;每段来源于同一文件且由相同补丁层修改的连续行之前都有一条 `# ==` 注释,标明该文件和这些补丁层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取、解析或字段验证失败则抛出 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | @@ -29,7 +29,7 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 `cordis:group` 与 `cordis:include` 一并注册,使一份组装能把一个提供方与它的消费方放进同一个 `isolate` realm。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析,这正是让本工作区之外的组装——放在 Harness home 下的 agent preset——能够使用 group 行的原因。 -配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包)通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与宿主会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个随附的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。 +配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包)通过 Cordis Loader 的内部模块 loader 解析。默认情况下,它们从配置目录解析;封闭运行时会向 `boot` 或 `mountRootInclude` 传入 `bareModuleBaseUrl`,使已安装包树保持权威,即使配置位于另一个 Node 项目中也不受遮蔽。相对 specifier 始终以配置目录为基准解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与宿主会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个随附的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。 此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 @@ -42,7 +42,7 @@ profile 是位于 `$DSH_HOME/profiles/` 下的目录(Harness home 由 [` - **`.env`**:产品 CLI 的普通环境层;调用目录的文件优先于 Harness home 的文件,两者都低于继承环境。`loadLayeredEnv` 记录每个值的来源,按不区分大小写的方式拒绝 [bootstrap-only 文件变量](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision),并把其余值物化进 `process.env`,供 Loader 表达式和第三方库使用。受管凭据另存于 [`.credentials.yaml`](../../credentials/credentials-local/README.md);留在任一 `.env` 中的凭据仍是低优先级后备值。 - **`cordis.patch.yml`**(home 级)与 **`profiles//cordis.patch.yml`**:用户 patch 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在组合后的树中,则输出一条 stderr 警告。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用该层,请使用 `[]`。 -长期运行的界面会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,监视器仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay/标志 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方的失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 +长期运行的界面会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,监视器仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方的失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 ## 模型体验 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 9de8c7765d..94d76dea81 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -9,7 +9,7 @@ import { pathToFileURL } from 'node:url' import { readFileSync } from 'node:fs' import { parseEnv } from 'node:util' -import { basename, dirname, resolve } from 'node:path' +import { basename, dirname, isAbsolute, resolve } from 'node:path' import * as yaml from 'js-yaml' import { Context, type FiberState } from '@deepseek-ai/cordis' import Loader, { type Entry, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' @@ -199,7 +199,7 @@ export function loadLayeredEnv( const bootstrapIncludes = new WeakMap() // The include's YAML dialect (`!!js` scalars become expression nodes the -// Loader interpolates against each entry's context at mount time), imported +// Loader interpolates against each entry's injection-ready context), imported // from the include itself so patch parsing and config dumping can never drift // from what the include mounts. User patch layers share it so they may // reference `process.env`. @@ -215,7 +215,7 @@ export interface UserPatchWatchOptions { * Compose the full patch list for a fresh user-layer generation — * the same composition the app booted with, so a reload can interleave the * new user patches between app-owned layers (bundle layers below, - * overlay/flag patches above). Identity when omitted: the user layer + * overlays above). Identity when omitted: the user layer * is the whole patch list. */ compose?: (userPatches: PatchOptions[]) => PatchOptions[] @@ -476,6 +476,8 @@ function groupedDump( * @param ctx - context carrying an initialized Loader service. * @param absoluteConfigPath - absolute YAML or JSON configuration path. * @param patches - initial app and user patches, applied in order. + * @param bareModuleBaseUrl - optional installed-host base for bare package + * names; relative names continue to resolve beside the configuration file. * @returns the created root Include entry, or `undefined` when a surface * disposed the whole tree (taking the Loader service with it) while the * transactional create was still settling entry lifecycle. @@ -484,8 +486,21 @@ export async function mountRootInclude( ctx: Context, absoluteConfigPath: string, patches: readonly PatchOptions[] = [], + bareModuleBaseUrl?: string, ): Promise { - ctx.loader.builtins.include = Include + ctx.loader.builtins.include = bareModuleBaseUrl === undefined + ? Include + : class HostResolvedRootInclude extends Include { + override import(name: string, getOuterStack?: () => string[]): unknown { + const specifier = isAbsolute(name) ? pathToFileURL(name).href : name + if (name.startsWith('.') || name.startsWith('cordis:')) return super.import(specifier, getOuterStack) + const internal = this.ctx.loader.internal + /* v8 ignore next -- Node supplies the internal loader; this preserves the + original diagnostic for hypothetical embedders without it. */ + if (internal === undefined) return super.import(specifier, getOuterStack) + return internal.import(specifier, bareModuleBaseUrl, {}) + } + } // `cordis:group` alongside it: a group row is how a composition gives one // `isolate` realm to a provider and its consumers together, and an agent // preset living outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` @@ -495,13 +510,14 @@ export async function mountRootInclude( // Pinned id: the bootstrap include is app glue, not a config row, and its // id appears in Loader failure chains — a random id would make startup // diagnostics unstable across runs (and snapshot fixtures). + const includeConfig: Include.Config = { + path: pathToFileURL(absoluteConfigPath).href, + ...patches.length > 0 ? { patches: [...patches] } : {}, + } const rootInclude: EntryOptions = { id: 'include', name: 'cordis:include', - config: { - path: pathToFileURL(absoluteConfigPath).href, - ...patches.length > 0 ? { patches: [...patches] } : {}, - }, + config: includeConfig, } const includeId = await ctx.loader.create(rootInclude) const loader = ctx.get('loader') @@ -709,14 +725,13 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro /** * Boot the Loader against `absoluteConfigPath` and return only after the whole - * tree settles. Entry names load through the Loader's internal module loader - * against `baseUrl` (the config directory), which may live outside - * `node_modules` reach and, unbuilt, cannot load vendored source; the - * bootstrap include is therefore statically imported and mounted as the - * `cordis:include` builtin, loading through the ambient module pipeline - * (vite/tsx/plain ESM) while the included tree's own specifiers stay - * config-relative. The package build embeds Include while leaving Loader - * external, so the built include tree and host share one Loader peer. Loader + * tree settles. Relative entry names resolve against the config directory; + * bare package names resolve there by default or against an explicit + * `bareModuleBaseUrl` for closed packaged runtimes. The bootstrap include + * is statically imported and mounted as the `cordis:include` builtin, loading + * through the ambient module pipeline (vite/tsx/plain ESM). The package build + * embeds Include while leaving Loader external, so the built include tree and + * host share one Loader peer. Loader * settlement rejects startup failures, which `boot` wraps after disposing the * partial context; a missing fiber or never-activating entry is rejected by * the final audit, {@link assertEntriesActivated}, which rethrows a plugin's @@ -729,6 +744,9 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro * @param patches - optional overlay patches applied over the included tree * (see {@link loadOptionalPatches}); an empty list mounts none. * @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts. + * @param bareModuleBaseUrl - optional installed-host base for bare package + * names; use it when the host, rather than the configuration project, owns the + * complete plugin set. * @returns the root context once every entry has started, or as soon as a * surface disposed the tree while startup was still in flight. * @throws a labelled error after disposing the partial context — `host @@ -740,6 +758,7 @@ export async function boot( absoluteConfigPath: string, patches?: PatchOptions[], prepare?: (ctx: Context) => Promise | void, + bareModuleBaseUrl?: string, ): Promise { const ctx = new Context() // Two failure labels: `prepare` runs before any config-tree entry mounts, @@ -751,7 +770,7 @@ export async function boot( await ctx.plugin(Loader) await prepare?.(ctx) stage = 'plugin tree failed to load' - await mountRootInclude(ctx, absoluteConfigPath, patches) + await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl) // A surface can finish and dispose the whole tree while startup is still // in flight, before the last entry settles. The Loader service goes with // it, and the activation audit describes a live tree — reading `ctx.loader` diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index 301a42cdc1..8bbb8fddfa 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -1,6 +1,7 @@ -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve, sep } from 'node:path' +import { pathToFileURL } from 'node:url' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' @@ -557,6 +558,73 @@ describe('boot', () => { } }) + it('can resolve bare plugins from the harness when the config project shadows their package name', async () => { + const dir = tmp() + const harness = tmp() + const absolutePlugin = join(dir, 'absolute.mjs') + const shadow = join(dir, 'node_modules', '@deepseek-ai', 'dsh-system-prompt') + const harnessPlugin = join(harness, 'node_modules', '@deepseek-ai', 'dsh-system-prompt') + mkdirSync(shadow, { recursive: true }) + mkdirSync(harnessPlugin, { recursive: true }) + writeFileSync(join(shadow, 'package.json'), JSON.stringify({ + name: '@deepseek-ai/dsh-system-prompt', + type: 'module', + exports: './index.mjs', + })) + writeFileSync(join(shadow, 'index.mjs'), [ + 'export function apply(ctx) {', + ' ctx.provide("shadowPluginLoaded", true)', + '}', + '', + ].join('\n')) + writeFileSync(join(harnessPlugin, 'package.json'), JSON.stringify({ + name: '@deepseek-ai/dsh-system-prompt', + type: 'module', + exports: './index.mjs', + })) + writeFileSync(join(harnessPlugin, 'index.mjs'), [ + 'export function apply(ctx) {', + ' ctx.provide("harnessPluginLoaded", true)', + '}', + '', + ].join('\n')) + writeFileSync(join(dir, 'relative.mjs'), 'export function apply(ctx) { ctx.provide("relativePluginLoaded", true) }\n') + writeFileSync(absolutePlugin, 'export function apply(ctx) { ctx.provide("absolutePluginLoaded", true) }\n') + const entries = [ + '- id: prompt', + " name: '@deepseek-ai/dsh-system-prompt'", + '- id: relative', + " name: './relative.mjs'", + ] + const configOwnedPath = join(dir, 'config-owned.cordis.yml') + writeFileSync(configOwnedPath, [...entries, ''].join('\n')) + const hostOwnedPath = join(dir, 'host-owned.cordis.yml') + writeFileSync(hostOwnedPath, [ + ...entries, + '- id: absolute', + ` name: ${JSON.stringify(absolutePlugin)}`, + '', + ].join('\n')) + const configOwned = await boot(NAME, configOwnedPath) + try { + expect(configOwned.get('shadowPluginLoaded')).toBe(true) + expect(configOwned.get('systemPrompt')).toBeUndefined() + expect(configOwned.get('relativePluginLoaded')).toBe(true) + } finally { + await configOwned.fiber.dispose() + } + const harnessBaseUrl = pathToFileURL(join(harness, 'entry.mjs')).href + const ctx = await boot(NAME, hostOwnedPath, undefined, undefined, harnessBaseUrl) + try { + expect(ctx.get('harnessPluginLoaded')).toBe(true) + expect(ctx.get('shadowPluginLoaded')).toBeUndefined() + expect(ctx.get('relativePluginLoaded')).toBe(true) + expect(ctx.get('absolutePluginLoaded')).toBe(true) + } finally { + await ctx.fiber.dispose() + } + }) + it('runs host preparation before the Loader tree mounts', async () => { const dir = tmp() writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n') @@ -631,7 +699,18 @@ describe('boot', () => { '}', '', ].join('\n')) - writeFileSync(join(dir, 'cordis.yml'), '- id: exiting\n name: ./exiting.mjs\n') + writeFileSync(join(dir, 'delayed.mjs'), [ + 'await new Promise(resolve => setTimeout(resolve, 10))', + 'export function apply() {}', + '', + ].join('\n')) + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: exiting', + ' name: ./exiting.mjs', + '- id: delayed', + ' name: ./delayed.mjs', + '', + ].join('\n')) const ctx = await boot(NAME, join(dir, 'cordis.yml')) expect(ctx.get('loader')).toBeUndefined() }) @@ -644,6 +723,25 @@ describe('boot', () => { ) }) + it('labels a deferred config failure with its row and leaves the source file unchanged', async () => { + const dir = tmp() + const configPath = join(dir, 'cordis.yml') + const config = [ + '- id: invalid-config', + ' name: ./noop.mjs', + ' config:', + ' value: !!js "JSON.parse(\'invalid\')"', + '', + ].join('\n') + writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n') + writeFileSync(configPath, config) + + await expect(boot(NAME, configPath)).rejects.toThrow( + 'failed to apply loader entry invalid-config (./noop.mjs)', + ) + expect(readFileSync(configPath, 'utf8')).toBe(config) + }) + it('appends the deepest cause with its original stack to the load failure', async () => { const dir = tmp() writeFileSync(join(dir, 'failing.mjs'), [ diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index 2e67bd08f8..da58524e1d 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -11,6 +11,7 @@ import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Hmr from '@deepseek-ai/cordis-plugin-hmr' +import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import Loader from '@deepseek-ai/cordis-plugin-loader' import Timer from '@deepseek-ai/cordis-plugin-timer' import { @@ -92,23 +93,101 @@ describe('loadOptionalPatches', () => { }) }) -describe('boot with user patches', () => { - function writeTree(dir: string): string { - writeFileSync(join(dir, 'noop.mjs'), [ - 'export const name = "noop"', - 'export function apply(_ctx, config = {}) {', - ' if (config.fail) throw new Error("candidate config failed")', - '}', +function writeTree(dir: string): string { + writeFileSync(join(dir, 'noop.mjs'), [ + 'export const name = "noop"', + 'export function apply(_ctx, config = {}) {', + ' if (config.fail) throw new Error("candidate config failed")', + '}', + '', + ].join('\n')) + writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n') + return join(dir, 'cordis.yml') +} + +function entryConfig(ctx: Context, id: string): unknown { + return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config +} + +describe('Loader config interpolation', () => { + it("resolves Include's own !!js options", async () => { + const dir = tmp() + writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n') + writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n') + const ctx = new Context() + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + ctx.provide('includePath', pathToFileURL(join(dir, 'cordis.yml')).href) + try { + await ctx.loader.create({ + name: 'cordis:include', + config: { path: { __jsExpr: "ctx.get('includePath')" } }, + }) + await ctx.loader.await() + expect([...ctx.loader.entries()].some(entry => entry.options.id === 'noop')).toBe(true) + } finally { + await ctx.fiber.dispose() + } + }) + + it('waits for row injections before resolving !!js and resolves again after provider replacement', async () => { + const dir = tmp() + writeFileSync(join(dir, 'provider.mjs'), [ + 'export const name = "provider"', + 'export function apply(ctx, config) { ctx.provide("phaseOne", config) }', '', ].join('\n')) - writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n') - return join(dir, 'cordis.yml') - } + writeFileSync(join(dir, 'reader.mjs'), [ + 'export const name = "reader"', + 'export const inject = ["phaseOne"]', + 'export function apply(ctx, config) { ctx.provide("readerResult", config) }', + '', + ].join('\n')) + writeFileSync(join(dir, 'cordis.yml'), '[]\n') + const composition: PatchOptions[] = [{ + insert: [ + { + // Consumer-first order proves interpolation follows injection + // readiness rather than YAML position. + id: 'reader', + name: './reader.mjs', + inject: ['phaseOne'], + config: { value: { __jsExpr: 'ctx.phaseOne.fail ? (() => { throw new Error("rejected provider") })() : ctx.phaseOne.value' } }, + }, + { id: 'provider', name: './provider.mjs', config: { value: 'first' } }, + ], + }] + const ctx = await boot(NAME, join(dir, 'cordis.yml'), composition) + try { + expect(ctx.get('readerResult')).toEqual({ value: 'first' }) + const provider = [...ctx.loader.entries()].find(entry => entry.options.id === 'provider') + expect(provider).toBeDefined() + await provider?.update({ disabled: true }) + await ctx.loader.await() + expect(ctx.get('readerResult')).toBeUndefined() + await provider?.update({ config: { value: 'second' } }) + await provider?.update({ disabled: false }) + await ctx.loader.await() + expect(ctx.get('readerResult')).toEqual({ value: 'second' }) - function entryConfig(ctx: Context, id: string): unknown { - return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config - } + await provider?.update({ disabled: true }) + await provider?.update({ config: { fail: true } }) + await provider?.update({ disabled: false }) + await expect(ctx.loader.await()).rejects.toThrow('rejected provider') + expect(ctx.get('readerResult')).toBeUndefined() + await provider?.update({ disabled: true }) + await provider?.update({ config: { value: 'recovered' } }) + await provider?.update({ disabled: false }) + await ctx.loader.await() + expect(ctx.get('readerResult')).toEqual({ value: 'recovered' }) + } finally { + await ctx.fiber.dispose() + } + }) +}) + +describe('boot with user patches', () => { it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => { const dir = tmp() const userDir = tmp() diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml new file mode 100644 index 0000000000..6a032582cf --- /dev/null +++ b/packages/boot/cmdline/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/boot/cmdline/README.md +README.md: 98335e901bdf8fe33e14c1ad4c1a320d77f30c96 +README.zh.md: 28ea749943c60089c6b4725cb61e121f82aa0114 diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md new file mode 100644 index 0000000000..98335e901b --- /dev/null +++ b/packages/boot/cmdline/README.md @@ -0,0 +1,74 @@ +# `@deepseek-ai/dsh-cmdline` + +English | [中文](README.zh.md) + +The command line a dsh launcher hands to the app it boots. The launcher parses only its own flags (`--profile`, `--patch`, the config dumps) and hands **everything after them** to the tree verbatim, so an app owns its flag family, its `--help` text, and its parse errors instead of the launcher knowing them. + +## The launcher values + +A launcher calls `provideCmdline(ctx, host)` before any tree entry mounts, which provides: + +- `ctx.cmdlineArgs` — the invocation's inner arguments. `get()` is the whole interface, and it returns a snapshot: `dsh --profile tui --resume abc` yields `['--resume', 'abc']`. +- `ctx.appExit` — a bounded process-exit request, wired to the launcher's shutdown controller. + +An embedding host with no command line provides an empty list; that is the honest answer, not a missing value. + +## Ordinary providers and injected config + +Any app plugin may inject `cmdlineArgs`, parse it, and publish an ordinary app-owned service. `parseCmdline(ctx, program, plan)` is only a commander adapter; the caller owns the returned value and service: + +```ts ignore +export const name = 'web-startup' +export const inject = ['cmdlineArgs'] + +export function apply(ctx: Context): void { + const values = parseCmdline(ctx, webCommand(), planWebStartup) + if (values !== undefined) ctx.provide('webStartup', values) +} +``` + +Its Loader row carries no launcher marker or special kind: + +```yaml +- id: web-startup + name: '@deepseek-ai/dsh-web-app/startup' +``` + +Every row configured from those values uses ordinary service injection and direct lazy config access: + +```yaml +- id: webserver + name: '@deepseek-ai/dsh-host-webserver' + inject: [webStartup] + config: + host: !!js ctx.webStartup.host ?? '127.0.0.1' + port: !!js ctx.webStartup.port ?? 3080 +``` + +`parseCmdline` parses the immutable arguments and asks `plan` for the app-owned value. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text, requests exit, and returns `undefined`; the provider publishes nothing, so dependent rows never activate. + +### How injection orders config + +Loader defers a row's `!!js` interpolation until that row's declared injections are active, then evaluates against the row's plugin context. The example above can therefore read `ctx.webStartup` directly: Cordis has already populated that injected service before Loader asks for `webserver`'s config. Include trees preserve nested expression nodes until each target row reaches this point. Provider replacement and live patch reload repeat interpolation against the current injected services, so a launch flag cannot be silently reset. + +`enableRow(ctx, id)` turns on a row a bundle ships disabled because only some invocations want it (`dsh web --dev` and its client-plugin reload chain). The activation is an in-memory override: it does not rewrite the row's configured `disabled` value and survives config reapplication for that mounted entry. Loader applies the enabled row's ordinary injection ordering. + +### Shared immutable arguments + +`get()` does not consume or mutate argv. Multiple plugins can parse the same snapshot and independently provide services. The launcher does not inspect the composition for a command-line owner; a profile with no reader simply ignores its app arguments. + +An out-of-tree plugin brings its own commander copy, so commander's control-flow errors are detected structurally rather than by class identity; an identity check would rethrow a printed help as a fatal load failure. + +## Model Experience + +None, as this package resolves the process's own command line before any session exists. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Launcher flags must precede app arguments.** The split is positional: the first token the launcher does not recognize starts the inner arguments, so `--patch` placed after an app flag belongs to the app. The launcher's parser consumes one `--`, so an app argument that must survive as a literal `--` needs `-- --`. +- **An app-owned service has no statically declared provider.** Consumer rows name it through ordinary injection; a bundle that omits its provider fails at settlement with pending entries naming the service rather than at load. +- **A user patch that replaces a row's whole `config` drops its expressions.** A flag beats the value written beside it, not a literal a user wrote in place of the expression; keeping the expression is what keeps the flag winning. diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md new file mode 100644 index 0000000000..28ea749943 --- /dev/null +++ b/packages/boot/cmdline/README.zh.md @@ -0,0 +1,74 @@ +# `@deepseek-ai/dsh-cmdline` + +[English](README.md) | 中文 + +dsh 启动器交给它所引导应用的那条命令行。启动器只解析属于自己的 flag(`--profile`、`--patch`、配置 dump),并把**其后的一切**原样交给配置树,因此 flag 家族、`--help` 文本和解析错误都由应用自己持有,启动器不必知道它们。 + +## 启动器提供的值 + +启动器在任何配置树条目挂载之前调用 `provideCmdline(ctx, host)`,它提供: + +- `ctx.cmdlineArgs`:本次调用的内层参数。`get()` 就是它的全部接口,返回一份快照:`dsh --profile tui --resume abc` 得到 `['--resume', 'abc']`。 +- `ctx.appExit`:一个有边界的进程退出请求,接到启动器的关停控制器上。 + +没有命令行的嵌入宿主提供空列表;这是诚实的答案,而不是缺失的值。 + +## 普通提供方与注入配置 + +任何应用插件都可以注入 `cmdlineArgs`、解析它,再发布一个普通的应用自有服务。`parseCmdline(ctx, program, plan)` 只适配 commander;返回值与服务都归调用方持有: + +```ts ignore +export const name = 'web-startup' +export const inject = ['cmdlineArgs'] + +export function apply(ctx: Context): void { + const values = parseCmdline(ctx, webCommand(), planWebStartup) + if (values !== undefined) ctx.provide('webStartup', values) +} +``` + +它的 Loader 行不携带启动器标记,也没有特殊类型: + +```yaml +- id: web-startup + name: '@deepseek-ai/dsh-web-app/startup' +``` + +所有由这些取值配置的行都使用普通服务注入,并在惰性配置中直接访问该服务: + +```yaml +- id: webserver + name: '@deepseek-ai/dsh-host-webserver' + inject: [webStartup] + config: + host: !!js ctx.webStartup.host ?? '127.0.0.1' + port: !!js ctx.webStartup.port ?? 3080 +``` + +`parseCmdline` 解析不可变参数,再向 `plan` 索取应用自有取值。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 文本、请求退出并返回 `undefined`;提供方什么也不发布,因此依赖行不会激活。 + +### 注入如何排列配置求值 + +Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活之后,再基于该行的插件上下文求值。所以上例可以直接读取 `ctx.webStartup`:Loader 索取 `webserver` 的配置之前,Cordis 已经填入了这个注入服务。Include 树会保留嵌套表达式节点,直到各个目标行到达这一时点。提供方替换与活动 patch 重载都会针对当前注入服务重新插值,因此启动 flag 不会被悄悄重置。 + +`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。该激活是内存中的覆盖:它不会改写行所配置的 `disabled` 值,并会在已挂载条目的配置重新应用后继续生效。Loader 会对启用后的行应用普通的注入顺序。 + +### 共享不可变参数 + +`get()` 不会消费或修改 argv。多个插件可以解析同一份快照,并分别提供服务。启动器不会检查组合中的命令行所有者;没有读取方的 profile 只会忽略自己的应用参数。 + +树外插件会带来自己的一份 commander 副本,因此 commander 的控制流错误按结构识别,而不是按类身份识别;按身份判断会把已经打印出来的 help 重新抛成致命的加载失败。 + +## 模型体验 + +无。本包在任何会话存在之前解析进程自身的命令行。 + +#### KV Cache 影响 + +无;本包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + +- **启动器的 flag 必须写在应用参数之前**:切分按位置进行,启动器不认识的第一个 token 就是内层参数的起点,因此写在某个应用 flag 之后的 `--patch` 属于应用。启动器的解析器会消耗掉一个 `--`,因此必须以字面量 `--` 存活到应用的参数需要写成 `-- --`。 +- **应用自有服务没有静态声明的提供方**:消费行通过普通注入点名它;缺少提供方的组合包会在结算时失败,由待处理条目点名该服务,而不是在加载时失败。 +- **用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉**:flag 胜过的是表达式旁写着的那个值,而不是用户用字面量替换掉表达式之后的结果;保留表达式才能保留 flag 的优先级。 diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json new file mode 100644 index 0000000000..28647bfc24 --- /dev/null +++ b/packages/boot/cmdline/package.json @@ -0,0 +1,39 @@ +{ + "name": "@deepseek-ai/dsh-cmdline", + "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "commander": "^15.0.0", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts new file mode 100644 index 0000000000..fb502cb0e2 --- /dev/null +++ b/packages/boot/cmdline/src/index.ts @@ -0,0 +1,174 @@ +/** + * @deepseek-ai/dsh-cmdline — the command line a dsh launcher hands to the app + * it boots. + * + * The launcher parses only its own flags (`--profile`, `--patch`, the config + * dumps) and hands everything after them to the tree verbatim through the + * {@link CmdlineArgs} service, so an app owns its flag family, its `--help` + * text, and its parse errors instead of the launcher knowing them. + * + * Any app plugin can inject `cmdlineArgs` and call {@link parseCmdline}. A + * provider may publish the parsed values as its own service, and ordinary rows + * can inject that service and read it from lazily resolved config — + * `port: !!js ctx.webStartup.port ?? 3080` — so a flag beats the value written + * beside it. No row has launcher-level command-line status. + * @module @deepseek-ai/dsh-cmdline + */ + +import type { Command } from 'commander' +import type { Context } from '@deepseek-ai/cordis' +// Empty type import carries the Loader Context merge used by enableRow. +import type {} from '@deepseek-ai/cordis-plugin-loader' + +/** + * The invocation's inner arguments: everything after the launcher's own flags, + * verbatim and in argv order. `dsh --profile tui --resume abc` yields + * `['--resume', 'abc']`. + */ +export interface CmdlineArgs { + /** + * Read the inner arguments. + * @returns the arguments in argv order; empty when the invocation carried none. + */ + get(): readonly string[] +} + +/** Request bounded process exit; the launcher wires it to its shutdown controller. */ +export interface AppExit { + /** + * Request exit once the tree has been disposed. + * @param code - the process exit code. + */ + (code: number): void +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** The invocation's inner arguments; provided by a launcher before the tree mounts. */ + cmdlineArgs?: CmdlineArgs + /** Bounded process-exit request; provided by a launcher before the tree mounts. */ + appExit?: AppExit + } +} + +/** The launcher facts an app needs. */ +export interface CmdlineHost { + /** The invocation's inner arguments, in argv order. */ + args: readonly string[] + /** Bounded process-exit request. */ + exit: AppExit +} + +/** + * Provide the command line and the exit request on a host context before any + * tree entry mounts. Both are launcher facts, not config: an embedding host + * with no command line provides an empty argument list. + * @param ctx - the host context the tree will mount under. + * @param host - the invocation's arguments and its exit request. + */ +export function provideCmdline(ctx: Context, host: CmdlineHost): void { + const snapshot: readonly string[] = Object.freeze([...host.args]) + ctx.provide('cmdlineArgs', { get: () => snapshot }) + ctx.provide('appExit', host.exit) +} + +/** The process streams commander output is written to; production writes to the process. */ +export const internals: { stdout: { write(chunk: string): unknown }; stderr: { write(chunk: string): unknown } } = { + stdout: process.stdout, + stderr: process.stderr, +} + +/** + * Resolve parsed arguments into an app-owned value. Call + * `program.error(...)` to reject the invocation with a usage message instead + * of throwing. + * @param program - the parsed commander program. + * @param ctx - the plugin context that received the command line. + * @returns the value an ordinary provider plugin may publish. + */ +export type CmdlinePlan = (program: Command, ctx: Context) => T + +/** + * Parse the launcher's immutable argument snapshot with an app's commander + * program. The caller decides whether and how to publish the returned value; + * this helper has no Loader-row or service ownership semantics. + * + * Help, version, and rejected arguments are terminal for the process: commander + * writes the text, the helper requests `ctx.appExit`, and it returns + * `undefined` so the caller publishes nothing. + * @param ctx - plugin context carrying `cmdlineArgs` and `appExit`. + * @param program - the app's commander program, with its flags and description already declared. + * @param plan - this invocation's resolved value; omitted returns an empty object. + * @returns the resolved value, or `undefined` when the app asked to exit. + * @throws when the launcher did not provide the command line and exit request. + */ +export function parseCmdline( + ctx: Context, + program: Command, + plan: CmdlinePlan = (() => ({}) as T), +): T | undefined { + // Read through the global service store, not the property proxy: appExit is + // an optional host value and the plugin only needs to inject cmdlineArgs. + const args = ctx.get('cmdlineArgs') + const exit = ctx.get('appExit') + if (args === undefined || exit === undefined) { + throw new Error(`${program.name()}: the launcher must provide ctx.cmdlineArgs and ctx.appExit before the tree mounts`) + } + program + .exitOverride() + .configureOutput({ + writeOut: text => void internals.stdout.write(text), + writeErr: text => void internals.stderr.write(text), + }) + try { + program.parse(args.get(), { from: 'user' }) + return plan(program, ctx) + } catch (error) { + // exitOverride turns help, version, a parse error, and a plan's own + // program.error() into a CommanderError; commander has already written the + // text through the output configured above. + if (!isCommanderError(error)) throw error + exit(error.exitCode) + return undefined + } +} + +/** + * Turn on a row this composition ships disabled, because this invocation asked + * for it (`dsh web --dev` and its client-plugin reload chain). + * + * A row cannot be inserted from inside a mounting plugin — the Loader returns a + * prefixed id it then fails to resolve — so a conditional row ships disabled + * and a row mounted beside it enables it after startup resolves the invocation. + * The Loader keeps that activation in memory, separate from serialized options, + * so reapplying the composition cannot restore the invocation's row to disabled. + * @param ctx - plugin context whose Loader tree carries the row. + * @param id - the row id. + * @returns nothing once the row has started or is waiting for its dependencies. + * @throws when the Loader or named row is absent. + */ +export async function enableRow(ctx: Context, id: string): Promise { + const loader = ctx.get('loader') + if (loader === undefined) throw new Error('dsh-cmdline: enabling a row requires the Loader service') + const entry = [...loader.entries()].find(candidate => candidate.options.id === id) + if (entry === undefined) throw new Error(`dsh-cmdline: the composition has no ${JSON.stringify(id)} row to enable`) + await entry.enableRuntime() +} + +/** + * Whether a thrown value is commander's own control-flow error (help, version, + * a parse error, or `program.error`). + * + * Detected structurally, not with `instanceof`: an out-of-tree plugin brings + * its own commander copy, whose `CommanderError` class is a different identity + * from this package's, and an identity check there would rethrow a printed + * help as a fatal load failure. + * @param error - the thrown value. + * @returns true when the value carries commander's error code and exit code. + */ +function isCommanderError(error: unknown): error is { code: string; exitCode: number } { + if (typeof error !== 'object' || error === null) return false + const candidate = error as { code?: unknown; exitCode?: unknown } + return typeof candidate.code === 'string' && candidate.code.startsWith('commander.') + && typeof candidate.exitCode === 'number' +} diff --git a/packages/boot/cmdline/src/invariant.ts b/packages/boot/cmdline/src/invariant.ts new file mode 100644 index 0000000000..b18094a1f4 --- /dev/null +++ b/packages/boot/cmdline/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-cmdline`. + * @module @deepseek-ai/dsh-cmdline/invariant + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-cmdline' + +/** Cordis companion plugin name. */ +export const name = 'cmdline-invariant' +/** Service required before the companion can register. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: `cmdlineArgs` is an immutable launcher fact that any + * number of ordinary plugins may read. App-owned providers and consumers use + * normal Cordis service injection, whose missing dependencies are already + * reported by Loader settlement. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts new file mode 100644 index 0000000000..bc9b63c9aa --- /dev/null +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -0,0 +1,265 @@ +/** + * The launcher-to-app command line over a REAL Loader tree, mounted the way a + * profile boot mounts it: Loader holds each row until its injections are + * active, then resolves that row's config against its injection-ready context. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Command } from 'commander' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import { afterEach, describe, expect, it } from 'vitest' +import { + enableRow, internals, parseCmdline, provideCmdline, type CmdlinePlan, +} from '../src/index.ts' + +/** Every value one boot of the fixture tree observed. */ +interface Observed { + /** Config the reading row started with; absent means it never started. */ + started?: Record + exits: number[] + out: string +} + +/** A booted fixture tree: what it observed, and its root for direct parser calls. */ +interface Fixture { + observed: Observed + ctx: Context +} + +const disposers: (() => Promise)[] = [] + +afterEach(async () => { + for (const dispose of disposers.splice(0)) await dispose() + internals.stdout = process.stdout + internals.stderr = process.stderr +}) + +/** The fixture app's flag family: one `--port` its rows read from the service. */ +function demoCommand(): Command { + return new Command().name('demo').exitOverride().option('--port ', 'listen port') +} + +/** The fixture app's plan: the resolved values its rows read. */ +const demoPlan: CmdlinePlan<{ port?: number }> = (program) => { + const port = program.opts<{ port?: string }>().port + if (port === undefined) return {} + if (!/^\d+$/.test(port)) program.error(`error: --port must be a number, got ${JSON.stringify(port)}`) + return { port: Number(port) } +} + +/** A YAML `!!js` expression node, as the include parses one out of a patch file. */ +const expression = (source: string): unknown => ({ __jsExpr: source }) + +/** + * Mount a two-row composition the way a profile boot does: both rows at once, + * with Loader ordering config resolution from their injections. + * @param args - the invocation's inner arguments. + * @param plan - the app's plan; defaults to the fixture's own. + * @returns the booted fixture. + */ +async function bootFixture( + args: string[], + plan: CmdlinePlan = demoPlan, + options: { objectInject?: boolean; withoutProvider?: boolean } = {}, +): Promise { + const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-')) + const observed: Observed = { exits: [], out: '' } + writeFileSync(join(dir, 'reader.mjs'), ` +export const name = 'reader' +export const inject = ['demoStartup'] +export function apply(ctx, config) { globalThis.__observed.started = config } +`) + // The Loader imports a row through Node's own resolver, which cannot resolve + // this workspace's sources; the row delegates to the real function the test + // imported through the source-plane path mapping. + writeFileSync(join(dir, 'startup.mjs'), ` +export const name = 'demo-startup' +export const inject = ['cmdlineArgs'] +export function apply(ctx) { return globalThis.__provideDemoArgs(ctx) } +`) + writeFileSync(join(dir, 'cordis.yml'), '[]\n') + const observing = { write: (chunk: string) => { observed.out += chunk; return true } } + internals.stdout = observing + internals.stderr = observing + const globals = globalThis as unknown as { __observed: Observed; __provideDemoArgs: (ctx: Context) => void } + globals.__observed = observed + globals.__provideDemoArgs = (ctx: Context) => { + const values = parseCmdline(ctx, demoCommand(), plan) + if (values !== undefined) ctx.provide('demoStartup', values) + } + + // The composition, exactly as a profile delivers one: include patches whose + // config carries `!!js` expressions. + const composition: PatchOptions[] = [{ + insert: [ + ...options.withoutProvider === true + ? [] + : [{ id: 'demo-startup', name: pathToFileURL(join(dir, 'startup.mjs')).href }], + { + id: 'reader', + name: pathToFileURL(join(dir, 'reader.mjs')).href, + inject: options.objectInject === true ? { demoStartup: { required: true } } : ['demoStartup'], + config: { port: expression('ctx.demoStartup.port ?? 3080') }, + }, + ], + }] + const ctx = new Context() + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + provideCmdline(ctx, { args, exit: code => void observed.exits.push(code) }) + await ctx.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(join(dir, 'cordis.yml')).href, patches: structuredClone(composition) }, + }) + await ctx.loader.await() + disposers.push(async () => { await ctx.fiber.dispose() }) + return { observed, ctx } +} + +describe('parseCmdline', () => { + it('lets a row read the flag value the app resolved', async () => { + const { observed } = await bootFixture(['--port', '8080']) + expect(observed.started).toEqual({ port: 8080 }) + expect(observed.exits).toEqual([]) + }) + + it('leaves a row on the value written beside the expression when no flag names one', async () => { + const { observed } = await bootFixture([]) + expect(observed.started).toEqual({ port: 3080 }) + }) + + it('recognizes the Loader object form of a provider-service injection', async () => { + const { observed } = await bootFixture(['--port', '8080'], demoPlan, { objectInject: true }) + expect(observed.started).toEqual({ port: 8080 }) + }) + + it('prints the app help, starts no reading row, and requests exit 0', async () => { + const { observed } = await bootFixture(['--help']) + expect(observed.out).toContain('Usage: demo') + expect(observed.started).toBeUndefined() + expect(observed.exits).toEqual([0]) + }) + + it('rejects the invocation from the plan without starting the app', async () => { + const { observed } = await bootFixture(['--port', 'abc']) + expect(observed.out).toContain('--port must be a number') + expect(observed.started).toBeUndefined() + expect(observed.exits).toEqual([1]) + }) + + it('rethrows a plan failure that is not commander asking to exit', async () => { + const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true }) + const plan: CmdlinePlan = () => { throw new Error('plan exploded') } + expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan exploded') + }) + + it('rethrows a thrown value that is not an object at all', async () => { + const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true }) + const plan: CmdlinePlan = () => { + const thrown: unknown = 'plan threw a string' + throw thrown + } + expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan threw a string') + }) + + it('returns values without inspecting Loader rows or owning a service', async () => { + const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true }) + expect(parseCmdline(ctx, demoCommand())).toEqual({}) + expect(ctx.get('demoStartup')).toBeUndefined() + }) +}) + +describe('enableRow', () => { + it('enables the named Loader row and fails loud when the Loader or row is absent', async () => { + const withoutLoader = new Context() + await expect(enableRow(withoutLoader, 'client-hmr')).rejects.toThrow('requires the Loader service') + + const ctx = new Context() + let enabled = false + ctx.provide('loader', { + entries: () => [{ + options: { id: 'client-hmr' }, + enableRuntime: async () => { enabled = true }, + }], + } as never) + await enableRow(ctx, 'client-hmr') + expect(enabled).toBe(true) + await expect(enableRow(ctx, 'absent')).rejects.toThrow('no "absent" row to enable') + }) + + it('keeps invocation-only activation through config reapplication', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-runtime-enable-')) + const observed = { starts: 0, stops: 0 } + ;(globalThis as unknown as { __runtimeEnableObserved: typeof observed }).__runtimeEnableObserved = observed + writeFileSync(join(dir, 'conditional.mjs'), ` +export function apply(ctx) { + globalThis.__runtimeEnableObserved.starts += 1 + ctx.effect(() => () => { globalThis.__runtimeEnableObserved.stops += 1 }) +} +`) + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: conditional', + ` name: ${pathToFileURL(join(dir, 'conditional.mjs')).href}`, + ' disabled: true', + '', + ].join('\n')) + + const ctx = new Context() + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(join(dir, 'cordis.yml')).href }, + }) + await ctx.loader.await() + const conditional = [...ctx.loader.entries()].find(entry => entry.options.id === 'conditional') + const include = [...ctx.loader.entries()].find(entry => entry.options.name === 'cordis:include') + expect(conditional).toBeDefined() + expect(include?.fiber).toBeDefined() + expect(conditional?.options.disabled).toBe(true) + expect(observed).toEqual({ starts: 0, stops: 0 }) + + await enableRow(ctx, 'conditional') + await ctx.loader.await() + expect(conditional?.disabled).toBe(false) + expect(conditional?.options.disabled).toBe(true) + expect(observed).toEqual({ starts: 1, stops: 0 }) + + await include!.fiber!.update(include!.options.config, true) + await ctx.loader.await() + expect(conditional?.disabled).toBe(false) + expect(conditional?.options.disabled).toBe(true) + expect(observed).toEqual({ starts: 1, stops: 0 }) + disposers.push(async () => { await ctx.fiber.dispose() }) + }) +}) + +describe('provideCmdline', () => { + it('hands the app a snapshot the caller cannot mutate afterwards', () => { + const ctx = new Context() + const args = ['--resume', 'abc'] + provideCmdline(ctx, { args, exit: () => {} }) + args.push('--tampered') + expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc']) + }) + + it('fails loud when a parser runs without the launcher values', () => { + const ctx = new Context() + expect(() => { parseCmdline(ctx, demoCommand()) }) + .toThrow('the launcher must provide ctx.cmdlineArgs and ctx.appExit') + }) + + it('lets multiple parsers read the same immutable snapshot', () => { + const ctx = new Context() + provideCmdline(ctx, { args: ['--port', '8080'], exit: () => {} }) + expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 }) + expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 }) + expect(Object.isFrozen(ctx.cmdlineArgs?.get())).toBe(true) + }) +}) diff --git a/packages/boot/cmdline/tsconfig.json b/packages/boot/cmdline/tsconfig.json new file mode 100644 index 0000000000..f4bcebf1e8 --- /dev/null +++ b/packages/boot/cmdline/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/include" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index dd08db1425..502cdf16d4 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/base/README.md -README.md: 70ecc181da8f0c120b8da0d55f68d47bf22d5820 -README.zh.md: 11f10bf561429c11471ff57d08950677e4924b40 +README.md: 8b0db20274036a2601da19617a35e6bf4aeb30ca +README.zh.md: ac5ab10a523fa211c1c1daf4c55d4dc8702eb782 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 70ecc181da..8b0db20274 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, and telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code. +The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, telemetry, and host-level subagent providers — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Codex and Claude Code providers load dormant; Agent Presets independently decide whether their agent contributes either model-facing delegation tool. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code. Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only bash stack (`bash-sandbox`/`tool-bash`) and inserts the sandbox-confined PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox`, `@deepseek-ai/dsh-tool-pwsh`). The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts never receive it. @@ -19,4 +19,5 @@ None directly; each inserted row's package owns its effect. ## Known Limitations and Deferred Work - **A patch replaces whole row configs** — profile overrides must restate every field a row keeps; there is no deep-merge layer. +- **Claude's SDK platform CLI remains in the Profile install closure** — the base bundle depends on the Claude provider, whose production path resolves the host `claude`; removing the SDK's unused optional payload is deferred to the product installation-closure follow-up. - **The Windows temp grant is a private per-session subdirectory** — `workspace-write` confines writes to the workspace plus the session's own temp subdirectory (`\dsh-`, TMP/TEMP rewritten for confined children); `read-only` grants nothing. See `@deepseek-ai/dsh-sandbox-windows-acl`. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index 11f10bf561..ac5ab10a52 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settings/credentials 和遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 +以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settings/credentials、遥测与宿主级 subagent provider——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。Codex 与 Claude Code provider 以休眠状态加载;Agent Preset 分别决定自己的 agent 是否贡献任一面向模型的委派工具。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的 bash 栈(`bash-sandbox`/`tool-bash`),并插入沙盒受限的 PowerShell 栈(`@deepseek-ai/dsh-pwsh-sandbox`、`@deepseek-ai/dsh-tool-pwsh`)。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner(`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。启动器在 win32 主机上把该层应用于 bundle 层与用户层之间;偏好不限权本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)。POSIX 主机永远不会收到它。 @@ -19,4 +19,5 @@ ## 已知限制与延期工作 - **patch 会替换整行 `config`**:profile 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。 +- **Claude SDK 的平台 CLI(命令行界面)仍在 Profile 安装闭包中**:base 组合包依赖 Claude 提供方,其生产路径解析宿主提供的 `claude`;移除 SDK 中未使用的可选载荷,推迟到产品安装闭包后续项处理。 - **Windows 的临时目录授权是按会话的私有子目录**——`workspace-write` 把写入限制在工作区与会话自己的 temp 子目录(`\dsh-`,受限子进程的 TMP/TEMP 被改写);`read-only` 不授予任何写入。见 `@deepseek-ai/dsh-sandbox-windows-acl`。 diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index e5ab0d9463..2fb5ff10d3 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -287,6 +287,15 @@ config: providerName: fork + # Product providers stay on the host plane because the registry is a + # process singleton. Agent presets decide whether their own model sees the + # matching delegation tools; loading either provider starts no product. + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + + - id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' + # Continuable background children are selected per delegation tool. The # separately loaded follow-up tool registers the one global `send_message`. - id: tool-subagent-control diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 272a403872..038e3ea0c6 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -80,6 +80,8 @@ "@deepseek-ai/dsh-spill-local": "workspace:^", "@deepseek-ai/dsh-spill-policy": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-claude-code": "workspace:^", + "@deepseek-ai/dsh-subagent-codex": "workspace:^", "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index 4db8f9bdb9..ba93c37a3c 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -15,7 +15,10 @@ describe('dsh-base bundle', () => { const root = fileURLToPath(new URL('..', import.meta.url)) const manifest = JSON.parse( readFileSync(resolve(root, 'package.json'), 'utf8'), - ) as { dsh?: { bundle?: { patch?: string } } } + ) as { + dependencies?: Record + dsh?: { bundle?: { patch?: string } } + } expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') const parsed = yaml.load( readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), @@ -28,6 +31,12 @@ describe('dsh-base bundle', () => { ) expect(rows.length).toBeGreaterThan(50) expect(rows.some(row => row.id === 'agent-loop')).toBe(true) + expect(rows.filter(row => row.id === 'subagent-codex')).toHaveLength(1) + expect(rows.filter(row => row.id === 'subagent-claude-code')).toHaveLength(1) + expect(manifest.dependencies).toMatchObject({ + '@deepseek-ai/dsh-subagent-codex': 'workspace:^', + '@deepseek-ai/dsh-subagent-claude-code': 'workspace:^', + }) }) it('ships the Windows platform layer as the confined pwsh roster over the ACL runner chain', () => { diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 2ded85f25b..4377802ae4 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/headless/README.md -README.md: f8b76b77f2beb22f501a49f0fc4cf5cd72223765 -README.zh.md: aae8ab5bea663b8909de942f72615f5ef9b16c84 +README.md: 31a4894dbb191d2244371ca7272339e96e253053 +README.zh.md: 6e8d28f10071fbab175c4f14f1aaa9618b8f598a diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index f8b76b77f2..31a4894dbb 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`). It mounts no Host, HTTP server, Web runtime, or browser plugin. +The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, resolved from the injected `headlessStartup` provider). It mounts no Host, HTTP server, Web runtime, or browser plugin. -After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` host hook (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The launcher patches the task text in (`dsh run "task"`) and fails loud when the selected profile lacks this row. +After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` host hook (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the ordinary `headless-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument of `dsh --profile headless "task"`, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. A missing or whitespace-only task is rejected before the runner activates. ## Model Experience diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index aae8ab5bea..6e8d28f100 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。 +dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,从注入的 `headlessStartup` 提供方解析)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。 -Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.headlessIo` 宿主钩子请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。启动器把任务文本 patch 进来(`dsh run "task"`);若所选 profile 缺少该行,则显式报错。 +Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.headlessIo` 宿主钩子请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:普通 `headless-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),读取 `dsh --profile headless "task"` 的位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务。缺失或只有空白的任务会在 runner 激活前被拒绝。 ## 模型体验 diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index 8b147714be..8d2e1ff4ab 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -1,7 +1,8 @@ # The dsh-headless bundle patch: one-shot task mode directly over dsh-base. -# It mounts no Host, HTTP server, Web runtime, or browser plugin. The launcher -# patches the runner's `task`; the direct driver creates an Agent through the -# core registry and prints the final durable assistant message. +# It mounts no Host, HTTP server, Web runtime, or browser plugin. An ordinary +# provider plugin injects `cmdlineArgs`, parses the task positional +# (`dsh --profile headless ""`) and this app's --help, then the direct +# driver creates an Agent through the core registry and prints its durable result. - id: system-prompt config: @@ -22,5 +23,12 @@ - id: code-runtime name: '@deepseek-ai/dsh-code-runtime-worker' + - id: headless-startup + name: '@deepseek-ai/dsh-headless/startup' + + # Reads its task from the ordinary headlessStartup provider. - id: headless-runner name: '@deepseek-ai/dsh-headless' + inject: [headlessStartup] + config: + task: !!js ctx.headlessStartup.task diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index c5090d4b93..e439fe75f7 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -11,6 +11,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./startup": { + "types": "./lib/types/startup.d.ts", + "default": "./lib/startup.js" + }, "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" @@ -22,6 +26,7 @@ "files": [ "lib/index.js", "lib/invariant.js", + "lib/startup.js", "cordis.patch.yml", "lib/types/**/*.d.ts" ], @@ -32,10 +37,13 @@ } }, "dependencies": { + "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0", + "commander": "^15.0.0" }, "peerDependencies": { + "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-default-model": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 4b1403ca59..284f948aac 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -25,7 +25,7 @@ export const name = 'headless-runner' /** Core services required before the one-shot turn can start. */ export const inject = ['agentDefaultModel', 'agents', 'sessions'] -/** Plugin config: the task, patched in by the launcher. */ +/** Plugin config: the task resolved from this app's injected provider service. */ export interface Config { /** The prompt text for the single run. */ task: string diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts new file mode 100644 index 0000000000..bfb4d44e51 --- /dev/null +++ b/packages/bundle/headless/src/startup.ts @@ -0,0 +1,62 @@ +/** + * The one-shot app's command-line provider: it parses the task positional and + * `--help`, then publishes {@link HEADLESS_STARTUP_SERVICE}. The runner is an + * ordinary consumer whose lazy config waits for that service. + * @module @deepseek-ai/dsh-headless/startup + */ + +import { Command } from 'commander' +import type { Context } from '@deepseek-ai/cordis' +import { parseCmdline } from '@deepseek-ai/dsh-cmdline' + +/** Stable Cordis plugin name. */ +export const name = 'headless-startup' + +/** Services required before the task can be resolved. */ +export const inject = ['cmdlineArgs'] + +/** Service provided by this plugin and injected by the one-shot runner. */ +export const HEADLESS_STARTUP_SERVICE = 'headlessStartup' + +/** What the runner row reads from {@link HEADLESS_STARTUP_SERVICE}. */ +export interface HeadlessStartupValues { + /** The task text this invocation asked for. */ + task: string +} + +/** + * This app's command: the task positional, its description, and its help text. + * @returns a fresh program, so one process can parse more than once (tests). + */ +function headlessCommand(): Command { + return new Command() + .name('dsh --profile headless') + .description('Answer one task, print the final assistant message, and exit.') + .helpOption('-h, --help', 'show this help') + .argument('[task...]', 'the task text; multiple words are joined by spaces') + .addHelpText('after', ` +Examples: + dsh --profile headless "run the tests" answer one task and exit +`) +} + +/** + * Turn the parsed command line into the runner's task. + * @param program - the parsed headless command. + * @returns the runner's service value. + */ +function planHeadlessStartup(program: Command): HeadlessStartupValues { + const task = program.args.join(' ') + if (task.trim() === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"') + return { task } +} + +/** + * Parse and provide the one-shot task as an ordinary Cordis service. + * @param ctx - plugin context carrying the command line. + * @returns nothing once the task is provided, or when the command requested exit. + */ +export function apply(ctx: Context): void { + const values = parseCmdline(ctx, headlessCommand(), planHeadlessStartup) + if (values !== undefined) ctx.provide(HEADLESS_STARTUP_SERVICE, values) +} diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts new file mode 100644 index 0000000000..07c200202e --- /dev/null +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -0,0 +1,106 @@ +/** + * The one-shot app's ordinary command-line provider over a real Loader tree: + * the task becomes injected runner config, while help and usage errors leave + * the consumer pending. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' +import { afterEach, describe, expect, it } from 'vitest' +import { apply, HEADLESS_STARTUP_SERVICE, type HeadlessStartupValues } from '../src/startup.ts' + +/** What one boot of the fixture tree observed. */ +interface Observed { + exits: number[] + out: string + runnerConfig?: unknown +} + +const disposers: (() => Promise)[] = [] + +afterEach(async () => { + for (const dispose of disposers.splice(0)) await dispose() + internals.stdout = process.stdout + internals.stderr = process.stderr +}) + +/** + * Mount the real provider over a runner stand-in. + * @param args - the invocation's inner arguments. + * @returns the resolved service value and observed runner/process effects. + */ +async function bootStartup(args: string[]): Promise<{ task: HeadlessStartupValues | undefined; observed: Observed }> { + const dir = mkdtempSync(join(tmpdir(), 'dsh-headless-startup-')) + const observed: Observed = { exits: [], out: '' } + writeFileSync(join(dir, 'row.mjs'), 'export function apply(_ctx, config) { globalThis.__headlessStartupObserved.runnerConfig = config }\n') + // Loader imports through Node's resolver, so this fixture delegates to the + // source-plane plugin already imported by the test. + writeFileSync(join(dir, 'startup.mjs'), ` +export const name = 'headless-startup' +export const inject = ['cmdlineArgs'] +export const apply = ctx => globalThis.__headlessStartupApply(ctx) +`) + const rowUrl = pathToFileURL(join(dir, 'row.mjs')).href + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: headless-runner', + ` name: ${rowUrl}`, + ` inject: [${HEADLESS_STARTUP_SERVICE}]`, + ' config:', + ' task: !!js ctx.headlessStartup.task', + '- id: headless-startup', + ` name: ${pathToFileURL(join(dir, 'startup.mjs')).href}`, + '', + ].join('\n')) + const observing = { write: (chunk: string) => { observed.out += chunk; return true } } + internals.stdout = observing + internals.stderr = observing + const globals = globalThis as unknown as { + __headlessStartupApply: typeof apply + __headlessStartupObserved: Observed + } + globals.__headlessStartupApply = apply + globals.__headlessStartupObserved = observed + + const ctx = new Context() + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + provideCmdline(ctx, { args, exit: code => void observed.exits.push(code) }) + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } }) + await ctx.loader.await() + disposers.push(async () => { await ctx.fiber.dispose() }) + return { + task: ctx.get(HEADLESS_STARTUP_SERVICE) as HeadlessStartupValues | undefined, + observed, + } +} + +describe('headless command-line provider', () => { + it('joins the task positional into the runner config', async () => { + const { task, observed } = await bootStartup(['run', 'the', 'tests']) + expect(task).toEqual({ task: 'run the tests' }) + expect(observed.runnerConfig).toEqual({ task: 'run the tests' }) + expect(observed.exits).toEqual([]) + }) + + it.each([{ args: [] }, { args: [' '] }])('rejects an invocation with no non-whitespace task ($args)', async ({ args }) => { + const { task, observed } = await bootStartup(args) + expect(observed.out).toContain('a task is required') + expect(task).toBeUndefined() + expect(observed.runnerConfig).toBeUndefined() + expect(observed.exits).toEqual([1]) + }) + + it('prints its own help and leaves the runner pending', async () => { + const { task, observed } = await bootStartup(['--help']) + expect(observed.out).toContain('dsh --profile headless') + expect(task).toBeUndefined() + expect(observed.runnerConfig).toBeUndefined() + expect(observed.exits).toEqual([0]) + }) +}) diff --git a/packages/bundle/headless/tsconfig.json b/packages/bundle/headless/tsconfig.json index 9ae3212f6b..8e0b4ae4b3 100644 --- a/packages/bundle/headless/tsconfig.json +++ b/packages/bundle/headless/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../boot/cmdline" } ] } diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 6594cdb6d1..9040315855 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md -README.md: d89ae4a7e28506166498caf0032f864bbb109cc5 -README.zh.md: 746ec2e8b6748a0d72f697d0aea5f3809e7106ee +README.md: b6fa225f5e0a0a079605a4fb9064b79287ab21cd +README.zh.md: 68af959719b9bd146eddd143aa9d98400e65fa68 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index d89ae4a7e2..b6fa225f5e 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -2,19 +2,19 @@ English | [中文](README.zh.md) -The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. The `dsh web` launcher alias patches `mode`/`lanAddresses` and the flag family over these rows. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. +The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, enables the optional HMR row before client-module discovery so the first development graph contains its reload receiver, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, `--dev`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. Flag-configured rows inject that service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. ## Model Experience -### Web-surface prompt section and bash runtime variables +### Harness-source and Web-surface context #### What the model sees -When `surfaceContext` is true, the `app:web-surface` global section (order −98) orients the model to the GUI: the canonical local URL, the "this page" referent, the HMR/rebuild update contract for the active mode, and the instruction not to start replacement servers. `DSH_WEB_URL` and `DSH_WEB_MODE` additionally appear in the managed bash environment with their descriptions, resolved per invocation from the live server. When it is false, neither the section nor the variables are registered. +When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (order −98) orients the model to the GUI: the canonical local URL, the "this page" referent, the HMR/rebuild update contract for the active mode, and the instruction not to start replacement servers. `DSH_WEB_URL` and `DSH_WEB_MODE` additionally appear in the managed bash environment with their descriptions, resolved per invocation from the live server. When it is false, neither section nor the variables are registered. #### Token effect -One prompt paragraph per session plus two managed-environment variable lines; constant per process. +One source line and one prompt paragraph per session plus two managed-environment variable lines; constant per process. #### KV Cache effect diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 746ec2e8b6..68af959719 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -2,19 +2,19 @@ [English](README.md) | 中文 -dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。`dsh web` 启动器别名把 `mode`/`lanAddresses` 与相应 flag 家族 patch 到这些行上。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 +dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,在客户端模块发现前启用可选的 HMR 行,确保首份开发模式图中包含它的重载接收端,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host`、`--port`、`--dev`、可重复的 `--trusted-host` 以及应用自己的 `--help`,再提供 `webStartup`。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 ## 模型体验 -### Web 表层提示词段落与 bash 运行时变量 +### Harness 源码与 Web 表层上下文 #### 模型看到的内容 -当 `surfaceContext` 为 true 时,全局段落 `app:web-surface`(顺序 −98)向模型说明 GUI:规范的本地 URL、「this page」指代什么、当前模式下 HMR(热模块替换)/重建的更新约定,以及不要启动替代服务器的指令。`DSH_WEB_URL` 与 `DSH_WEB_MODE` 还会连同各自描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,该提示词段和这些变量都不会注册。 +当 `surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(顺序 −98)则向模型说明 GUI:规范的本地 URL、「this page」指代什么、当前模式下 HMR(热模块替换)/重建的更新约定,以及不要启动替代服务器的指令。`DSH_WEB_URL` 与 `DSH_WEB_MODE` 还会连同各自描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和这些变量都不会注册。 #### Token 影响 -每个会话一段提示词,外加两行受管环境变量;每个进程内保持恒定。 +每个会话一行源码说明和一段提示词,外加两行受管环境变量;每个进程内保持恒定。 #### KV Cache 影响 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 8ccfdc1777..1d314a1f9c 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -3,9 +3,13 @@ # the profile's own cordis.patch.yml and any --patch overlays still to come. # # A patch replaces the targeted row's whole `config`, so each row below -# restates every key it owns. The `dsh web` launcher alias turns --host/--port/ -# --dev/--trusted-host into further patches over these rows -# (`--dev` inserts the dsh-client-hmr row). +# restates every key it owns. +# +# The web-startup plugin injects `cmdlineArgs` and provides `webStartup` as an +# ordinary Cordis service. Rows configured from flags inject that service, so +# Loader resolves their expressions only after it exists. The web runtime then +# provides bind-dependent `webRuntime` values to the trust fence and client +# roster. `dsh --profile web --help` provides neither service, so no server binds. # ── surface-specific values the base deliberately omits ───────────────────── @@ -76,44 +80,70 @@ - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' + # Ordinary provider for the parsed Web flags. Its plugin-level injection + # waits for cmdlineArgs; no launcher metadata or special row kind is needed. + - id: web-startup + name: '@deepseek-ai/dsh-web-app/startup' + # ── layer 2: transport/service ────────────────────────────────────────────── - # Plain route-registration carrier; host and port arrive as `dsh web` - # flag patches over these defaults. The dist is served by the web-runtime - # row below through the fallback seat. + # Plain route-registration carrier; host and port come from the app's + # webStartup provider, with these deployment fallbacks. The dist is served by + # the web-runtime row below through the fallback seat. - id: webserver name: '@deepseek-ai/dsh-host-webserver' + inject: [webStartup] config: - host: 127.0.0.1 - port: 3080 + host: !!js ctx.webStartup.host ?? '127.0.0.1' + port: !!js ctx.webStartup.port ?? 3080 # Web glue owned by this bundle: resolves the built frontend dist (an # assembly fact of dsh-web-app, never user config), mounts the # frontend-static fallback owner, registers the web-surface prompt - # section and bash runtime variables, and prints the URL line. `dsh web` - # patches mode/lanAddresses over these defaults; complete-prompt overlays - # set surfaceContext false to suppress every model- and shell-visible Web - # runtime contribution. + # section and bash runtime variables, and prints the URL line. The webStartup + # provider supplies invocation-only values; after the server binds, this row + # samples LAN trust once and provides `webRuntime`. A complete agent-preset + # persona suppresses the prompt section for that agent while retaining + # these host-owned shell variables. - id: web-runtime name: '@deepseek-ai/dsh-web-app' + inject: [webStartup] config: - mode: production + mode: !!js ctx.webStartup.mode printUrl: true surfaceContext: true + trustedHosts: !!js ctx.webStartup.trustedHosts + + # The client-plugin reload chain: a dev-only row this bundle ships off, + # which the runtime row turns on before client discovery. It is a row rather + # than a child of web-runtime because its node half is a client-side package, + # which a host-side bundle cannot import. + - id: client-hmr + name: '@deepseek-ai/dsh-client-hmr' + inject: [webStartup] + disabled: true # ── browser plugin roster (dsh.client rows; node halves are layer-2 hosts) ── - # Dual-face: node half scans this very tree for dsh.client rows, composes - # window.__DSH_BOOT__, serves /plugins//client.js; browser half is the - # module table the shell kernel constructs before cordis exists (adopted - # as a plugin entry by the kernel, never fetched). + # Dual-face: this waits for the runtime row to decide whether HMR belongs + # in the first graph. The node half then scans this tree, composes + # window.__DSH_BOOT__, and serves /plugins//client.js; the browser half + # is the module table the shell kernel constructs before cordis exists + # (adopted as a plugin entry by the kernel, never fetched). - id: modules name: '@deepseek-ai/dsh-client-modules' + inject: [webRuntime] # Owns both ends of the web transport: node half binds the gateway to the # webserver under /api; browser half is the fetch/SSE client. - id: connection name: '@deepseek-ai/dsh-client-connection' + inject: [webRuntime] + config: + # LAN literals derived from the active bind plus --trusted-host extras. + # A deployment adding authorities keeps this expression and concatenates + # its literals, for example: ['app.internal', ...ctx.webRuntime.trustedHosts]. + trustedHosts: !!js ctx.webRuntime.trustedHosts - id: api-remotes name: '@deepseek-ai/dsh-api-remotes' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index c68af089ed..351fd122d4 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -11,6 +11,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./startup": { + "types": "./lib/types/startup.d.ts", + "default": "./lib/startup.js" + }, "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" @@ -22,6 +26,7 @@ "files": [ "lib/index.js", "lib/invariant.js", + "lib/startup.js", "cordis.patch.yml", "lib/types/**/*.d.ts" ], @@ -34,6 +39,7 @@ "dependencies": { "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -61,6 +67,7 @@ "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", + "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", "@deepseek-ai/dsh-frontend": "workspace:^", "@deepseek-ai/dsh-frontend-static": "workspace:^", @@ -74,15 +81,18 @@ "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0", + "commander": "^15.0.0" }, "peerDependencies": { + "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-bash-env": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 286a006f28..0a8ec7ffbb 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -4,15 +4,19 @@ * manifest field). The plugin owns the browser-surface glue: it resolves * the built frontend dist (workspace knowledge of this bundle, never user * config), mounts the `frontend-static` fallback owner over it, registers the - * web-surface prompt section and the bash-visible web runtime variables, and - * prints the URL line when configured to. Flag-derived values (`mode`, - * `lanAddresses`, `printUrl`) arrive as launcher patches over this row. + * harness-source and web-surface prompt sections, the bash-visible web runtime + * variables, and the URL line. App command-line values arrive through the + * `webStartup` service expressions in the bundle patch. * @module @deepseek-ai/dsh-web-app */ import { createRequire } from 'node:module' +import { networkInterfaces } from 'node:os' +import { fileURLToPath } from 'node:url' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' +import { enableRow } from '@deepseek-ai/dsh-cmdline' import * as FrontendStatic from '@deepseek-ai/dsh-frontend-static' import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-host-webserver' @@ -22,41 +26,51 @@ import type {} from '@deepseek-ai/dsh-bash-env' /** Stable Cordis plugin name. */ export const name = 'web-app' +/** This dsh installation's root, from either this package's source or built entry. */ +const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)) +const HMR_ROW_ID = 'client-hmr' + +/** Runtime service that releases Web rows after bind-dependent values resolve. */ +const WEB_RUNTIME_SERVICE = 'webRuntime' + /** Services required before the web runtime can mount. */ export const inject = ['httpServer'] /** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */ export type WebMode = 'production' | 'development' -/** Plugin config: the surface facts the launcher patches over this bundle's defaults. */ +/** Plugin config: composed deployment settings plus per-invocation command-line values. */ export interface Config { /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */ mode: WebMode - /** Print the URL line on activation; a headless layer over this bundle turns it off. */ + /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** * Register the model-visible surface context (the `app:web-surface` prompt * section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot - * layer turns it off: its user is not interacting through the GUI, so the + * non-interactive layer can turn it off when its user is not in the GUI, so the * orientation text would be false. */ surfaceContext: boolean - /** - * LAN IPv4 addresses sampled once by the launcher when the effective bind - * is all-interfaces — the exact snapshot the /api trust fence was - * configured with, so the printed LAN URL can never name an address the - * fence rejects. Empty on a loopback bind. - */ - lanAddresses: string[] + /** Explicit `--trusted-host` authorities from this invocation. */ + trustedHosts: string[] } export const Config: z = z.object({ mode: z.union([z.const('production'), z.const('development')]).default('production'), printUrl: z.boolean().default(true), surfaceContext: z.boolean().default(true), - lanAddresses: z.array(String).default([]), + trustedHosts: z.array(String).default([]), }) +/** Bind-dependent Web values shared by the trust fence and URL display. */ +export interface WebRuntimeValues { + /** LAN IPv4 literals sampled once when the server binds all interfaces. */ + lanAddresses: string[] + /** LAN literals followed by explicit invocation authorities. */ + trustedHosts: string[] +} + /** Environment variable naming the canonical local URL of this Web GUI. */ const DSH_WEB_URL = 'DSH_WEB_URL' as const /** Environment variable naming the Web runtime mode. */ @@ -65,6 +79,27 @@ const DSH_WEB_MODE = 'DSH_WEB_MODE' as const // Display-only mirror of the webserver schema's loopback host: the address the // local URL always prints. Not a source of truth — the schema is. const LOOPBACK_HOST = '127.0.0.1' +/** The webserver schema's all-interfaces bind literal. */ +const ALL_INTERFACES_HOST = '0.0.0.0' + +/** + * Resolve one LAN-trust snapshot from the active server bind. + * + * Derived entries are port-less IP literals: DNS rebinding needs an + * attacker-controlled name, while an IP-literal Host is safe on any port and + * an OS-assigned port is unknowable before bind. + * @param bindHost - the active webserver bind host. + * @param extra - explicit `--trusted-host` values, in argument order. + * @returns the LAN display addresses and invocation-derived fence authorities. + */ +export function resolveLanTrust(bindHost: string, extra: readonly string[]): WebRuntimeValues { + const lanAddresses = bindHost === ALL_INTERFACES_HOST + ? Object.values(networkInterfaces()).flat() + .filter((iface): iface is NonNullable => iface !== undefined && iface.family === 'IPv4' && !iface.internal) + .map(iface => iface.address) + : [] + return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } +} /** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */ function webSurfacePrompt(webUrl: string, mode: WebMode): string { @@ -109,11 +144,21 @@ export const internals: { resolveDistIndex: () => string } = { resolveDistIndex * variables, and the URL line. * @param ctx - plugin context carrying the httpServer service. * @param config - validated {@link Config}. + * @returns nothing once the invocation's client roster and runtime contributions are registered. */ -export function apply(ctx: Context, config: Config): void { +export async function apply(ctx: Context, config: Config): Promise { + // Client discovery must start after the optional HMR row has a pending + // fiber. Otherwise its first browser graph omits the reload receiver, which + // cannot use that receiver to discover itself later. + if (config.mode === 'development') await enableRow(ctx, HMR_ROW_ID) + const runtime = resolveLanTrust(ctx.httpServer.host, config.trustedHosts) + // Release dependent rows only after the optional row has a pending fiber and + // bind-dependent trust has been sampled once. + ctx.provide(WEB_RUNTIME_SERVICE, runtime) ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() }) if (config.surfaceContext) { ctx.inject(['systemPrompt'], (promptCtx) => { + addHarnessSourceSection(promptCtx, SOURCE_ROOT) promptCtx.systemPrompt.section({ name: 'app:web-surface', order: -98, @@ -137,21 +182,24 @@ export function apply(ctx: Context, config: Config): void { // sibling rows (the /api route owner) are still mounting. Await Loader // settlement first; a hand-built tree without a Loader prints at once. const printUrl = (): void => { - // The launcher's boot-time LAN snapshot, not a fresh sample: the printed - // LAN URL must name an address the /api trust fence was configured with. - const lanCandidate = config.lanAddresses[0] + // Reuse the exact LAN snapshot provided to the /api trust fence. + const lanCandidate = runtime.lanAddresses[0] const port = ctx.httpServer.port console.log(`dsh web: ${localWebUrl(ctx)}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`) } - const loader = ctx.get('loader') - if (loader === undefined) printUrl() + // This row's own activation can precede a sibling failure. The app owns + // readiness by waiting for its Loader tree, or prints at once in a + // hand-built context without Loader. + const settled = ctx.get('loader')?.await() + if (settled === undefined) printUrl() else { - void loader.await().then(() => { - // The tree can be disposed while settlement was in flight (early + void settled.then(() => { + // The tree can be disposed while the boot was in flight (early // SIGTERM); a URL line for a dead server would only mislead, and // reading the torn-down port would turn a clean shutdown into a crash. if (ctx.get('httpServer') !== undefined) printUrl() - }) + // Loader reports a failed boot; this row only stays quiet. + }, () => {}) } } } diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts new file mode 100644 index 0000000000..040fe843c0 --- /dev/null +++ b/packages/bundle/web-app/src/startup.ts @@ -0,0 +1,90 @@ +/** + * The web app's command-line provider: it parses the `dsh --profile web` flag + * family (`--host`, `--port`, `--dev`, `--trusted-host`) and its `--help` + * text, then provides the immutable values as {@link WEB_STARTUP_SERVICE}. + * Ordinary rows inject that service before reading it from lazy config. + * @module @deepseek-ai/dsh-web-app/startup + */ + +import { Command } from 'commander' +import type { Context } from '@deepseek-ai/cordis' +import { parseCmdline } from '@deepseek-ai/dsh-cmdline' + +/** Stable Cordis plugin name. */ +export const name = 'web-startup' + +/** Services required before the flags can be resolved. */ +export const inject = ['cmdlineArgs'] + +/** Service provided by this ordinary plugin and injected by flag-configured rows. */ +export const WEB_STARTUP_SERVICE = 'webStartup' + +/** What the web rows read from {@link WEB_STARTUP_SERVICE}. */ +export interface WebStartupValues { + /** `--host`, absent when the invocation did not name one. */ + host?: string + /** `--port`, absent when the invocation did not name one. */ + port?: number + /** Web runtime mode; `--dev` selects development, which also mounts the client-plugin reload chain. */ + mode: 'production' | 'development' + /** Explicit `--trusted-host` authorities, in argument order. */ + trustedHosts: string[] +} + +/** The web flag family, as commander parsed it. */ +interface WebOptions { + host?: string + port?: string + dev?: boolean + trustedHost?: string[] +} + +/** + * This app's command: its flags, its description, and its help text. + * @returns a fresh program, so one process can parse more than once (tests). + */ +function webCommand(): Command { + return new Command() + .name('dsh --profile web') + .description('Serve the DeepSeek Harness browser UI.') + .helpOption('-h, --help', 'show this help') + .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') + .option('--port ', 'listen port; pass 0 to let the OS pick a free one') + .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') + .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') + .addHelpText('after', ` +Examples: + dsh --profile web serve on the composed host and port + dsh --profile web --port 8080 serve on another port + dsh --profile web --host 0.0.0.0 reach it from another machine on the LAN + dsh --profile web --dev mount the client-plugin HMR receiver +`) +} + +/** + * Turn the parsed flags into the value injected rows read. + * @param program - the parsed web command. + * @returns this invocation's immutable Web options. + */ +function planWebStartup(program: Command): WebStartupValues { + const options = program.opts() + if (options.port !== undefined && !/^\d+$/.test(options.port)) { + program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) + } + return { + ...options.host !== undefined && { host: options.host }, + ...options.port !== undefined && { port: Number(options.port) }, + mode: options.dev === true ? 'development' : 'production', + trustedHosts: options.trustedHost ?? [], + } +} + +/** + * Parse and provide the Web invocation as an ordinary Cordis service. + * @param ctx - plugin context carrying the command line. + * @returns nothing once values are provided, or when the command requested exit. + */ +export function apply(ctx: Context): void { + const values = parseCmdline(ctx, webCommand(), planWebStartup) + if (values !== undefined) ctx.provide(WEB_STARTUP_SERVICE, values) +} diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts new file mode 100644 index 0000000000..5108d04232 --- /dev/null +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -0,0 +1,135 @@ +/** + * The Web command-line provider over a real Loader tree: its ordinary service + * releases a consumer whose config reads `ctx.webStartup` directly. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' +import { afterEach, describe, expect, it } from 'vitest' +import { apply, WEB_STARTUP_SERVICE, type WebStartupValues } from '../src/startup.ts' + +/** What one fixture boot observed. */ +interface Observed { + exits: number[] + out: string + readerConfig?: unknown +} + +const disposers: (() => Promise)[] = [] + +afterEach(async () => { + for (const dispose of disposers.splice(0)) await dispose() + internals.stdout = process.stdout + internals.stderr = process.stderr +}) + +/** + * Mount the real provider and a consumer using injection-ordered config. + * @param args - the invocation's inner arguments. + * @returns the service value and observed consumer/process effects. + */ +async function bootProvider(args: string[]): Promise<{ + values: WebStartupValues | undefined + observed: Observed +}> { + const dir = mkdtempSync(join(tmpdir(), 'dsh-web-startup-')) + const observed: Observed = { exits: [], out: '' } + writeFileSync(join(dir, 'reader.mjs'), ` +export function apply(_ctx, config) { globalThis.__webStartupObserved.readerConfig = config } +`) + // Node imports the fixture row outside Vite's source resolver, so delegate + // to the source-plane plugin already imported by this test. + writeFileSync(join(dir, 'provider.mjs'), ` +export const name = 'web-startup' +export const inject = ['cmdlineArgs'] +export const apply = ctx => globalThis.__webStartupApply(ctx) +`) + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: reader', + ` name: ${pathToFileURL(join(dir, 'reader.mjs')).href}`, + ` inject: [${WEB_STARTUP_SERVICE}]`, + ' config:', + " host: !!js ctx.webStartup.host ?? '127.0.0.1'", + ' port: !!js ctx.webStartup.port ?? 3080', + ' mode: !!js ctx.webStartup.mode', + ' trustedHosts: !!js ctx.webStartup.trustedHosts', + '- id: provider', + ` name: ${pathToFileURL(join(dir, 'provider.mjs')).href}`, + '', + ].join('\n')) + const observing = { write: (chunk: string) => { observed.out += chunk; return true } } + internals.stdout = observing + internals.stderr = observing + const globals = globalThis as unknown as { + __webStartupApply: typeof apply + __webStartupObserved: Observed + } + globals.__webStartupApply = apply + globals.__webStartupObserved = observed + + const ctx = new Context() + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + provideCmdline(ctx, { args, exit: code => void observed.exits.push(code) }) + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } }) + await ctx.loader.await() + disposers.push(async () => { await ctx.fiber.dispose() }) + return { + values: ctx.get(WEB_STARTUP_SERVICE) as WebStartupValues | undefined, + observed, + } +} + +describe('web command-line provider', () => { + it('publishes each flag and releases direct service expressions', async () => { + const { values, observed } = await bootProvider([ + '--host', '0.0.0.0', + '--port', '8080', + '--dev', + '--trusted-host', 'lab.internal', 'lab-2.internal', + '--trusted-host', '10.0.0.9', + ]) + expect(values).toEqual({ + host: '0.0.0.0', + port: 8080, + mode: 'development', + trustedHosts: ['lab.internal', 'lab-2.internal', '10.0.0.9'], + }) + expect(observed.readerConfig).toEqual(values) + expect(observed.exits).toEqual([]) + }) + + it('leaves deployment values to each consumer when flags omit them', async () => { + const { values, observed } = await bootProvider([]) + expect(values).toEqual({ mode: 'production', trustedHosts: [] }) + expect(observed.readerConfig).toEqual({ + host: '127.0.0.1', + port: 3080, + mode: 'production', + trustedHosts: [], + }) + }) + + it('prints its own help and leaves the consumer pending', async () => { + const { values, observed } = await bootProvider(['--help']) + expect(observed.out).toContain('dsh --profile web') + expect(observed.out).toContain('--trusted-host') + expect(values).toBeUndefined() + expect(observed.readerConfig).toBeUndefined() + expect(observed.exits).toEqual([0]) + }) + + it('rejects a non-numeric port before the consumer activates', async () => { + const { values, observed } = await bootProvider(['--port', 'abc']) + expect(observed.out).toContain('--port must be a number') + expect(values).toBeUndefined() + expect(observed.readerConfig).toBeUndefined() + expect(observed.exits).toEqual([1]) + }) +}) diff --git a/apps/cli/tests/trusted-hosts.spec.ts b/packages/bundle/web-app/tests/trusted-hosts.spec.ts similarity index 58% rename from apps/cli/tests/trusted-hosts.spec.ts rename to packages/bundle/web-app/tests/trusted-hosts.spec.ts index 5647d01536..5972569b0d 100644 --- a/apps/cli/tests/trusted-hosts.spec.ts +++ b/packages/bundle/web-app/tests/trusted-hosts.spec.ts @@ -1,7 +1,7 @@ /** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */ import { describe, expect, it, vi } from 'vitest' -import { resolveLanTrust, webSurfaceContextEnabled } from '../src/web.ts' +import { resolveLanTrust } from '../src/index.ts' vi.mock('node:os', () => ({ networkInterfaces: () => ({ @@ -26,20 +26,9 @@ describe('resolveLanTrust', () => { expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080']) }) - it('derives nothing for a loopback or unresolved bind — extras alone stand, no LAN URL to print', () => { + it('derives nothing for a loopback bind — extras alone stand, no LAN URL to print', () => { expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] }) - expect(resolveLanTrust(undefined, ['lab.internal'])).toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] }) - }) -}) - -describe('webSurfaceContextEnabled', () => { - it('defaults to enabled and honors an explicit complete-prompt disable', () => { - expect(webSurfaceContextEnabled(new Map())).toBe(true) - expect(webSurfaceContextEnabled(new Map([ - ['web-runtime', { config: { mode: 'production' } }], - ]))).toBe(true) - expect(webSurfaceContextEnabled(new Map([ - ['web-runtime', { config: { surfaceContext: false } }], - ]))).toBe(false) + expect(resolveLanTrust('127.0.0.1', ['lab.internal'])) + .toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] }) }) }) diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index eeb2aefc07..f8c5079f17 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -2,7 +2,7 @@ * Web runtime glue behavior: dist resolution through the bundle's own hook, * the frontend-static child claiming the fallback seat, the web-surface * prompt section and bash runtime variables, and URL-line printing with the - * launcher's LAN snapshot. + * runtime's bind-dependent LAN snapshot. */ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' @@ -14,6 +14,14 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver' import { apply, Config, internals } from '../src/index.ts' +vi.mock('node:os', async importOriginal => ({ + ...await importOriginal(), + networkInterfaces: () => ({ + lo0: [{ family: 'IPv4', internal: true, address: '127.0.0.1' }], + en0: [{ family: 'IPv4', internal: false, address: '192.168.1.5' }], + }), +})) + let dist: string | undefined afterEach(() => { @@ -36,9 +44,10 @@ function stageDist(): string { } /** A fake httpServer capturing the fallback seat and index taps. */ -function fakeHttpServer(): { server: HttpServerService; seat: () => unknown } { +function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server: HttpServerService; seat: () => unknown } { let fallback: unknown const server = { + host, port: 4567, registerFallback: (handler: unknown) => { fallback = handler @@ -49,6 +58,19 @@ function fakeHttpServer(): { server: HttpServerService; seat: () => unknown } { return { server, seat: () => fallback } } +/** Install the optional HMR row the runtime sequences before client discovery. */ +function provideHmrRow(ctx: Context, settle: () => Promise = async () => {}): string[] { + const updates: string[] = [] + ctx.provide('loader', { + entries: () => [{ + options: { id: 'client-hmr' }, + enableRuntime: async () => { updates.push('client-hmr') }, + }], + await: settle, + } as never) + return updates +} + interface BashContribution { name: string variables: Record @@ -59,7 +81,7 @@ describe('web-app runtime glue', () => { it('mounts dist serving, prompt section, bash variables, and prints the URL with the LAN snapshot', async () => { stageDist() const ctx = new Context() - const { server, seat } = fakeHttpServer() + const { server, seat } = fakeHttpServer('0.0.0.0') ctx.provide('httpServer', server) const contributions: BashContribution[] = [] ctx.provide('bashEnv', { @@ -68,15 +90,22 @@ describe('web-app runtime glue', () => { return () => {} }, } as never) + const enabledRows = provideHmrRow(ctx) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, lanAddresses: ['192.168.1.5'] })) + await apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] })) await ctx.plugin(SystemPrompt, { persona: '' }) // Settle the injected registrations. await new Promise(resolve => setTimeout(resolve, 0)) expect(seat()).toBeDefined() // frontend-static claimed the fallback + expect(enabledRows).toEqual(['client-hmr']) + expect(ctx.get('webRuntime')).toEqual({ + lanAddresses: ['192.168.1.5'], + trustedHosts: ['192.168.1.5', 'lab.internal'], + }) expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)') const assembly = await ctx.systemPrompt.assemble() + expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout') const section = assembly.sections.find(entry => entry.name === 'app:web-surface') expect(section?.text).toContain('http://127.0.0.1:4567') expect(section?.text).toContain('--dev') @@ -90,7 +119,7 @@ describe('web-app runtime glue', () => { const ctx = new Context() ctx.provide('httpServer', fakeHttpServer().server) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] })) + await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() @@ -111,11 +140,12 @@ describe('web-app runtime glue', () => { return () => {} }, } as never) - apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, lanAddresses: [] })) + await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) const assembly = await ctx.systemPrompt.assemble() expect(assembly.sections.some(entry => entry.name === 'app:web-surface')).toBe(false) + expect(assembly.sections.some(entry => entry.name === 'harness:source')).toBe(false) expect(contributions).toEqual([]) await ctx.fiber.dispose() }) @@ -125,13 +155,13 @@ describe('web-app runtime glue', () => { const ctx = new Context() ctx.provide('httpServer', fakeHttpServer().server) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') await ctx.fiber.dispose() }) - it('defers the URL line until Loader settlement and drops it when the server is gone', async () => { + it('defers the URL line until Loader settlement and drops it on failure or teardown', async () => { stageDist() // Settlement path: the line waits for loader.await() so supervisors can // RPC immediately after observing it. @@ -139,9 +169,9 @@ describe('web-app runtime glue', () => { settled.provide('httpServer', fakeHttpServer().server) let release: () => void const settlement = new Promise((resolve) => { release = resolve }) - settled.provide('loader', { await: () => settlement } as never) + provideHmrRow(settled, () => settlement) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() release!() @@ -149,6 +179,17 @@ describe('web-app runtime glue', () => { expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') await settled.fiber.dispose() + // Failed path: Loader reports the sibling failure; the app prints no URL + // for a process that is about to exit. + log.mockClear() + const failed = new Context() + failed.provide('httpServer', fakeHttpServer().server) + provideHmrRow(failed, async () => { throw new Error('boot failed') }) + await apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).not.toHaveBeenCalled() + await failed.fiber.dispose() + // Torn-down path: settlement resolves after the webserver is gone — no // line, no crash. log.mockClear() @@ -159,8 +200,8 @@ describe('web-app runtime glue', () => { await child let releaseTorn: () => void const tornSettlement = new Promise((resolve) => { releaseTorn = resolve }) - torn.provide('loader', { await: () => tornSettlement } as never) - apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + provideHmrRow(torn, () => tornSettlement) + await apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) await child.dispose() // the httpServer service goes away releaseTorn!() await new Promise(resolve => setTimeout(resolve, 0)) @@ -176,7 +217,7 @@ describe('web-app runtime glue', () => { const { server } = fakeHttpServer() Object.defineProperty(server, 'port', { get: () => undefined }) ctx.provide('httpServer', server) - apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] })) + await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing') diff --git a/packages/bundle/web-app/tsconfig.json b/packages/bundle/web-app/tsconfig.json index 6aadb534cb..195aa985e8 100644 --- a/packages/bundle/web-app/tsconfig.json +++ b/packages/bundle/web-app/tsconfig.json @@ -14,6 +14,15 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../boot/app-boot" + }, + { + "path": "../../boot/cmdline" + }, { "path": "../../host/frontend-static" }, diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index 0fab8db94b..e00a4d0e92 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -42,7 +42,7 @@ export const en: Record = { 'All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program.', presetMinimalName: 'Minimal mode', presetMinimalDescription: - 'Two-tool coding agent with only bash and str_replace_editor, for benchmarks and minimal reproductions.', + 'Two-tool coding agent with persistent bash and str_replace_editor.', presetCordisName: 'Creator mode', presetCordisDescription: 'Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance.', @@ -103,7 +103,7 @@ export const zh: Record = { presetCodeName: '代码模式', presetCodeDescription: '具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。', presetMinimalName: '极简模式', - presetMinimalDescription: '仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。', + presetMinimalDescription: '仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。', presetCordisName: '创造模式', presetCordisDescription: '用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。', duplicate: '复制', diff --git a/packages/core/agent-default-model/README.i18n.yaml b/packages/core/agent-default-model/README.i18n.yaml index 7835a159bc..c84c0ea271 100644 --- a/packages/core/agent-default-model/README.i18n.yaml +++ b/packages/core/agent-default-model/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-default-model/README.md -README.md: 98bc7d082e62a764868f8acd323c4617e9839e61 -README.zh.md: 807b612bd25e49aa318c13c8c8dc7595a6459080 +README.md: e86be7c37a1f994ca52f018144ef6a2409bd1eea +README.zh.md: 00250c28ef8c03d4b33fe1c1bfca138a022f6638 diff --git a/packages/core/agent-default-model/README.md b/packages/core/agent-default-model/README.md index 98bc7d082e..e86be7c37a 100644 --- a/packages/core/agent-default-model/README.md +++ b/packages/core/agent-default-model/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The deployment default used when an entry point creates an Agent that has no session-local model selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel`; direct entry points such as `dsh run` and Host-backed entry points such as ApiProxy read the same service instead of owning parallel provider/model defaults. +The deployment default used when an entry point creates an Agent that has no session-local model selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel`; direct entry points such as `dsh --profile headless` and Host-backed entry points such as ApiProxy read the same service instead of owning parallel provider/model defaults. The plugin config requires `{ provider, model }`. That composition entry is the base of the `agent-default-model` Settings section; a mounted settings provider layers the user's choice over it and changes are visible on the next `currentSelection()` read. `reasoningEffort` belongs to the Settings section but deliberately not to plugin config: a complete saved selection can clear an effort when the next selected model has none, while a composition value would be inherited again. diff --git a/packages/core/agent-default-model/README.zh.md b/packages/core/agent-default-model/README.zh.md index 807b612bd2..00250c28ef 100644 --- a/packages/core/agent-default-model/README.zh.md +++ b/packages/core/agent-default-model/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -该部署默认值供入口在创建尚无会话级模型选择的 Agent 时使用。`AgentDefaultModelService` 提供 `ctx.agentDefaultModel`;`dsh run` 这类直接入口与 ApiProxy 这类由 Host 支撑的入口读取同一服务,而不是分别持有平行的提供方/模型默认值。 +该部署默认值供入口在创建尚无会话级模型选择的 Agent 时使用。`AgentDefaultModelService` 提供 `ctx.agentDefaultModel`;`dsh --profile headless` 这类直接入口与 ApiProxy 这类由 Host 支撑的入口读取同一服务,而不是分别持有平行的提供方/模型默认值。 插件配置必须提供 `{ provider, model }`。该组合配置项构成 Settings 中 `agent-default-model` 分节的基础层;挂载的设置提供方在其上叠加用户选择,更改会在下一次调用 `currentSelection()` 时可见。`reasoningEffort` 属于该 Settings 分节,但特意不属于插件配置:完整保存的选择必须能在下一个选定模型没有推理(reasoning)强度时清除旧值,而组合配置值会再次被继承。 diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 4824e63758..caf2e560b9 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -113,27 +113,19 @@ describe('config-driven session id', () => { const config = { agents: [{ id: 'main', sessionId: SessionId('config-exact-reload'), provider: 'mock', model: 'mock' }] } const firstLoop = await ctx.plugin(AgentLoop, config) - let first: Agent | undefined - for (let i = 0; i < 50 && first === undefined; i++) { - await new Promise(resolve => setTimeout(resolve, 5)) - first = ctx.agents.get(SessionId('config-exact-reload')) - } - expect(first).toBeDefined() - first!.followup(createUserMessage({ content: [{ type: 'text', text: 'remember me' }], source: { kind: 'user' } })) - await waitForIdle(ctx, first!) + await expect.poll(() => ctx.agents.get(SessionId('config-exact-reload')), { timeout: 5_000 }).toBeDefined() + const first = ctx.agents.get(SessionId('config-exact-reload'))! + first.followup(createUserMessage({ content: [{ type: 'text', text: 'remember me' }], source: { kind: 'user' } })) + await waitForIdle(ctx, first) await firstLoop.dispose() const secondLoop = await ctx.plugin(AgentLoop, config) - let second: Agent | undefined - for (let i = 0; i < 50 && second === undefined; i++) { - await new Promise(resolve => setTimeout(resolve, 5)) - second = ctx.agents.get(SessionId('config-exact-reload')) - } - expect(second).toBeDefined() - expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me') - second!.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } })) - await waitForIdle(ctx, second!) - await ctx.sessions.flush(second!.session) + await expect.poll(() => ctx.agents.get(SessionId('config-exact-reload')), { timeout: 5_000 }).toBeDefined() + const second = ctx.agents.get(SessionId('config-exact-reload'))! + expect(JSON.stringify(second.session.deriveMessages())).toContain('remember me') + second.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } })) + await waitForIdle(ctx, second) + await ctx.sessions.flush(second.session) const loaded = await ctx.sessionPersistence.load(SessionId('config-exact-reload')) expect(loaded.events.filter(event => event.type === 'turn/start')).toHaveLength(2) @@ -426,18 +418,14 @@ describe('config-driven session id', () => { await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')])) - // The deferred resume runs on a microtask after the backend is available. - let resumed: Agent | undefined - for (let i = 0; i < 50 && !resumed; i++) { - await new Promise(r => setTimeout(r, 5)) - resumed = ctx2.agents.get(SessionId('sticky-1')) - } - expect(resumed).toBeDefined() + // The deferred resume runs after the backend is available. + await expect.poll(() => ctx2.agents.get(SessionId('sticky-1')), { timeout: 5_000 }).toBeDefined() + const resumed = ctx2.agents.get(SessionId('sticky-1'))! // The live session id IS the resumed id (NOT a fresh ${id}-session-), // and the prior turn's user message is in the derived history. - expect(resumed!.id).toBe(SessionId('sticky-1')) - expect(resumed!.session.id).toBe('sticky-1') - const derived = resumed!.session.deriveMessages() + expect(resumed.id).toBe(SessionId('sticky-1')) + expect(resumed.session.id).toBe('sticky-1') + const derived = resumed.session.deriveMessages() expect(JSON.stringify(derived)).toContain('remember me') await ctx2.fiber.dispose() }) diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml index 7d4e8f07bb..b1f068fa39 100644 --- a/packages/core/system-prompt/README.i18n.yaml +++ b/packages/core/system-prompt/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/system-prompt/README.md -README.md: 13b05bfcd19212ade42f22ece455871d022e6260 -README.zh.md: 0f9e7a2358134018975db1bc3c6b7206a274b3ec +README.md: cedda783d549633f5be9765a9a074e968d99500d +README.zh.md: 41729cdd1cfe6ebbd86f38c15bab5c50bd6ff7d2 diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 13b05bfcd1..cedda783d5 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -16,19 +16,19 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem ### Public API -- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber. +- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. A `complete: true` section becomes the exact complete prompt after the assembly waterfall; more than one effective complete section rejects assembly. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber. - `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber. - `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber. -- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform waterfall. Runs through the scope-filtered `system-prompt/assemble` waterfall and returns its authoritative result. An optional `context.signal` explicitly controls this assembly request; providers and listeners may cooperate with it but must not retain it for another turn. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name. +- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform waterfall. Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores an effective complete section as the sole prompt section. An optional `context.signal` explicitly controls this assembly request; providers and listeners may cooperate with it but must not retain it for another turn. Rejects for multiple complete sections, when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name. ### Live events -`system-prompt/assemble` is authoritative; listeners that replace entries must preserve any active Code Mode or structured-output protocol. Use [`ToolRegistry.restrict()`](../tools/README.md) when filtering must stay aligned across presentation, lookup, and execution. Registry-change notifications are unfiltered. The generated region of [system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) owns signatures and dispatch contracts. +`system-prompt/assemble` is authoritative for ordinary sections; a complete section is the final prompt constraint applied after the waterfall. Listeners that replace entries must preserve any active Code Mode or structured-output protocol. Use [`ToolRegistry.restrict()`](../tools/README.md) when filtering must stay aligned across presentation, lookup, and execution. Registry-change notifications are unfiltered. The generated region of [system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) owns signatures and dispatch contracts. ### Key types - `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) and `signal?: AbortSignal` (the explicit request control capability) here, while `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent, signal)`). Providers must tolerate absent fields because a bare `assemble()` carries an empty, scope-less, signal-less context. `signal` is a request value, not part of the ambient Agent execution frame. -- `PromptSection` — `{ name, order, text }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`. +- `PromptSection` — `{ name, order, text, complete? }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`. One effective `complete` section suppresses all other sections after cooperative assembly. - `PromptAssembly` — `{ sections: AssembledSection[], tools: ToolSchema[], variables: Record }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field. - `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned. @@ -39,7 +39,7 @@ Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `Asse - Section providers: tool packages own their cross-call guidance (`tool:bash`, `tool:read`, …); this plugin owns `harness:identity` and `deployment:persona`. - Variable providers: the agent loop registers `model` and `cwd`; any plugin can register the facts it owns (a future `date`, git state, …). - Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically. -- The [`system-prompt/assemble` waterfall](#live-events): cooperatively mutate or replace the assembly per caller. +- The [`system-prompt/assemble` waterfall](#live-events): cooperatively mutate or replace the assembly per caller before any complete-section constraint is enforced. Design rationale: [the prompt-variables Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). @@ -49,7 +49,7 @@ Design rationale: [the prompt-variables Agent Note](../../../.agents/notes/imple #### What the model sees -By default every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. `includeHarnessIdentity: false` omits only that fixed opener for a deployment that owns the complete compatibility persona. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The final `system-prompt/assemble` waterfall result is authoritative, so an expert listener's changes determine the delivered prompt and tool schemas. +By default every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. `includeHarnessIdentity: false` omits only that fixed opener. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The `system-prompt/assemble` waterfall determines the delivered prompt and tool schemas unless one effective section declares itself complete; that exact section then becomes the whole system prompt while the waterfall's contexts, tools, and variables remain. ##### Harness identity diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md index 0f9e7a2358..41729cdd1c 100644 --- a/packages/core/system-prompt/README.zh.md +++ b/packages/core/system-prompt/README.zh.md @@ -16,21 +16,21 @@ ### 公开 API -- `ctx.systemPrompt.section(section: PromptSection): () => void`:贡献一个段。层由调用上下文的作用域决定:`agent.ctx` 只为该 agent 贡献,并在该处遮蔽同名全局段。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 一并 dispose(资源释放)。 +- `ctx.systemPrompt.section(section: PromptSection): () => void`:贡献一个段。层由调用上下文的作用域决定:`agent.ctx` 只为该 agent 贡献,并在该处遮蔽同名全局段。一个 `complete: true` 段会在组装 waterfall 之后成为精确的完整提示词;有效 complete 段超过一个时,组装会被拒绝。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 一并 dispose(资源释放)。 - `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void`:贡献工具 schema;每次组装时使用该次组装的上下文求值。`ToolProviderResult` = `{ schemas, knownNames? }`:`schemas` 是限制后的可见集合;`knownNames` 是限制前由 `toolOrder` 使用的全集。提供方不得返回名为 `TOOL_ORDER_REST` 的 schema。带作用域提供方只在其作用域的组装中查询。随调用 fiber 一并 dispose。 - `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void`:贡献提示词变量,在段文本中以 `{{name}}` 引用。带作用域变量会为该 agent 遮蔽同名全局变量。同层重复或无法引用的名称会抛出;`undefined` 表示「本次组装没有值」。随调用 fiber 一并 dispose。 -- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise`:为一个调用方组装提示词:将全局层与 `context.scope` 的层合并,并在变换 waterfall 前分离工具 schema。它经过按作用域筛选的 `system-prompt/assemble` waterfall,并返回其权威结果。可选的 `context.signal` 显式控制本次组装请求;提供方与监听器可以配合该信号,但不得将它保留给另一轮次。当已配置的 `toolOrder` 指名提供方 `knownNames` 全集以外的工具,或提供方返回保留的其余项名称时,调用会被拒绝。 +- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise`:为一个调用方组装提示词:将全局层与 `context.scope` 的层合并,并在变换 waterfall 前分离工具 schema。它经过按作用域筛选的 `system-prompt/assemble` waterfall,之后将一个有效的 complete 段恢复为唯一的提示词段落。可选的 `context.signal` 显式控制本次组装请求;提供方与监听器可以配合该信号,但不得将它保留给另一轮次。存在多个 complete 段、已配置的 `toolOrder` 指名提供方 `knownNames` 全集以外的工具,或提供方返回保留的其余项名称时,调用会被拒绝。 ### 实时事件 -`system-prompt/assemble` 是权威来源;替换条目的监听器必须保留任何活动 Code Mode 或结构化输出协议。筛选需要在呈现、查找与执行之间保持一致时,应使用 [`ToolRegistry.restrict()`](../tools/README.md)。注册表变更通知不经过筛选。[system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) 的生成区块拥有签名与分发约定。 +`system-prompt/assemble` 对普通段落具有权威性;complete 段是在 waterfall 之后应用的最终提示词约束。替换条目的监听器必须保留任何活动 Code Mode 或结构化输出协议。筛选需要在呈现、查找与执行之间保持一致时,应使用 [`ToolRegistry.restrict()`](../tools/README.md)。注册表变更通知不经过筛选。[system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) 的生成区块拥有签名与分发约定。 ### 关键类型 - `AssembleContext`:说明一次 `assemble()` 调用的用途。它可通过合并扩展;此处声明 `scope?: ScopeKey`(层选择器)与 `signal?: AbortSignal`(显式请求控制能力),而 `dsh-agent` 声明 `agent?: Agent`(类型化 DX 字段;绝不能在没有 `scope` 时设置,应使用 `assembleContextFor(agent, signal)`)。提供方必须容忍字段缺席,因为裸 `assemble()` 携带的是无作用域、无信号的空上下文。`signal` 是请求值,不是环境 Agent 执行 frame 的一部分。 -- `PromptSection`:`{ name, order, text }`。各段按 `order` 升序拼接。顺序区间:`-100` 是 harness 身份,`0` 是部署 persona,工具引导使用 `100–199`。 +- `PromptSection`:`{ name, order, text, complete? }`。各段按 `order` 升序拼接。顺序区间:`-100` 是 harness 身份,`0` 是部署 persona,工具引导使用 `100–199`。协作式组装完成后,一个有效的 `complete` 段会抑制其他所有段落。 - `PromptAssembly`:`{ sections: AssembledSection[], tools: ToolSchema[], variables: Record }`。段文本到达时已解析,但尚未插值;`variables` 包含对上下文解析后的每个已注册变量。工具 schema 按设计属于组装结果:「模型获知自己能做什么」是一个连贯整体,尽管适配器把 schema 作为独立 wire 字段传输。 - `renderPrompt(assembly)`:插值每个段中的 `{{variable}}` 引用,删除空段,并用空行连接。严格规则:未知引用(使用 `Object.hasOwn` 查找,因此 `{{constructor}}` 等原型名称未知)、已注册但无值的引用、格式错误的完整 `{{…}}` 组,或一个起始 `{{` 没有打开完整组、但后面仍有 `}}`(`{{{model}}}`),都会抛出;明确失败胜过交付格式错误的提示词。孤立的 `{{` 如果后面任何位置都没有 `}}`,会按字面量通过;替换值绝不再次扫描。 @@ -41,7 +41,7 @@ - 段提供方:工具包拥有跨调用引导(`tool:bash`、`tool:read` 等);此插件拥有 `harness:identity` 与 `deployment:persona`。 - 变量提供方:agent loop(智能体循环)注册 `model` 与 `cwd`;任何插件都可以注册自己拥有的事实(未来的 `date`、git 状态等)。 - 工具 schema 提供方:`ToolRegistry` 自动将自身注册为工具提供方。 -- [`system-prompt/assemble` waterfall](#live-events):按调用方协作式修改或替换组装结果。 +- [`system-prompt/assemble` waterfall](#live-events):按调用方协作式修改或替换组装结果,之后再实施 complete 段约束。 设计原理:[提示词变量 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)。 @@ -51,7 +51,7 @@ #### 模型看到的内容 -默认情况下,每次组装都从下方 harness 身份开始,然后在严格变量插值后追加已配置 persona 与有序插件段。`includeHarnessIdentity: false` 仅为拥有完整兼容 persona 的部署省略这个固定开场白。空段会消失;带作用域的段和变量可以为一个 agent 遮蔽全局项。最终 `system-prompt/assemble` waterfall 结果是权威来源,因此专家监听器的变更决定交付的提示词与工具 schema。 +默认情况下,每次组装都从下方 harness 身份开始,然后在严格变量插值后追加已配置 persona 与有序插件段。`includeHarnessIdentity: false` 仅省略这个固定开场白。空段会消失;带作用域的段和变量可以为一个 agent 遮蔽全局项。`system-prompt/assemble` waterfall 决定交付的提示词与工具 schema,除非一个有效段声明自身为 complete;此时,该确切段落会成为完整的系统提示词,而 waterfall 得到的上下文、工具和变量保持不变。 ##### Harness 身份 diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index d4f5c52db0..45a613cd8f 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -21,7 +21,9 @@ declare module '@deepseek-ai/cordis' { * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners * receive only that scope's assemblies. The returned value is authoritative. * A supplied signal controls only this explicit assembly request and must not - * be retained to control later turns. + * be retained to control later turns. A registered complete section is + * restored after this waterfall, so listeners cannot add to or replace + * that scope's system prompt. * @param assembly - the mutable assembly built from registered providers. * @param context - the caller's per-assembly context. * @mode waterfall @@ -63,6 +65,13 @@ export interface PromptSection { * interpolated later, by {@link renderPrompt}. */ readonly text: string | ((context: AssembleContext) => string) + /** + * Treat this contribution as the complete system prompt. Assembly still + * runs the cooperative waterfall so tools, contexts, and variables can be + * resolved, then restores this exact section as the sole prompt section. + * More than one effective complete section makes assembly fail. + */ + readonly complete?: boolean } /** Dynamic model context materialized as a durable user-role snapshot. */ @@ -428,9 +437,11 @@ export class SystemPrompt extends Service { /** * Assemble global and scoped providers, detach tool parameters, apply * canonical ordering, then run the assembly waterfall. Scoped sections and - * variables shadow globals; the returned waterfall value is authoritative. + * variables shadow globals. The returned waterfall value is authoritative + * except that an effective complete section is restored afterwards as the + * sole prompt section. * @param context - the optional scope and plugin-defined assembly fields. - * @returns the authoritative post-waterfall assembly. + * @returns the post-waterfall assembly with any complete prompt enforced. */ // Keep configuration failures on the declared asynchronous error path. async assemble(context: AssembleContext = {}): Promise { @@ -467,13 +478,23 @@ export class SystemPrompt extends Service { collected.push(...schemas) for (const name of acceptedKnownNames) knownNames.add(name) } - const assembly: PromptAssembly = { - sections: [...sectionByName.values()] - .sort((a, b) => a.order - b.order) - .map(section => ({ + const sectionDefinitions = [...sectionByName.values()].sort((a, b) => a.order - b.order) + const completeSections = sectionDefinitions.filter(section => section.complete === true) + if (completeSections.length > 1) { + throw new Error(`multiple complete prompt sections are active: ${completeSections.map(section => JSON.stringify(section.name)).join(', ')}`) + } + let completeSection: AssembledSection | undefined + const sections = sectionDefinitions + .map((section) => { + const assembled = { name: section.name, text: typeof section.text === 'function' ? section.text(context) : section.text, - })), + } + if (section.complete === true) completeSection = { ...assembled } + return assembled + }) + const assembly: PromptAssembly = { + sections, contexts: [...contextByName.values()] .sort((a, b) => a.order - b.order) .map(entry => ({ @@ -483,10 +504,12 @@ export class SystemPrompt extends Service { tools: orderTools(collected, this.toolOrder, knownNames), variables, } - return this.ctx.waterfall( + const transformed = await this.ctx.waterfall( scopeTarget(this, scope), 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly), ) + if (completeSection === undefined) return transformed + return { ...transformed, sections: [completeSection] } } } diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index 96592565ce..8ab6c37ea7 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -264,6 +264,34 @@ describe('SystemPrompt', () => { expect(assembly.sections).toHaveLength(0) }) + it('restores one complete section after the assembly waterfall', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + ctx.systemPrompt.section({ name: 'complete', order: 10, text: 'Exact prompt.', complete: true }) + ctx.systemPrompt.section({ name: 'extra', order: 20, text: 'extra' }) + ctx.on('system-prompt/assemble', async (assembly, _context, next) => { + const complete = assembly.sections.find(section => section.name === 'complete') + if (complete === undefined) throw new Error('complete section missing before waterfall') + complete.text = 'mutated' + assembly.sections.push({ name: 'late', text: 'late' }) + return next() + }, { prepend: true }) + + expect((await ctx.systemPrompt.assemble()).sections).toEqual([ + { name: 'complete', text: 'Exact prompt.' }, + ]) + }) + + it('rejects multiple effective complete sections', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + ctx.systemPrompt.section({ name: 'first', order: 10, text: 'first', complete: true }) + ctx.systemPrompt.section({ name: 'second', order: 20, text: 'second', complete: true }) + + await expect(ctx.systemPrompt.assemble()) + .rejects.toThrow('multiple complete prompt sections are active: "first", "second"') + }) + it('assembles snapshots so one-step mutations do not leak into future assemblies', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) diff --git a/packages/examples/README.i18n.yaml b/packages/examples/README.i18n.yaml index 2270947eea..7cd26439c2 100644 --- a/packages/examples/README.i18n.yaml +++ b/packages/examples/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/examples/README.md -README.md: d8369b1e263e72c7b0ac1687c3b14a5d723ab944 -README.zh.md: acb402e925f692beaacbe0ab4e029691d664dbe8 +README.md: 0048d14ec49776f036d841bbc0579a6867e513bb +README.zh.md: 1b7acc5646071f4fc21e9238e4e440c192a1e82a diff --git a/packages/examples/README.md b/packages/examples/README.md index d8369b1e26..0048d14ec4 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -10,7 +10,7 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling | [`acp-demo/`](acp-demo/README.md) | `@deepseek-ai/dsh-acp-demo` | ACP automation application bundle | | [`jsonrpc-demo/`](jsonrpc-demo/README.md) | `@deepseek-ai/dsh-jsonrpc-demo` | External-config JSON-RPC runtime | -`agent-spine-demo` is the shared bundle; `acp-demo` adds its automation entry point, while `jsonrpc-demo` boots a deployment-owned plugin tree. Product one-shot execution belongs to `dsh run`; no package in this directory provides it. +`agent-spine-demo` is the shared bundle; `acp-demo` adds its automation entry point, while `jsonrpc-demo` boots a deployment-owned plugin tree. Product one-shot execution belongs to `dsh --profile headless`; no package in this directory provides it. These packages are not product API. Product seams and entry points remain in their owning groups; demo bundles select concrete compositions. diff --git a/packages/examples/README.zh.md b/packages/examples/README.zh.md index acb402e925..1b7acc5646 100644 --- a/packages/examples/README.zh.md +++ b/packages/examples/README.zh.md @@ -10,7 +10,7 @@ | [`acp-demo/`](acp-demo/README.md) | `@deepseek-ai/dsh-acp-demo` | ACP(Agent Client Protocol)自动化应用组合包 | | [`jsonrpc-demo/`](jsonrpc-demo/README.md) | `@deepseek-ai/dsh-jsonrpc-demo` | 外部配置 JSON-RPC 运行时 | -`agent-spine-demo` 是共享组合包;`acp-demo` 添加自动化入口,`jsonrpc-demo` 则启动由部署方拥有的插件树。产品单次执行由 `dsh run` 提供;本目录没有任何包提供该功能。 +`agent-spine-demo` 是共享组合包;`acp-demo` 添加自动化入口,`jsonrpc-demo` 则启动由部署方拥有的插件树。产品单次执行由 `dsh --profile headless` 提供;本目录没有任何包提供该功能。 这些包不是产品 API。产品 seam 与产品入口仍位于各自的归属组;演示组合包选择具体组合。 diff --git a/packages/examples/jsonrpc-demo/README.i18n.yaml b/packages/examples/jsonrpc-demo/README.i18n.yaml index c47938ce3b..ae8f55a8cd 100644 --- a/packages/examples/jsonrpc-demo/README.i18n.yaml +++ b/packages/examples/jsonrpc-demo/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/examples/jsonrpc-demo/README.md -README.md: fff8e78698cd3d6320606084ef5c533be7c52633 -README.zh.md: 75382b97ea1837cf1415e8a7f5004206596e168c +README.md: 40ced3ee1fe2d3eac69b82501d417130267d7634 +README.zh.md: 451bdf7428f8265750082af240418d4653bb8595 diff --git a/packages/examples/jsonrpc-demo/README.md b/packages/examples/jsonrpc-demo/README.md index fff8e78698..40ced3ee1f 100644 --- a/packages/examples/jsonrpc-demo/README.md +++ b/packages/examples/jsonrpc-demo/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../../scaffold/server/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. The published bin is `dsh-jsonrpc-agent`, and `lib/bin.js` also ships as the `dsh-jsonrpc-agent-pkg` [single-executable runtime](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) used by the Python SDK. +Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../../scaffold/server/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. The published `dsh-jsonrpc-agent` bin resolves bare plugins from the configuration project. The Python SDK's `dsh-jsonrpc-agent-pkg` [single-executable runtime](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) uses `lib/packaged-bin.js` instead: packaged bare plugins resolve from its closed runtime tree, while relative plugins remain configuration-relative. ## Config discovery diff --git a/packages/examples/jsonrpc-demo/README.zh.md b/packages/examples/jsonrpc-demo/README.zh.md index 75382b97ea..451bdf7428 100644 --- a/packages/examples/jsonrpc-demo/README.zh.md +++ b/packages/examples/jsonrpc-demo/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -只包含 bin 的应用,启动外部 `cordis.yml`;其 [`jsonrpc`](../../scaffold/server/README.md) 入口通过按换行分隔的 stdio 为 SDK 客户端提供服务。配置负责组合主干、后端和服务插件。发布的 bin 名为 `dsh-jsonrpc-agent`,`lib/bin.js` 还会作为 Python SDK 使用的 `dsh-jsonrpc-agent-pkg` [单文件可执行运行时](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)交付。 +只包含 bin 的应用,启动外部 `cordis.yml`;其 [`jsonrpc`](../../scaffold/server/README.md) 入口通过按换行分隔的 stdio 为 SDK 客户端提供服务。配置负责组合主干、后端和服务插件。发布的 `dsh-jsonrpc-agent` bin 从配置项目解析裸插件。Python SDK 的 `dsh-jsonrpc-agent-pkg` [单文件可执行运行时](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)改用 `lib/packaged-bin.js`:已打包的裸插件从封闭运行时包树解析,相对插件仍以配置目录为基准。 ## 配置发现 diff --git a/packages/examples/jsonrpc-demo/package.json b/packages/examples/jsonrpc-demo/package.json index 774028c914..49aec73d1f 100644 --- a/packages/examples/jsonrpc-demo/package.json +++ b/packages/examples/jsonrpc-demo/package.json @@ -22,6 +22,10 @@ "types": "./lib/types/bin.d.ts", "default": "./lib/bin.js" }, + "./packaged-bin": { + "types": "./lib/types/packaged-bin.d.ts", + "default": "./lib/packaged-bin.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -29,6 +33,7 @@ "lib/index.js", "lib/invariant.js", "lib/bin.js", + "lib/packaged-bin.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", diff --git a/packages/examples/jsonrpc-demo/src/bin.ts b/packages/examples/jsonrpc-demo/src/bin.ts index ad17709efc..532c1ba1b9 100644 --- a/packages/examples/jsonrpc-demo/src/bin.ts +++ b/packages/examples/jsonrpc-demo/src/bin.ts @@ -1,52 +1,11 @@ #!/usr/bin/env node /** - * Boots an external `cordis.yml`; its `@deepseek-ai/dsh-jsonrpc` entry serves - * newline-delimited JSON-RPC on stdio. `$DSH_CORDIS_CONFIG` wins over `argv[2]`; - * empty or missing paths exit 1, with no default config or `DSH_SNAPSHOT` mode. - * App-boot owns env loading, Loader guards, and settled-tree startup. - * stdin EOF and SIGTERM dispose the root context and exit 0; SIGINT exits 130. - * Protocol `shutdown` belongs to the server plugin. Stdout is reserved for frames. + * Generic JSON-RPC agent bin. External configurations own their bare plugin + * packages; the packaged runtime uses `packaged-bin.ts` instead. * * @module @deepseek-ai/dsh-jsonrpc-demo/bin */ -import { existsSync } from 'node:fs' -import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { runJsonrpcAgent } from './runner.ts' -const NAME = 'dsh-jsonrpc-agent' - -/* v8 ignore start -- composition over tested app-boot/jsonrpc and executable acceptance paths */ -installFailLoud(NAME) -loadEnv(NAME) - -// Env wins over argv; empty values are absent. External config defines the deployment. -const fromEnv = process.env['DSH_CORDIS_CONFIG'] -const fromArgv = process.argv[2] -const requested = fromEnv !== undefined && fromEnv !== '' - ? fromEnv - : fromArgv !== undefined && fromArgv !== '' ? fromArgv : undefined -const configPath = requested === undefined ? undefined : resolveConfigPath(requested, undefined) -if (configPath === undefined || !existsSync(configPath)) { - process.stderr.write( - `usage: ${NAME} (or set DSH_CORDIS_CONFIG=, which wins); the config is required — there is no built-in fallback\n`, - ) - process.exit(1) -} - -const ctx = await boot(NAME, configPath) -let exiting = false - -async function disposeAndExit(code: number): Promise { - if (exiting) return - exiting = true - try { - await ctx.fiber.dispose() - } finally { - process.exit(code) - } -} - -process.stdin.on('end', () => { void disposeAndExit(0) }) -process.on('SIGTERM', () => { void disposeAndExit(0) }) -process.on('SIGINT', () => { void disposeAndExit(130) }) -/* v8 ignore stop */ +await runJsonrpcAgent() diff --git a/packages/examples/jsonrpc-demo/src/index.ts b/packages/examples/jsonrpc-demo/src/index.ts index d4a4017f62..eb85517431 100644 --- a/packages/examples/jsonrpc-demo/src/index.ts +++ b/packages/examples/jsonrpc-demo/src/index.ts @@ -1,7 +1,8 @@ /** - * Bin-only app package: `bin.ts` discovers an external `cordis.yml` and owns - * process exit. This module exports no composition plugin; the config chooses - * whether to load the {@link @deepseek-ai/dsh-jsonrpc} serving plugin. + * Bin-only app package: its generic and packaged entries discover an external + * `cordis.yml` and own process exit. This module exports no composition plugin; + * the config chooses whether to load the + * {@link @deepseek-ai/dsh-jsonrpc} serving plugin. * * @module @deepseek-ai/dsh-jsonrpc-demo */ diff --git a/packages/examples/jsonrpc-demo/src/packaged-bin.ts b/packages/examples/jsonrpc-demo/src/packaged-bin.ts new file mode 100644 index 0000000000..4ad41a2ee4 --- /dev/null +++ b/packages/examples/jsonrpc-demo/src/packaged-bin.ts @@ -0,0 +1,12 @@ +#!/usr/bin/env node +/** + * Closed-runtime JSON-RPC agent bin. Bare plugins resolve from the installed + * runtime closure while relative plugins remain configuration-relative. + * + * @module @deepseek-ai/dsh-jsonrpc-demo/packaged-bin + */ + +import { runJsonrpcAgent } from './runner.ts' + +/* v8 ignore next -- exercised through the built Python runtime carriers */ +await runJsonrpcAgent(import.meta.url) diff --git a/packages/examples/jsonrpc-demo/src/runner.ts b/packages/examples/jsonrpc-demo/src/runner.ts new file mode 100644 index 0000000000..25d17481e5 --- /dev/null +++ b/packages/examples/jsonrpc-demo/src/runner.ts @@ -0,0 +1,55 @@ +/** + * Shared process lifecycle for the generic and closed-runtime JSON-RPC bins. + * + * @module @deepseek-ai/dsh-jsonrpc-demo/runner + */ + +import { existsSync } from 'node:fs' +import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' + +/* v8 ignore start -- composition over tested app-boot/jsonrpc and executable acceptance paths */ +const NAME = 'dsh-jsonrpc-agent' + +/** + * Boot the explicitly selected external configuration and own process exit. + * @param bareModuleBaseUrl - optional installed-runtime base for bare plugins; + * omit it when the configuration project owns its plugin packages. + * @returns after process handlers are installed; process lifetime then belongs + * to stdin and signal events. + */ +export async function runJsonrpcAgent(bareModuleBaseUrl?: string): Promise { + installFailLoud(NAME) + loadEnv(NAME) + + // Env wins over argv; empty values are absent. External config defines the deployment. + const fromEnv = process.env['DSH_CORDIS_CONFIG'] + const fromArgv = process.argv[2] + const requested = fromEnv !== undefined && fromEnv !== '' + ? fromEnv + : fromArgv !== undefined && fromArgv !== '' ? fromArgv : undefined + const configPath = requested === undefined ? undefined : resolveConfigPath(requested, undefined) + if (configPath === undefined || !existsSync(configPath)) { + process.stderr.write( + `usage: ${NAME} (or set DSH_CORDIS_CONFIG=, which wins); the config is required — there is no built-in fallback\n`, + ) + process.exit(1) + } + + const ctx = await boot(NAME, configPath, undefined, undefined, bareModuleBaseUrl) + let exiting = false + + async function disposeAndExit(code: number): Promise { + if (exiting) return + exiting = true + try { + await ctx.fiber.dispose() + } finally { + process.exit(code) + } + } + + process.stdin.on('end', () => { void disposeAndExit(0) }) + process.on('SIGTERM', () => { void disposeAndExit(0) }) + process.on('SIGINT', () => { void disposeAndExit(130) }) +} +/* v8 ignore stop */ diff --git a/packages/examples/jsonrpc-demo/tsdown.config.ts b/packages/examples/jsonrpc-demo/tsdown.config.ts index a8864a84a9..3609ebbc93 100644 --- a/packages/examples/jsonrpc-demo/tsdown.config.ts +++ b/packages/examples/jsonrpc-demo/tsdown.config.ts @@ -1,15 +1,21 @@ import { defineConfig } from 'tsdown' -/** - * Build the doc-only module and CLI entry; `tsc -b` supplies declarations. - */ -export default defineConfig({ - entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/bin.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, -}) +/** Builds each published entry as a self-contained file admitted by the package whitelist. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, + { + entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, + { + entry: ['lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, + { + entry: ['lib/types/packaged-bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, +]) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 372eabcad2..34e38aaa84 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 5bc4dfa47e3c512eeffb7c0bc80151a7c4091512 -README.zh.md: 891c3fa91a09aeee5d8e69feb8b025970b768088 +README.md: 02559df3666397732f6c4009040c78722be29076 +README.zh.md: 5d348d6b97f048a05aaf8a32fffb22d3a281049d diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 5bc4dfa47e..02559df366 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -56,7 +56,7 @@ The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-pag ## Carrier layer (`/client` + root) -`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh run` is a direct core entry point and does not mount this package. +`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh --profile headless` is a direct core entry point and does not mount this package. ## Model Experience diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 891c3fa91a..5d348d6b97 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -56,7 +56,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr ## 载体层(`/client` + 根路径) -`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,仍是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供需要该路径的调用方和载体测试使用。产品的 `dsh run` 是直连 core 的入口,不挂载本包。 +`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,仍是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供需要该路径的调用方和载体测试使用。产品的 `dsh --profile headless` 是直连 core 的入口,不挂载本包。 ## 模型体验 diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index d1bfaba00d..34eb306913 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -354,11 +354,11 @@ describe('agentPreset.select', () => { }) it('records the switch in the log, and the list reads it back', async () => { - const { api, ctx } = await harness(['standard', 'core-web']) + const { api, ctx } = await harness(['standard', 'minimal']) await api.sessions.create(request({ sessionId: SessionId('sel-log'), agentPreset: 'standard' })) await api.agentPresets.select( - request({ sessionId: SessionId('sel-log'), agentPreset: 'core-web' })) + request({ sessionId: SessionId('sel-log'), agentPreset: 'minimal' })) // The header is written once at creation, so the switch lives in the log — // this is what a restart replays and what every projection resolves from. @@ -366,11 +366,11 @@ describe('agentPreset.select', () => { const session = ctx.sessions.get(SessionId('sel-log')) if (session === undefined) throw new Error('unreachable') expect(session.header.agentPreset).toBe('standard') - expect(resolveSessionPreset(session)).toBe('core-web') + expect(resolveSessionPreset(session)).toBe('minimal') const listed = await api.sessions.list(request({})) if (!listed.result.ok) throw new Error('unreachable') expect(listed.result.value.items.find(item => item.sessionId === 'sel-log')?.agentPreset) - .toBe('core-web') + .toBe('minimal') }) it('frames the committed switch so clients can drop that session\'s catalogs', async () => { @@ -405,14 +405,14 @@ describe('agentPreset.select', () => { }) it('serializes two concurrent selects on one session', async () => { - const { api, ctx } = await harness(['standard', 'core-web']) + const { api, ctx } = await harness(['standard', 'minimal']) await api.sessions.create(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })) // Both pass the blank check; unserialized, the second unmount finds no // record because the first already removed it, and two compositions end up // in one agent layer. The client's busy flag is not enforcement. const [first, second] = await Promise.all([ - api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'core-web' })), + api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'minimal' })), api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })), ]) @@ -635,7 +635,7 @@ describe('skills over the layered host registry', () => { }) it('resolves a cold session to its recorded preset standing key', async () => { - const { api, ctx } = await harness(['standard', 'core-web']) + const { api, ctx } = await harness(['standard', 'minimal']) const seen: unknown[] = [] ctx.provide('skills', { list: (options: { scope?: unknown }) => { @@ -643,12 +643,12 @@ describe('skills over the layered host registry', () => { return Promise.resolve([]) }, } as never) - ctx.sessions.create(SessionId('h2'), { meta: { cwd: '/workspace/cold', agentPreset: 'core-web' } }) + ctx.sessions.create(SessionId('h2'), { meta: { cwd: '/workspace/cold', agentPreset: 'minimal' } }) const response = await api.skills.list(request({ sessionId: SessionId('h2') })) expect(response.result).toMatchObject({ ok: true, value: { skills: [] } }) - expect(seen).toEqual([standingKeys.get('core-web')]) + expect(seen).toEqual([standingKeys.get('minimal')]) }) it('serves the global view when the roster no longer supplies the recorded preset', async () => { @@ -671,8 +671,8 @@ describe('skills over the layered host registry', () => { describe('session.history presenter scope', () => { it('asks the roster for the RECORDED preset\'s standing key on a cold read', async () => { - const { api } = await harness(['standard', 'core-web']) - await api.sessions.create(request({ sessionId: SessionId('p1'), agentPreset: 'core-web' })) + const { api } = await harness(['standard', 'minimal']) + await api.sessions.create(request({ sessionId: SessionId('p1'), agentPreset: 'minimal' })) // Cold: creation registered a live agent in this harness, so simulate the // cold path by asking for a session only persistence knows... the harness // has no persistence, so read the live one and assert no roster query. diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index ecd2a5a6c0..d74f29d955 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md -README.md: 632fc7828313a932512cb59a924050005067a008 -README.zh.md: 41dfab1af81149a221cb333a5613ab0a2d899899 +README.md: e31dc8e666096baf6fd6b2cf9407110c7678c6e1 +README.zh.md: cf7e24bb0f256623beec1b86c70473a70be03cb5 diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index 632fc78283..e31dc8e666 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -73,7 +73,7 @@ A preset may publish display text in an optional `preset.yml` beside its composi ```yaml name: 极简模式 -description: 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 +description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。 ``` It carries display text ONLY. `id` is the directory name and `trust` comes from the root the preset was discovered under, so neither is writable here — otherwise a locally authored preset could name itself into the shipped set. It is a separate file because the composition is a top-level list of plugin rows: YAML cannot carry sibling keys beside it, and a fake metadata row would hand the Loader something to load. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 41dfab1af8..cf7e24bb0f 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -73,7 +73,7 @@ preset 可以在组装文件旁的可选 `preset.yml` 里发布展示文本: ```yaml name: 极简模式 -description: 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 +description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。 ``` 它**只**承载展示文本。`id` 是目录名,`trust` 取自 preset 被发现时所在的根目录,两者都不可写在这里——否则本地创作的 preset 就能把自己命名进随附集合。之所以是独立文件:组装是插件行的顶层列表,YAML 无法在其旁携带同级键,而伪造一个元信息行等于递给 Loader 一个要加载的东西。 diff --git a/packages/preset/persona/README.i18n.yaml b/packages/preset/persona/README.i18n.yaml index c4573b49f8..f40850a6c9 100644 --- a/packages/preset/persona/README.i18n.yaml +++ b/packages/preset/persona/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/persona/README.md -README.md: 789776b32d907f7d217accccbca5508f88de0ed1 -README.zh.md: 4e28d75bbd4fd22b77a0fa3b18c5f19df08588d8 +README.md: 742141e65fa8d50b89e6b74e6d21aa8c5bfe98cd +README.zh.md: add106adb5b81e45d8c6929a9a0f98b5c0072a01 diff --git a/packages/preset/persona/README.md b/packages/preset/persona/README.md index 789776b32d..742141e65f 100644 --- a/packages/preset/persona/README.md +++ b/packages/preset/persona/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The agent persona as a composable row. One config field, one prompt section. +The agent persona as a composable row. It can either shadow the deployment persona or own the complete system prompt. [`dsh-system-prompt`](../../core/system-prompt/README.md) owns the deployment persona as its own config and registers that section unconditionally, so a process has exactly one. An [agent preset](../agent-presets/README.md) cannot mount the prompt registry itself — without a row of its own, a preset could change an agent's tools but never its identity. This package is that row. @@ -15,8 +15,9 @@ Mounting this row outside an agent scope collides with the registry's own `deplo | Field | Default | Meaning | |---|---|---| | `text` | required | Persona prose rendered as the `deployment:persona` section | +| `complete` | `false` | Restore this persona after assembly as the only system-prompt section | -`text` is a template, like any prompt section: complete `{{…}}` groups resolve strictly against registered prompt variables when the prompt renders, not when it assembles. Empty text still occupies the slot, so it shadows the deployment persona away entirely and then disappears at render. +`text` is a template, like any prompt section: complete `{{…}}` groups resolve strictly against registered prompt variables when the prompt renders, not when it assembles. Empty text still occupies the slot, so it shadows the deployment persona away entirely and then disappears at render. With `complete: true`, assembly still resolves contexts, tools, variables, and cooperative listeners, then the prompt registry restores this exact persona as the sole section; no identity, tool guidance, or listener can append prompt text. ## Model Experience @@ -24,11 +25,11 @@ Mounting this row outside an agent scope collides with the registry's own `deplo #### What the model sees -The `deployment:persona` section at order 0, immediately after the harness identity opener, carrying exactly this row's configured `text` with prompt variables resolved. For an agent whose preset mounts this row, it replaces whatever persona the deployment configured. +The `deployment:persona` section at order 0, immediately after the harness identity opener, carrying exactly this row's configured `text` with prompt variables resolved. For an agent whose preset mounts this row, it replaces whatever persona the deployment configured. In complete mode, the model sees only this rendered section as its system prompt. #### Token effect -Fixed for a given preset: the persona's own tokens on every request that agent makes, and none for any other agent. Empty text contributes nothing. +Fixed for a given preset: the persona's own tokens on every request that agent makes, and none for any other agent. Empty text contributes nothing. Complete mode removes every other system-prompt token for that agent. #### KV Cache effect diff --git a/packages/preset/persona/README.zh.md b/packages/preset/persona/README.zh.md index 4e28d75bbd..add106adb5 100644 --- a/packages/preset/persona/README.zh.md +++ b/packages/preset/persona/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -把 agent(智能体)人设做成一个可组装的行:一个配置字段,一个提示词段落。 +把 agent(智能体)人设做成一个可组装的行:它既可以遮蔽部署级人设,也可以拥有完整系统提示词。 [`dsh-system-prompt`](../../core/system-prompt/README.md) 以自身配置持有部署级人设,并且无条件注册该段落,因此一个进程只有一份。[agent preset](../agent-presets/README.md) 无法自行挂载提示词注册表——若没有属于自己的行,preset 能改变 agent 的工具,却永远改不了它的身份。本包就是那一行。 @@ -15,8 +15,9 @@ | 字段 | 默认值 | 含义 | |---|---|---| | `text` | 必填 | 作为 `deployment:persona` 段落渲染的人设文本 | +| `complete` | `false` | 组装后将此人设恢复为唯一的系统提示词段落 | -`text` 与任何提示词段落一样是模板:完整的 `{{…}}` 组在提示词**渲染**时(而非组装时)严格解析为已注册的提示词变量。空文本同样占据该槽位,因此会把部署级人设整个遮蔽掉,然后在渲染时消失。 +`text` 与任何提示词段落一样是模板:完整的 `{{…}}` 组在提示词**渲染**时(而非组装时)严格解析为已注册的提示词变量。空文本同样占据该槽位,因此会把部署级人设整个遮蔽掉,然后在渲染时消失。启用 `complete: true` 时,组装仍会解析上下文、工具、变量和协作式监听器,之后提示词注册表将这份确切人设恢复为唯一段落;身份、工具引导或监听器都无法追加提示词文本。 ## Model Experience @@ -24,11 +25,11 @@ #### What the model sees -位于 order 0 的 `deployment:persona` 段落,紧随 harness 身份开场白之后,携带本行配置的 `text`,其中的提示词变量已解析。对于其 preset 挂载了本行的 agent,它会替换部署所配置的任何人设。 +位于 order 0 的 `deployment:persona` 段落,紧随 harness 身份开场白之后,携带本行配置的 `text`,其中的提示词变量已解析。对于其 preset 挂载了本行的 agent,它会替换部署所配置的任何人设。在完整模式下,模型只会看到这个渲染后的段落作为系统提示词。 #### Token effect -对给定 preset 而言是固定的:该 agent 的每次请求都携带人设自身的 token,其他 agent 一个都不带。空文本不贡献任何 token。 +对给定 preset 而言是固定的:该 agent 的每次请求都携带人设自身的 token,其他 agent 一个都不带。空文本不贡献任何 token。完整模式会移除该 agent 的其他所有系统提示词 token。 #### KV Cache effect diff --git a/packages/preset/persona/src/index.ts b/packages/preset/persona/src/index.ts index 4690bd2618..69a53678e9 100644 --- a/packages/preset/persona/src/index.ts +++ b/packages/preset/persona/src/index.ts @@ -38,23 +38,27 @@ export interface Config { * variables. Empty text drops the section at render, matching the registry. */ text: string + /** Make this persona the complete system prompt, suppressing every other section. */ + complete?: boolean } /** Runtime schema for the persona row. */ export const Config: z = z.object({ text: z.string().required(), + complete: z.boolean().default(false), }) /** * Register the persona section for the mounting context's scope. * @param ctx - an agent scope context; an unscoped context collides with the * prompt registry's own persona registration and rejects. - * @param config - the persona text. + * @param config - the persona text and complete-prompt policy. */ export function apply(ctx: Context, config: Config): void { ctx.effect(() => ctx.systemPrompt.section({ name: PERSONA_SECTION, order: PERSONA_ORDER, text: config.text, + ...(config.complete ? { complete: true } : {}), }), 'persona.section()') } diff --git a/packages/preset/persona/src/invariant.ts b/packages/preset/persona/src/invariant.ts index 0f818eb888..ee63503de3 100644 --- a/packages/preset/persona/src/invariant.ts +++ b/packages/preset/persona/src/invariant.ts @@ -16,7 +16,8 @@ export const inject = ['invariants'] /** * No runtime invariant: this row owns no event stream or mutable runtime data — it registers one - * prompt section and the prompt registry owns section identity, shadowing, and disposal. + * prompt section and the prompt registry owns identity, complete-prompt enforcement, shadowing, + * and disposal. */ const install: InvariantInstaller = () => {} diff --git a/packages/preset/persona/tests/persona.spec.ts b/packages/preset/persona/tests/persona.spec.ts index c4dbe825b0..3fcc30d3a5 100644 --- a/packages/preset/persona/tests/persona.spec.ts +++ b/packages/preset/persona/tests/persona.spec.ts @@ -85,4 +85,21 @@ describe('the persona row', () => { expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))) .toContain('You run on deepseek-v4-pro.') }) + + it('makes a complete persona the exact prompt after every other contribution', async () => { + const ctx = await harness('deployment identity') + const key: ScopeKey = { agent: 'a1' } + const scope = createScope(ctx, key) + ctx.systemPrompt.section({ name: 'global:extra', order: 100, text: 'global guidance' }) + + await scope.ctx.plugin(Persona, { text: 'Only this.', complete: true }) + scope.ctx.on('system-prompt/assemble', async (assembly, _context, next) => { + assembly.sections.push({ name: 'late:extra', text: 'late guidance' }) + return next() + }, { prepend: true }) + + const assembly = await ctx.systemPrompt.assemble({ scope: key }) + expect(assembly.sections).toEqual([{ name: PERSONA_SECTION, text: 'Only this.' }]) + expect(renderPrompt(assembly)).toBe('Only this.') + }) }) diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 8bfb1f3d80..8305b2cc20 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -26,6 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/runner.js", + "lib/types-*.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 01631bb22c..29965759bc 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -1114,7 +1114,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async assemble(context: AssembleContext = {}): Promise', - jsDoc: '/**\n * Assemble global and scoped providers, detach tool parameters, apply\n * canonical ordering, then run the assembly waterfall. Scoped sections and\n * variables shadow globals; the returned waterfall value is authoritative.\n * @param context - the optional scope and plugin-defined assembly fields.\n * @returns the authoritative post-waterfall assembly.\n */', + jsDoc: '/**\n * Assemble global and scoped providers, detach tool parameters, apply\n * canonical ordering, then run the assembly waterfall. Scoped sections and\n * variables shadow globals. The returned waterfall value is authoritative\n * except that an effective complete section is restored afterwards as the\n * sole prompt section.\n * @param context - the optional scope and plugin-defined assembly fields.\n * @returns the post-waterfall assembly with any complete prompt enforced.\n */', }, ], }, @@ -1610,7 +1610,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'system-prompt/assemble', mode: 'waterfall', signature: '\'system-prompt/assemble\'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise', - jsDoc: '/**\n * Expert waterfall over the assembled sections, contexts, tools, and variables.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners\n * receive only that scope\'s assemblies. The returned value is authoritative.\n * A supplied signal controls only this explicit assembly request and must not\n * be retained to control later turns.\n * @param assembly - the mutable assembly built from registered providers.\n * @param context - the caller\'s per-assembly context.\n * @mode waterfall\n */', + jsDoc: '/**\n * Expert waterfall over the assembled sections, contexts, tools, and variables.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners\n * receive only that scope\'s assemblies. The returned value is authoritative.\n * A supplied signal controls only this explicit assembly request and must not\n * be retained to control later turns. A registered complete section is\n * restored after this waterfall, so listeners cannot add to or replace\n * that scope\'s system prompt.\n * @param assembly - the mutable assembly built from registered providers.\n * @param context - the caller\'s per-assembly context.\n * @mode waterfall\n */', summary: 'Expert waterfall over the assembled sections, contexts, tools, and variables.', }, { @@ -2421,7 +2421,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PromptSection', - declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n}', + declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n readonly complete?: boolean;\n}', }, { name: 'ProviderRequestId', diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index 23450e6e42..bbb3c9cf1a 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-claude-code/README.md -README.md: 222cf796f71f8dc0dc2c06f7f32bab70ded6ab43 -README.zh.md: 9334820a591cbfcb8dc2046dc3a78201ba193aab +README.md: 17b14e847baea3eadda7129b5e49f5e65b668cc8 +README.zh.md: 2f59144d5bd9f26a58773e6dd53909b2b0e8da14 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 222cf796f7..17b14e847b 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This package registers the fixed `claude-code` subagent provider. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, starts the SDK-distributed Claude Code CLI through the shared subprocess service, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract. +This package registers the fixed `claude-code` subagent provider. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, resolves the native `claude` executable through the shared subprocess service, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract. ## Start and ownership @@ -29,9 +29,9 @@ The provider advertises no optional start-time capabilities and reports `inherit | `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. | | `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | -Production uses the Claude Code CLI supplied by `@anthropic-ai/claude-agent-sdk` and the host's native settings and authentication. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. +Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. On Windows, a resolved `.cmd` or `.bat` path is carried as a quoted, per-spawn environment value that `cmd.exe /v:off` expands once, so valid path metacharacters remain data. The pinned SDK's fixed flags then occupy cmd's command tail and contain no cmd metacharacters; they are not ordinary Windows argv. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. -Install this package and add the following rows to your own `cordis.yml`. Shipped CLI configurations do not load this provider or expose `subagent_claude_code` by default. +Shipped profiles load this provider once on the host and start no Claude process until a tool call. Full Agent Presets carry the tool row below with `disabled: true`; copy a preset and remove that field to expose `subagent_claude_code` only to agents composed from the copy. A custom host composition can still use both rows directly. ```yaml - id: subagent-claude-code @@ -42,6 +42,7 @@ Install this package and add the following rows to your own `cordis.yml`. Shippe - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' + disabled: true config: provider: claude-code toolName: subagent_claude_code @@ -51,7 +52,7 @@ Install this package and add the following rows to your own `cordis.yml`. Shippe ## Product compatibility and evidence -The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`, whose platform optional dependency supplies Claude Code 2.1.220. Required evidence exercises that official distribution through a keyless loopback product path and a credentialed DeepSeek path, while Loader composition proves that both opt-in product packages coexist without starting either product. +The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`. Production runs the native `claude` installation. The keyless real-product test uses the SDK-distributed Claude Code 2.1.220 CLI as a deterministic fixture, routed through the same native executable-resolution and Windows batch-shim path; it does not claim compatibility with every independently installed version. Loader composition proves that both product packages coexist without starting either product. The project owner's identity-scoped distribution authorization covers the official SDK and the official CLI/platform payloads declared by each SDK version. [`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) discloses the current optional payload closure without classifying its declared terms as permissive; unrelated non-permissive runtime dependencies continue to fail the notices gate. @@ -89,7 +90,8 @@ Append-only: the new tool result follows the reusable parent request prefix. - **One fresh query and process per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. - **Host settings are intentionally authoritative** — project and user settings can change model, tools, and behavior; the provider does not provide a filtered or hermetic production mode. -- **Product installation and account state remain native** — an incompatible SDK payload, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer or login flow. +- **Product installation and account state remain native** — a missing or incompatible `claude`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer or login flow. +- **The SDK platform CLI remains in the install closure** — production ignores it in favor of the host `claude`, but the current SDK optional dependency is still installed and supplies the keyless compatibility fixture. Removing that payload belongs to the separate product installation-closure follow-up. - **No human interaction path** — `AskUserQuestion` is disabled and other interactive callbacks are absent, so tasks requiring new approval or input fail instead of suspending. - **Final text only** — reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local. - **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 9334820a59..2f59144d5b 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本包(package)注册固定的 `claude-code` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,通过共享子进程服务启动 SDK 分发的 Claude Code CLI,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定仅返回最终答案。 +本包(package)注册固定的 `claude-code` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,通过共享子进程服务解析原生 `claude` 可执行文件,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定仅返回最终答案。 ## 启动与所有权 @@ -29,9 +29,9 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | | `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | -生产环境使用 `@anthropic-ai/claude-agent-sdk` 提供的 Claude Code CLI,以及宿主机原生设置与身份验证。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 +生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。在 Windows 上,解析到的 `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境值交给 `cmd.exe /v:off` 展开一次,因此合法路径中的元字符仍只是数据。锁定版本的 SDK 随后把固定命令行选项放在 cmd 的命令尾部;这些选项不含 cmd 元字符,也并不是普通的 Windows argv。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 -请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_claude_code`。 +随附 profile 会在宿主上加载一次该提供方,而且在工具被调用前不会启动 Claude 进程。完整 Agent Preset 携带下列工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_claude_code`。自定义宿主组装仍可直接使用两条配置行。 ```yaml - id: subagent-claude-code @@ -42,6 +42,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' + disabled: true config: provider: claude-code toolName: subagent_claude_code @@ -51,7 +52,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK ## 产品兼容性与证据 -运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`,其平台可选依赖提供 Claude Code 2.1.220。强制证据会通过无密钥回环产品路径与带密钥 DeepSeek 路径运行该官方发行版,而 Loader 组合则证明两个选择启用的产品包能够共存,且不会启动任一产品。 +运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`。生产运行使用原生 `claude` 安装。无密钥真实产品测试使用由 SDK 分发的 Claude Code 2.1.220 CLI 作为确定性 fixture(测试前置数据),并通过同一套原生可执行文件解析路径与 Windows batch shim 路径运行;这项测试不声称兼容每个独立安装的版本。Loader 组合证明两个产品包能够共存且不会启动任一产品。 项目所有者按身份范围授权分发官方 SDK 及每个 SDK 版本声明的官方 CLI/平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) 会披露当前可选载荷闭包,但不会把其声明条款归类为宽松许可证;其他无关的非宽松运行时依赖仍会使第三方声明门禁失败。 @@ -89,7 +90,8 @@ Claude Code 子任务会在一个全新的 SDK query 中接收独立文本任务 - **每次运行均新建一个 query 和一个进程**:不支持续接、恢复、池化、进度流或产品会话持久化。 - **宿主设置有意保持权威**:项目和用户设置可以改变模型、工具与行为;本提供方不提供经过筛选或与宿主环境隔离的生产模式。 -- **产品安装与账户状态仍由原生机制管理**:不兼容的 SDK 载荷、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。 +- **产品安装与账户状态仍由原生机制管理**:`claude` 缺失或不兼容、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。 +- **SDK 平台 CLI 仍在安装闭包内**:生产环境会忽略它,改用宿主提供的 `claude`,但当前 SDK 的可选依赖仍会安装,并提供无密钥兼容性 fixture。移除该载荷属于独立的产品安装闭包后续项。 - **没有人工交互路径**:`AskUserQuestion` 被禁用,其他交互回调也不存在,因此需要新审批或输入的任务会失败而不会挂起。 - **仅返回最终文本**:推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。 - **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index ea780401d7..ccd150b746 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -59,19 +59,25 @@ class ClaudeCodeProvider implements SubagentProvider { private readonly config: ResolvedConfig, ) {} - start(request: ResolvedSubagentStartRequest) { + async start(request: ResolvedSubagentStartRequest) { const parentCwd = request.parent.session.header.cwd if (parentCwd === undefined) { throw new Error( 'subagent-claude-code: no working directory for the child — delegate from a parent session that has one', ) } + const executable = await this.ctx.subprocess.resolveExecutable( + 'claude', + this.config.env, + request.signal, + ) const spec: ClaudeCodeRunSpec = { cwd: resolveChildCwd( 'subagent-claude-code', undefined, parentCwd, ), + executable, env: this.config.env, disposeGraceMs: this.config.disposeGraceMs, spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec), diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index 32a545bf08..1e2a259ca2 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -6,6 +6,7 @@ */ import { EventEmitter } from 'node:events' +import { extname } from 'node:path' import type { SpawnedProcess, SpawnOptions, @@ -16,6 +17,8 @@ import { type SubprocessSpawnSpec, } from '@deepseek-ai/dsh-subprocess' +const WINDOWS_BATCH_EXECUTABLE_ENV = 'DSH_CLAUDE_CODE_EXECUTABLE' + function thrown(value: unknown): Error { /* v8 ignore next -- the subprocess seam rejects with Error. */ return value instanceof Error ? value : new Error(String(value)) @@ -40,22 +43,33 @@ export function sdkEnvironmentOverlay( * Translate one official SDK spawn request to the shared process owner. * @param options - command, arguments, workspace, environment, and forwarded signal from the SDK. * @param graceMs - process-tree termination grace. + * @param platform - host platform selecting the Windows batch-shim boundary. * @returns the fully explicit shared subprocess request. + * @remarks The batch-shim path quotes only the resolved executable. The pinned SDK + * supplies fixed flag arguments without cmd metacharacters; cmd reparses that tail. */ export function claudeSpawnSpec( options: SpawnOptions, graceMs: number, + platform: NodeJS.Platform = process.platform, ): SubprocessSpawnSpec { if (options.cwd === undefined || options.cwd.length === 0) { throw new Error('subagent-claude-code: SDK spawn request omitted its workspace') } + const extension = extname(options.command).toLowerCase() + const batchShim = platform === 'win32' && (extension === '.cmd' || extension === '.bat') + const env = sdkEnvironmentOverlay(options.env) + const argv = batchShim + ? ['cmd.exe', '/d', '/v:off', '/s', '/c', `%${WINDOWS_BATCH_EXECUTABLE_ENV}%`, ...options.args] + : [options.command, ...options.args] + if (batchShim) env[WINDOWS_BATCH_EXECUTABLE_ENV] = `"${options.command}"` return { - argv: [options.command, ...options.args], + argv, cwd: options.cwd, stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs, signal: options.signal, - env: sdkEnvironmentOverlay(options.env), + env, } } diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 9dc4d740ac..6c1e0a8dbf 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -44,6 +44,8 @@ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 export interface ClaudeCodeRunSpec { /** Parent Session workspace supplied to the SDK and real CLI. */ readonly cwd: string + /** Exact native Claude Code executable resolved from the host PATH. */ + readonly executable: string /** Explicit deployment/test environment layered after shared scrubbing. */ readonly env: Record /** Subprocess termination grace passed to the shared process-tree owner. */ @@ -180,6 +182,7 @@ export function claudeQueryOptions( return { abortController: controller, cwd: spec.cwd, + pathToClaudeCodeExecutable: spec.executable, env: { ...scrubbedParentEnv(), ...spec.env }, persistSession: false, disallowedTools: ['AskUserQuestion'], diff --git a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts index aa77615297..9c2c9c25e5 100644 --- a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts +++ b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts @@ -7,7 +7,7 @@ import { rmSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join, resolve } from 'node:path' +import { delimiter, dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { Context } from '@deepseek-ai/cordis' @@ -87,6 +87,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)( ]) mkdirSync(directory) const env = { + PATH: `${dirname(claudeBin)}${delimiter}${process.env.PATH ?? ''}`, ANTHROPIC_AUTH_TOKEN: apiKey, ANTHROPIC_BASE_URL: `${deepSeekBaseUrl()}/anthropic`, ANTHROPIC_MODEL: 'deepseek-v4-pro[1m]', diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 558724eee2..9320cb0e7c 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -3,11 +3,12 @@ import { mkdirSync, mkdtempSync, readFileSync, + symlinkSync, writeFileSync, } from 'node:fs' import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { dirname, join, resolve } from 'node:path' +import { delimiter, dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import type { @@ -19,7 +20,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' -import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as claudeCode from '../src/index.ts' import { @@ -98,9 +99,11 @@ afterEach(async () => { interface RealHarness { readonly ctx: Context readonly handles: SubprocessHandle[] + readonly spawnSpecs: SubprocessSpawnSpec[] readonly parent: Agent readonly workspace: string readonly env: Record + readonly executable: string } async function realHarness(behavior: MessagesBehavior): Promise<{ @@ -112,9 +115,17 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ const workspace = join(root, 'workspace') const claudeConfig = join(root, 'claude-config') const xdgConfig = join(root, 'xdg') + const nativeBin = join(root, 'native&%literal%!bang!bin') mkdirSync(workspace) mkdirSync(claudeConfig) mkdirSync(xdgConfig) + mkdirSync(nativeBin) + const executable = join(nativeBin, process.platform === 'win32' ? 'claude.cmd' : 'claude') + if (process.platform === 'win32') { + writeFileSync(executable, `@echo off\r\n"${claudeBin}" %*\r\n`) + } else { + symlinkSync(claudeBin, executable) + } writeFileSync( join(claudeConfig, 'settings.json'), `${JSON.stringify({ model: settingsModel }, null, 2)}\n`, @@ -122,6 +133,7 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ const fixture = await startMessagesFixture(behavior) fixtures.push(fixture) const env = { + PATH: `${nativeBin}${delimiter}${process.env.PATH ?? ''}`, ANTHROPIC_API_KEY: fakeKey, ANTHROPIC_BASE_URL: fixture.baseUrl, CLAUDE_CONFIG_DIR: claudeConfig, @@ -141,8 +153,10 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ await ctx.plugin(SubagentService) await ctx.plugin(LocalSubprocessService) const handles: SubprocessHandle[] = [] + const spawnSpecs: SubprocessSpawnSpec[] = [] const spawn = ctx.subprocess.spawn.bind(ctx.subprocess) vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => { + spawnSpecs.push(spec) const handle = spawn(spec) handles.push(handle) return handle @@ -153,7 +167,7 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ session: { header: { cwd: workspace } }, } as unknown as Agent return { - harness: { ctx, handles, parent, workspace, env }, + harness: { ctx, handles, spawnSpecs, parent, workspace, env, executable }, fixture, } } @@ -182,7 +196,7 @@ function startRequest( }) } -describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { +describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 fixture', { timeout: 60_000, }, () => { it('inherits host settings and sends the exact task and fake key to local Messages', async () => { @@ -195,7 +209,7 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { expect(sdkPackage.version).toBe('0.3.220') expect(sdkPackage.claudeCodeVersion).toBe('2.1.220') expect(sdkPackage.optionalDependencies[platformPackage]).toBe('0.3.220') - const version = await execFileAsync(claudeBin, ['--version'], { + const version = await execFileAsync(process.platform === 'win32' ? claudeBin : harness.executable, ['--version'], { env: { ...process.env, ...harness.env }, }) expect(version.stdout.trim()).toBe('2.1.220 (Claude Code)') @@ -212,6 +226,18 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { message.type === 'system' && message.subtype === 'init', ) expect(initMessage?.claude_code_version).toBe('2.1.220') + if (process.platform === 'win32') { + expect(harness.spawnSpecs[0]?.argv.slice(0, 6)).toEqual([ + 'cmd.exe', '/d', '/v:off', '/s', '/c', '%DSH_CLAUDE_CODE_EXECUTABLE%', + ]) + const batchExecutable = harness.spawnSpecs[0]?.env?.DSH_CLAUDE_CODE_EXECUTABLE + expect(batchExecutable?.startsWith('"')).toBe(true) + expect(batchExecutable?.endsWith('"')).toBe(true) + expect(batchExecutable?.slice(1, -1).toLowerCase()) + .toBe(harness.executable.toLowerCase()) + } else { + expect(harness.spawnSpecs[0]?.argv[0]).toBe(harness.executable) + } expect(fixture.requests).toHaveLength(1) const recorded = fixture.requests[0]! diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index b2cc0d7e37..cee479fd9d 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -248,6 +248,7 @@ function fakeRun( const options: FakeRun['options'] = [] const spec: ClaudeCodeRunSpec = { cwd: '/workspace', + executable: '/native/claude', env: { ANTHROPIC_API_KEY: 'fake-key' }, disposeGraceMs: 5, spawn: (spawnSpec) => { @@ -331,6 +332,8 @@ describe('task admission and package contracts', () => { const child = fakeChild() const spawn = vi.spyOn(ctx.subprocess, 'spawn') .mockImplementation(() => child.handle) + const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable') + .mockResolvedValue('/native/claude') const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) await ctx.plugin(claudeCode, { env: { @@ -352,6 +355,11 @@ describe('task admission and package contracts', () => { ) expect(queryMock).not.toHaveBeenCalled() + resolveExecutable.mockRejectedValueOnce(new Error('claude missing from PATH')) + await expect(ctx.subagents.start('claude-code', request())) + .rejects.toThrow('claude missing from PATH') + expect(queryMock).not.toHaveBeenCalled() + const run = await ctx.subagents.start('claude-code', request()) child.settle({ exitCode: 9, signal: null }) child.stdout.end() @@ -362,6 +370,13 @@ describe('task admission and package contracts', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining( 'subagent-claude-code: child run failed (error):', )) + expect(resolveExecutable).toHaveBeenCalledWith( + 'claude', + expect.objectContaining({ ANTHROPIC_API_KEY: 'provider-fake-key' }), + expect.any(AbortSignal), + ) + expect(queryMock.mock.calls[0]?.[0].options.pathToClaudeCodeExecutable) + .toBe('/native/claude') expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: process.cwd(), graceMs: 29, @@ -441,6 +456,22 @@ describe('official spawn projection', () => { )).toThrow('SDK spawn request omitted its workspace') }) + it.each(['cmd', 'bat'])('routes a Windows .%s shim through cmd.exe', (extension) => { + const command = String.raw`C:\Program Files\Claude\claude.${extension}` + const spec = claudeSpawnSpec(sdkSpawnOptions({ + command, + args: ['--output-format', 'stream-json'], + }), 7, 'win32') + + expect(spec.argv).toEqual([ + 'cmd.exe', '/d', '/v:off', '/s', '/c', '%DSH_CLAUDE_CODE_EXECUTABLE%', + '--output-format', 'stream-json', + ]) + expect(spec.env).toEqual(expect.objectContaining({ + DSH_CLAUDE_CODE_EXECUTABLE: `"${command}"`, + })) + }) + it('projects streams, exit facts, listeners, and idempotent tree termination', async () => { const child = fakeChild({ exitOnTerminate: false }) const process = new ManagedClaudeCodeProcess(child.handle) @@ -508,6 +539,7 @@ describe('query options and result mapping', () => { const captured: SubprocessHandle[] = [] const spec: ClaudeCodeRunSpec = { cwd: '/workspace', + executable: '/native/claude', env: { HOST_VISIBLE: 'overridden', ANTHROPIC_API_KEY: 'explicit-fake-key', @@ -523,6 +555,7 @@ describe('query options and result mapping', () => { expect(options).toMatchObject({ abortController: controller, cwd: '/workspace', + pathToClaudeCodeExecutable: '/native/claude', persistSession: false, disallowedTools: ['AskUserQuestion'], }) @@ -670,6 +703,7 @@ describe('run publication, cancellation, and settlement', () => { let index = 0 const spec: ClaudeCodeRunSpec = { cwd: '/workspace', + executable: '/native/claude', env: {}, disposeGraceMs: 5, spawn: () => children[index++]!.handle, @@ -720,6 +754,7 @@ describe('run publication, cancellation, and settlement', () => { request(undefined, parentAbort.signal), { cwd: '/workspace', + executable: '/native/claude', env: {}, disposeGraceMs: 5, spawn: () => child.handle, diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index 7da7a5fc78..1cfb9645c2 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-codex/README.md -README.md: 686c1f4d47f9024bfe66a4b85490bf0f84610b61 -README.zh.md: afe5433a1d0453b25e346bd7a8a33006a055309c +README.md: 3d59ca1eaf3db9dd9d9d2cd451692ebd2a956ef4 +README.zh.md: b60cb1bba9b2d7b3f61c544c1600862a0ad6ce5b diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index 686c1f4d47..3d59ca1eaf 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -27,7 +27,7 @@ The provider advertises no optional start-time capabilities and reports `inherit Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. -Install this package and add the following rows to your own `cordis.yml`. Shipped CLI configurations do not load this provider or expose `subagent_codex` by default. +Shipped profiles load this provider once on the host and start no Codex process until a tool call. Full Agent Presets carry the tool row below with `disabled: true`; copy a preset and remove that field to expose `subagent_codex` only to agents composed from the copy. A custom host composition can still use both rows directly. ```yaml - id: subagent-codex @@ -38,6 +38,7 @@ Install this package and add the following rows to your own `cordis.yml`. Shippe - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' + disabled: true config: provider: codex toolName: subagent_codex diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index afe5433a1d..b60cb1bba9 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -27,7 +27,7 @@ 生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 -请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_codex`。 +随附 profile 会在宿主上加载一次该提供方,而且在工具被调用前不会启动 Codex 进程。完整 Agent Preset 携带下列工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_codex`。自定义宿主组装仍可直接使用两条配置行。 ```yaml - id: subagent-codex @@ -38,6 +38,7 @@ - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' + disabled: true config: provider: codex toolName: subagent_codex diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83ec437060..fc1628bf32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,10 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@deepseek-ai/cosmokit': link:vendor/cosmokit + '@deepseek-ai/schemastery': link:vendor/schemastery + patchedDependencies: node-pty@1.1.0: 7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6 @@ -149,6 +153,9 @@ importers: '@deepseek-ai/dsh-client-ui-agent-preset': specifier: workspace:^ version: link:../../packages/client/ui-agent-preset + '@deepseek-ai/dsh-cmdline': + specifier: workspace:^ + version: link:../../packages/boot/cmdline '@deepseek-ai/dsh-command-compact': specifier: workspace:^ version: link:../../packages/compact/command-compact @@ -161,6 +168,9 @@ importers: '@deepseek-ai/dsh-compact-tool-result-prune': specifier: workspace:^ version: link:../../packages/compact/compact-tool-result-prune + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../packages/util/environment '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../packages/goal/goal @@ -352,6 +362,9 @@ importers: '@deepseek-ai/dsh-client-web-react': specifier: workspace:^ version: link:../../packages/client/web-react + '@deepseek-ai/dsh-cmdline': + specifier: workspace:^ + version: link:../../packages/boot/cmdline '@deepseek-ai/dsh-pwsh-local': specifier: workspace:^ version: link:../../packages/bash/pwsh-local @@ -735,7 +748,7 @@ importers: specifier: 0.25.1 version: 0.25.1(zod@4.4.3) '@deepseek-ai/schemastery': - specifier: ^3.17.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -834,7 +847,7 @@ importers: packages/attachment/attachment-local: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery sharp: specifier: ^0.35.3 @@ -874,7 +887,7 @@ importers: packages/bash/bash-env: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -905,7 +918,7 @@ importers: packages/bash/bash-local: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -963,7 +976,7 @@ importers: packages/bash/pwsh-local: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -1018,7 +1031,7 @@ importers: packages/bash/tool-bash: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -1085,7 +1098,7 @@ importers: packages/bash/tool-pwsh: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -1180,6 +1193,24 @@ importers: specifier: ^4.0.9 version: 4.0.9 + packages/boot/cmdline: + devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + commander: + specifier: ^15.0.0 + version: 15.0.0 + packages/bundle/base: dependencies: '@deepseek-ai/cordis-plugin-hmr': @@ -1320,6 +1351,12 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent + '@deepseek-ai/dsh-subagent-claude-code': + specifier: workspace:^ + version: link:../../subagent/subagent-claude-code + '@deepseek-ai/dsh-subagent-codex': + specifier: workspace:^ + version: link:../../subagent/subagent-codex '@deepseek-ai/dsh-subagent-fork': specifier: workspace:^ version: link:../../subagent/subagent-fork @@ -1423,12 +1460,18 @@ importers: packages/bundle/headless: dependencies: + '@deepseek-ai/dsh-cmdline': + specifier: workspace:^ + version: link:../../boot/cmdline '@deepseek-ai/dsh-code-runtime-worker': specifier: workspace:^ version: link:../../code-runtime/code-runtime-worker '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + commander: + specifier: ^15.0.0 + version: 15.0.0 devDependencies: '@deepseek-ai/cordis': specifier: ^4.0.0-rc.7 @@ -1460,6 +1503,9 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../../boot/app-boot '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection @@ -1541,6 +1587,9 @@ importers: '@deepseek-ai/dsh-client-ui-workspace': specifier: workspace:^ version: link:../../client/ui-workspace + '@deepseek-ai/dsh-cmdline': + specifier: workspace:^ + version: link:../../boot/cmdline '@deepseek-ai/dsh-code-runtime-worker': specifier: workspace:^ version: link:../../code-runtime/code-runtime-worker @@ -1581,12 +1630,18 @@ importers: specifier: workspace:^ version: link:../../workspace/workspace '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + commander: + specifier: ^15.0.0 + version: 15.0.0 devDependencies: '@deepseek-ai/cordis': specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader '@deepseek-ai/dsh-bash-env': specifier: workspace:^ version: link:../../bash/bash-env @@ -1618,7 +1673,7 @@ importers: specifier: workspace:^ version: link:../../core/tools '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery ws: specifier: ^8.21.0 @@ -1640,7 +1695,7 @@ importers: packages/client/hmr: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -1668,7 +1723,7 @@ importers: specifier: workspace:^ version: link:../../settings/settings '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -1778,7 +1833,7 @@ importers: specifier: workspace:^ version: link:../../typert/registry '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery '@types/react': specifier: ~18.3.1 @@ -1787,7 +1842,7 @@ importers: packages/client/schema-form: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -1931,7 +1986,7 @@ importers: specifier: workspace:^ version: link:../../settings/settings '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery clsx: specifier: ^2.0.0 @@ -2501,7 +2556,7 @@ importers: specifier: workspace:^ version: link:../../settings/settings '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -2721,7 +2776,7 @@ importers: specifier: workspace:^ version: link:../../settings/settings '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery clsx: specifier: ^2.0.0 @@ -2970,7 +3025,7 @@ importers: packages/code-runtime/code-runtime-worker: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3043,7 +3098,7 @@ importers: packages/compact/compact-basic: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3095,7 +3150,7 @@ importers: packages/compact/compact-tool-result-prune: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3126,7 +3181,7 @@ importers: packages/context/session-reference: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3157,7 +3212,7 @@ importers: packages/context/time-context: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3194,7 +3249,7 @@ importers: packages/context/tmux-context: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3222,7 +3277,7 @@ importers: packages/context/workspace-context: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3298,7 +3353,7 @@ importers: packages/core/agent-default-model: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3320,7 +3375,7 @@ importers: packages/core/agent-loop: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3360,7 +3415,7 @@ importers: packages/core/agent-tool-mode: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3424,7 +3479,7 @@ importers: packages/core/system-prompt: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3443,7 +3498,7 @@ importers: packages/core/tools: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3489,7 +3544,7 @@ importers: packages/credentials/credentials-local: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery chokidar: specifier: ^4.0.3 @@ -3520,7 +3575,7 @@ importers: packages/e2b/e2b: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery e2b: specifier: 2.29.1 @@ -3557,7 +3612,7 @@ importers: packages/e2b/subprocess-e2b: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3577,6 +3632,10 @@ importers: version: link:../../util/timeout packages/examples/acp-demo: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': specifier: ^4.0.0-rc.7 @@ -3623,14 +3682,11 @@ importers: '@deepseek-ai/dsh-workspace-context': specifier: workspace:^ version: link:../../context/workspace-context - '@deepseek-ai/schemastery': - specifier: ^3.17.0 - version: link:../../../vendor/schemastery packages/examples/agent-spine-demo: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3803,7 +3859,7 @@ importers: packages/fs/fs-local: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery koffi: specifier: ^3.1.0 @@ -3861,7 +3917,7 @@ importers: packages/fs/tool-fs: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery diff: specifier: ^9.0.0 @@ -3919,7 +3975,7 @@ importers: packages/fs/tool-fs-search: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery '@vscode/ripgrep': specifier: ^1.18.0 @@ -3965,7 +4021,7 @@ importers: packages/fs/tool-str-replace-editor: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -4038,7 +4094,7 @@ importers: packages/goal/goal: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.17.2 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 @@ -4111,7 +4167,7 @@ importers: packages/goal/tool-goal: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -4145,7 +4201,7 @@ importers: packages/guard/repeat-tool-guard: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -4209,7 +4265,7 @@ importers: packages/hooks/hooks-claude: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -4261,7 +4317,7 @@ importers: packages/hooks/hooks-codex: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -4382,7 +4438,7 @@ importers: specifier: workspace:^ version: link:../../workspace/workspace '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 @@ -4452,7 +4508,7 @@ importers: specifier: workspace:^ version: link:../directory-picker '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery clsx: specifier: ^2.0.0 @@ -4529,7 +4585,7 @@ importers: packages/host/frontend-static: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -4548,7 +4604,7 @@ importers: packages/host/webserver: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -4582,7 +4638,7 @@ importers: packages/interaction/permission: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 @@ -4646,7 +4702,7 @@ importers: packages/interaction/user-approval: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -4692,7 +4748,7 @@ importers: packages/llm/llm: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -4714,7 +4770,7 @@ importers: packages/llm/llm-deepseek: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery eventsource-parser: specifier: ^3.1.0 @@ -4745,7 +4801,7 @@ importers: packages/llm/llm-pi-ai: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery '@earendil-works/pi-ai': specifier: ^0.82.1 @@ -4782,7 +4838,7 @@ importers: packages/llm/llm-retry: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -4840,7 +4896,7 @@ importers: packages/llm/token-meter: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 @@ -4883,7 +4939,7 @@ importers: packages/lsp/lsp-local: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -4926,7 +4982,7 @@ importers: packages/lsp/tool-lsp: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -4972,7 +5028,7 @@ importers: packages/mcp/mcp-client: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery '@modelcontextprotocol/sdk': specifier: ^1.12.0 @@ -5049,7 +5105,7 @@ importers: packages/preset/agent-presets: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery js-yaml: specifier: ^4.1.0 @@ -5104,7 +5160,7 @@ importers: packages/preset/persona: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5141,7 +5197,7 @@ importers: packages/pty/pty-local: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5175,7 +5231,7 @@ importers: packages/pty/tool-bash-persistent: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5227,7 +5283,7 @@ importers: packages/pty/tool-pty: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5309,7 +5365,7 @@ importers: specifier: workspace:* version: link:../../../native/landlock-run/packages/entry '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5331,7 +5387,7 @@ importers: packages/sandbox/sandbox-policy: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5510,7 +5566,7 @@ importers: packages/scaffold/server: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.17.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5572,7 +5628,7 @@ importers: packages/self-modification/tool-cordis: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5639,7 +5695,7 @@ importers: packages/session-query/session-query-sqlite: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5667,7 +5723,7 @@ importers: packages/session-query/tool-session-query: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5776,7 +5832,7 @@ importers: packages/session/session-persistence-jsonl: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery koffi: specifier: ^3.1.0 @@ -5798,7 +5854,7 @@ importers: packages/session/session-persistence-sqlite: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5833,7 +5889,7 @@ importers: packages/session/session-projection-cache: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 @@ -5879,7 +5935,7 @@ importers: packages/session/session-telemetry-otel: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery '@opentelemetry/api': specifier: ^1.9.1 @@ -5928,7 +5984,7 @@ importers: packages/session/session-title: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 @@ -5962,7 +6018,7 @@ importers: packages/session/session-title-all-messages-llm: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5987,7 +6043,7 @@ importers: packages/session/session-title-first-message-llm: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6021,7 +6077,7 @@ importers: packages/session/session-title-llm: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6059,6 +6115,10 @@ importers: version: link:../../util/paths packages/settings/settings: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': specifier: ^4.0.0-rc.7 @@ -6069,14 +6129,11 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - '@deepseek-ai/schemastery': - specifier: ^3.18.0 - version: link:../../../vendor/schemastery packages/settings/settings-local: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery chokidar: specifier: ^4.0.3 @@ -6104,7 +6161,7 @@ importers: packages/skill/skill: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6135,7 +6192,7 @@ importers: packages/skill/skill-local: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery chokidar: specifier: ^5.0.0 @@ -6163,7 +6220,7 @@ importers: packages/skill/tool-skill: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6215,7 +6272,7 @@ importers: packages/spill/spill-local: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6240,7 +6297,7 @@ importers: packages/spill/spill-policy: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6283,7 +6340,7 @@ importers: packages/storage/storage-domain: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 @@ -6302,7 +6359,7 @@ importers: packages/storage/storage-json: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6318,7 +6375,7 @@ importers: packages/storage/storage-sqlite: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6398,7 +6455,7 @@ importers: specifier: 0.25.1 version: 0.25.1(zod@4.4.3) '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6444,7 +6501,7 @@ importers: specifier: 0.93.0 version: 0.93.0(zod@4.4.3) '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6481,7 +6538,7 @@ importers: packages/subagent/subagent-codex: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6527,7 +6584,7 @@ importers: packages/subagent/subagent-dsh-sdk: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6567,7 +6624,7 @@ importers: packages/subagent/subagent-fork: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6661,7 +6718,7 @@ importers: packages/subagent/subagent-spawn: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6713,7 +6770,7 @@ importers: packages/subagent/tool-subagent: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6807,7 +6864,7 @@ importers: packages/subagent/tool-subagent-report: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6930,7 +6987,7 @@ importers: packages/support/invariants: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -7037,7 +7094,7 @@ importers: packages/tasks/tool-tasks: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -7074,7 +7131,7 @@ importers: packages/todo/tool-todo: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 @@ -7151,7 +7208,7 @@ importers: packages/typert/loader: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -7261,7 +7318,7 @@ importers: packages/web/tool-web: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery '@joplin/turndown-plugin-gfm': specifier: ^1.0.67 @@ -7316,7 +7373,7 @@ importers: packages/web/web: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -7332,7 +7389,7 @@ importers: packages/web/web-fetch-local: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -7351,7 +7408,7 @@ importers: packages/web/web-search-deepseek: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -7385,7 +7442,7 @@ importers: packages/web/web-search-exa: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -7404,7 +7461,7 @@ importers: packages/web/web-search-perplexity: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -7423,7 +7480,7 @@ importers: packages/workflow/tool-ralph: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -7475,7 +7532,7 @@ importers: packages/workflow/tool-workflow: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -7533,7 +7590,7 @@ importers: packages/workflow/workflow-workerthread: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -7624,6 +7681,9 @@ importers: '@deepseek-ai/cordis-plugin-timer': specifier: workspace:^ version: link:../../vendor/timer + '@deepseek-ai/cosmokit': + specifier: link:../../vendor/cosmokit + version: link:../../vendor/cosmokit '@deepseek-ai/dsh-acp': specifier: workspace:^ version: link:../../packages/acp/acp @@ -7690,6 +7750,9 @@ importers: '@deepseek-ai/dsh-fs-policy': specifier: workspace:^ version: link:../../packages/fs/fs-policy + '@deepseek-ai/dsh-fs-sandbox': + specifier: workspace:^ + version: link:../../packages/fs/fs-sandbox '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../packages/goal/goal @@ -7919,7 +7982,7 @@ importers: specifier: workspace:^ version: link:../../packages/context/workspace-context '@deepseek-ai/schemastery': - specifier: workspace:^ + specifier: link:../../vendor/schemastery version: link:../../vendor/schemastery vendor/cordis: @@ -7931,7 +7994,7 @@ importers: specifier: ^1.0.0-rc.5 version: link:../loader '@deepseek-ai/cosmokit': - specifier: ^1.8.1 + specifier: link:../cosmokit version: link:../cosmokit '@standard-schema/spec': specifier: ^1.1.0 @@ -7960,10 +8023,10 @@ importers: specifier: ^1.1.2 version: link:../timer '@deepseek-ai/cosmokit': - specifier: ^1.8.1 + specifier: link:../cosmokit version: link:../cosmokit '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../schemastery version: link:../schemastery chokidar: specifier: ^4.0.3 @@ -7991,7 +8054,7 @@ importers: specifier: ^1.0.0-rc.5 version: link:../loader '@deepseek-ai/cosmokit': - specifier: ^1.8.1 + specifier: link:../cosmokit version: link:../cosmokit js-yaml: specifier: ^4.1.0 @@ -8003,7 +8066,7 @@ importers: specifier: ^4.0.0-rc.7 version: link:../cordis '@deepseek-ai/cosmokit': - specifier: ^1.8.1 + specifier: link:../cosmokit version: link:../cosmokit node-addon-require-builtin: specifier: ^0.1.4 @@ -8015,10 +8078,10 @@ importers: specifier: ^4.0.0-rc.7 version: link:../cordis '@deepseek-ai/cosmokit': - specifier: ^1.8.1 + specifier: link:../cosmokit version: link:../cosmokit '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../schemastery version: link:../schemastery supports-color: specifier: ^9.4.0 @@ -8027,7 +8090,7 @@ importers: vendor/schemastery: dependencies: '@deepseek-ai/cosmokit': - specifier: ^1.8.1 + specifier: link:../cosmokit version: link:../cosmokit '@standard-schema/spec': specifier: ^1.1.0 @@ -8039,7 +8102,7 @@ importers: specifier: ^4.0.0-rc.7 version: link:../cordis '@deepseek-ai/cosmokit': - specifier: ^1.8.1 + specifier: link:../cosmokit version: link:../cosmokit website: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 65208d7da6..e8d8ee5bec 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,6 +24,10 @@ packages: # builds must resolve those matching names to this workspace's pinned sources. linkWorkspacePackages: true +overrides: + '@deepseek-ai/cosmokit': 'link:vendor/cosmokit' + '@deepseek-ai/schemastery': 'link:vendor/schemastery' + peerDependencyRules: allowedVersions: typescript: '>=5 <7' @@ -48,7 +52,7 @@ allowBuilds: koffi: true # The Python runtime deploy includes the reviewed workspace postinstall that # restores the executable bit on node-pty's macOS spawn helper. - '@deepseek-ai/dsh-pty-local@file:packages/pty/pty-local': true + '@deepseek-ai/dsh-subprocess-local@file:packages/subprocess/subprocess-local': true minimumReleaseAgeExclude: # Fresh pi-ai releases carry the model catalog updates that are the whole diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index 0086d8519f..ab8ad1f4f8 100644 --- a/python/README.i18n.yaml +++ b/python/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/README.md -README.md: 6ab9de681471c4be3bff72ddbf6ce8f118224d4d -README.zh.md: 82fca597791f19caa21a7a433e533a4d47c64ccb +README.md: 75276a915eb4b63f84e0876de46e6d8d63540b59 +README.zh.md: 7791231f9899bd1cca0d62ad35e388db608294c2 diff --git a/python/README.md b/python/README.md index 6ab9de6814..75276a915e 100644 --- a/python/README.md +++ b/python/README.md @@ -8,7 +8,7 @@ Python packages for driving DeepSeek Harness as a subprocess. The client SDK com | Directory | Dist / module | Role | |---|---|---| -| [sdk](sdk/README.md) | `deepseek-harness` / `deepseek_harness` | High-level turns API and lower-level JSON-RPC client | +| [sdk](sdk/README.md) | `deepseek-harness-sdk` / `deepseek_harness` | High-level turns API and lower-level JSON-RPC client | | [sdk-runtime](sdk-runtime/README.md) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | Bundled runtime binaries and default agent configuration | ## Behavior diff --git a/python/README.zh.md b/python/README.zh.md index 82fca59779..7791231f98 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -8,7 +8,7 @@ | 目录 | 分发名 / 模块 | 职责 | |---|---|---| -| [sdk](sdk/README.md) | `deepseek-harness` / `deepseek_harness` | 高层轮次 API 与低层 JSON-RPC 客户端 | +| [sdk](sdk/README.md) | `deepseek-harness-sdk` / `deepseek_harness` | 高层轮次 API 与低层 JSON-RPC 客户端 | | [sdk-runtime](sdk-runtime/README.md) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | 内置运行时二进制与默认 agent(智能体)配置 | ## 行为 diff --git a/python/development.i18n.yaml b/python/development.i18n.yaml index 1a7b57f86d..c341c32cea 100644 --- a/python/development.i18n.yaml +++ b/python/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/development.md -development.md: b0d4875f0d5b7c8fd2b4b480ac67793741640710 -development.zh.md: 053cd1d022ef5fc7d8cff98b9fc3234df6ff4cd1 +development.md: 9614c06436ab6863a5e1b2ff83fbe605552dc13b +development.zh.md: 1c646ca39735b85a5d380768fe215c92532be7e7 diff --git a/python/development.md b/python/development.md index b0d4875f0d..9614c06436 100644 --- a/python/development.md +++ b/python/development.md @@ -55,7 +55,7 @@ Build the pure SDK wheel once and one runtime wheel on each native platform: version="$(node -p "require('./package.json').version")" python scripts/build-python-release.py --package sdk --output-dir dist-python python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python -pip install --find-links dist-python deepseek-harness=="$version" +pip install --find-links dist-python deepseek-harness-sdk=="$version" ``` The runtime distribution is wheel-only. The release pipeline publishes three platform wheels with the pure SDK wheel: Linux x64, Linux arm64, and macOS arm64. A `python-vX.Y.Z` tag is accepted only when it matches the repository version. diff --git a/python/development.zh.md b/python/development.zh.md index 053cd1d022..1c646ca397 100644 --- a/python/development.zh.md +++ b/python/development.zh.md @@ -55,7 +55,7 @@ with DeepSeekHarness() as harness: version="$(node -p "require('./package.json').version")" python scripts/build-python-release.py --package sdk --output-dir dist-python python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python -pip install --find-links dist-python deepseek-harness=="$version" +pip install --find-links dist-python deepseek-harness-sdk=="$version" ``` 运行时分发包仅提供 wheel 包。发布流水线会连同纯 SDK wheel 包一起发布三个平台 wheel 包:Linux x64、Linux arm64 和 macOS arm64。只有与仓库版本匹配时,才接受 `python-vX.Y.Z` 标签。 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index bc52b5ee6c..8af8d3e1b9 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk-runtime/README.md -README.md: 07bb3c574b3cd49f1dc74f0e9d9bd1bb7ca9b216 -README.zh.md: 0613b6faf68ea3bdb6c9b673677fc483b79dab72 +README.md: 912d88938c1a8b3c79dad19ab449c7f912d57e22 +README.zh.md: 5b82f33cfe1413e4fb6ceded04d9b6feca4c94ca diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 07bb3c574b..912d88938c 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -2,14 +2,14 @@ English | [中文](README.zh.md) -Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, module `deepseek_harness_runtime`): it locates the bundled runtime binaries the `deepseek-harness` client spawns, and ships the default configuration behind zero-config runs. +Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, module `deepseek_harness_runtime`): it locates the bundled runtime binaries the `deepseek-harness-sdk` client spawns, and ships the default configuration behind zero-config runs. ## Runtime carriers Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injected by the repo's `scripts/build-exe-for-python-sdk.ts` build and both gitignored: - **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). macOS builds also ship the native `-spawn-helper` sibling that `node-pty` uses there. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. -- **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. +- **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 0613b6faf6..5b82f33cfe 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -2,14 +2,14 @@ [English](README.md) | 中文 -Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`,模块名 `deepseek_harness_runtime`):它定位 `deepseek-harness` 客户端要 spawn 的内置运行时二进制,并附带支撑零配置运行的默认配置。 +Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`,模块名 `deepseek_harness_runtime`):它定位 `deepseek-harness-sdk` 客户端要 spawn 的内置运行时二进制,并附带支撑零配置运行的默认配置。 ## 运行时载体 两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略: - **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。macOS 构建还会随附 `node-pty` 在该平台使用的原生 `-spawn-helper` 伴随文件。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 -- **node(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 +- **node(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 04a182d727..4c1c8d7ea2 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -9,6 +9,7 @@ "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/cordis-plugin-timer": "workspace:^", + "@deepseek-ai/cosmokit": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", @@ -31,6 +32,7 @@ "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index a3aa53ae80..5f3a94b3e3 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -9,7 +9,7 @@ Two runtime carriers coexist under ``runtime/``, both injected by the repo's needs no Node installation. - **node (dev-only)**: the full deploy closure under ``runtime/node/`` (``package.json`` + ``node_modules/``), executed as ``node - runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`` on a + runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`` on a system Node >= 22.19. It is the current checkout's source build, never selected automatically, and excluded from wheel/sdist distributions. @@ -131,7 +131,12 @@ def _current_platform_tag() -> str: def _node_launch_args() -> tuple[str, str]: node_root = bundled_package_dir() / "runtime" / "node" bin_js = ( - node_root / "node_modules" / "@deepseek-ai" / "dsh-jsonrpc-demo" / "lib" / "bin.js" + node_root + / "node_modules" + / "@deepseek-ai" + / "dsh-jsonrpc-demo" + / "lib" + / "packaged-bin.js" ) if not bin_js.is_file(): raise FileNotFoundError( diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 6467b4ca87..895fea6cfc 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk/README.md -README.md: 2d545688c58a2f1b755e647d7cda9555249e41c4 -README.zh.md: b335d75aedc3a145771b23ea5d408315cae9a3e3 +README.md: 9640c7e8dfd011b94acdc781ae0e4fdc8ad87378 +README.zh.md: 47ac04f9083ef41e23fda8ec527c1da160fe4769 diff --git a/python/sdk/README.md b/python/sdk/README.md index 2d545688c5..9640c7e8df 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -5,10 +5,15 @@ English | [中文](README.zh.md) Python subprocess SDK for driving DeepSeek Harness over JSON-RPC stdio. The runtime inherits normal DeepSeek Harness environment variables such as `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY`, so callers can use real model -endpoints directly or point those variables at a local proxy during -benchmark runs. +endpoints directly or point those variables at a local proxy. -Installing `deepseek-harness` installs the exact same-version `deepseek-harness-runtime-bin` platform wheel. The normal entry point therefore needs no executable argument: +Install the `deepseek-harness-sdk` distribution from PyPI; the import module remains `deepseek_harness`: + +```sh +python -m pip install deepseek-harness-sdk +``` + +Installing `deepseek-harness-sdk` installs the exact same-version `deepseek-harness-runtime-bin` platform wheel. The normal entry point therefore needs no executable argument: ```py from deepseek_harness import DeepSeekHarness @@ -35,6 +40,8 @@ with DeepSeekHarness( `provider` selects a provider route registered by the chosen Cordis composition; `model` is the model id resolved by that adapter. `max_tokens` is an optional positive per-request output-token cap for the root agent and its in-process descendants; omission leaves the provider default in control. Compaction summaries keep the separate limit configured by their compaction plugin. The bundled default composition registers `deepseek-official`. A custom composition can mount `llm-pi-ai`, configure provider-specific credentials/endpoints there, and select any provider/model present in pi-ai's installed catalog. +The [Python SDK tutorial](../../docs/user/guide/python-sdk.md) uses a complete standalone Cordis file to demonstrate installation, direct SDK usage, and runs without the Web UI. + `Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, events, notifications, session_root)`. The result has no prompt-level status or turn reason: `final_response` is the last committed root-session assistant text in the interval, not an output causally assigned to the prompt. Steering, injected context, and other queued work may contribute before idle. `HarnessClient` retains discovered subagent ancestry for the lifetime of the runtime process. During each `Session.run()`, `RunResult.notifications` and `on_notification` receive the root session and all known descendant notifications in wire order, including nested subagent lifecycle and session events. `RunResult.events` contains root-session events only, so descendant messages cannot replace the root response. The low-level `session_prompt()` returns the queued `MessageId` immediately; callers that bypass `Session.run()` own any later activity boundary themselves. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index b335d75aed..47ac04f908 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -2,9 +2,15 @@ [English](README.md) | 中文 -通过 JSON-RPC stdio 驱动 DeepSeek Harness 的 Python 子进程 SDK。运行时继承常规的 DeepSeek Harness 环境变量(如 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`),调用方可以直接用真实模型端点,也可以在跑基准测试时把它们指向本地代理。 +通过 JSON-RPC stdio 驱动 DeepSeek Harness 的 Python 子进程 SDK。运行时继承常规的 DeepSeek Harness 环境变量(如 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`),调用方可以直接使用真实模型端点,也可以把这些变量指向本地代理。 -安装 `deepseek-harness` 会同时安装版本完全相同的 `deepseek-harness-runtime-bin` 平台 wheel 包。因此常规入口不需要传可执行文件参数: +请从 PyPI 安装 `deepseek-harness-sdk` 分发包;导入模块仍为 `deepseek_harness`: + +```sh +python -m pip install deepseek-harness-sdk +``` + +安装 `deepseek-harness-sdk` 会同时安装版本完全相同的 `deepseek-harness-runtime-bin` 平台 wheel 包。因此常规入口不需要传可执行文件参数: ```py from deepseek_harness import DeepSeekHarness @@ -31,6 +37,8 @@ with DeepSeekHarness( `provider` 用于选择当前 Cordis 组合已注册的提供方路由;`model` 是该适配器解析的模型 ID。`max_tokens` 是可选的正整数,用于限制根 agent(智能体)及其进程内后代每次请求的输出 token;省略时由提供方默认值控制。压缩摘要继续使用压缩插件单独配置的上限。内置默认组合注册 `deepseek-official`。自定义组合可以挂载 `llm-pi-ai`,在其中配置各提供方的凭据与端点,再选择 pi-ai 已安装目录中的任意提供方/模型组合。 +[Python SDK 教程](../../docs/user/guide/python-sdk.md)使用完整的独立 Cordis 文件演示安装方式、直接调用 SDK,以及在不使用 Web UI 的情况下运行 agent。 + `Session.run()` 拥有一个从提示词进入持久 inbox 时开始、到整个 agent 下一次进入空闲状态为止的活动区间,并返回 `RunResult(session_id, final_response, events, notifications, session_root)`。结果不携带提示词级状态或轮次原因:`final_response` 是该区间内根会话最后提交的助手文本,并非因果上归属于该提示词的输出。steering(中途引导)、注入的上下文和其他排队工作都可能在进入空闲状态前参与其中。 `HarnessClient` 会在运行时进程的生命周期内保留已发现的 subagent(子 agent)祖先关系。每次执行 `Session.run()` 时,`RunResult.notifications` 与 `on_notification` 会按协议传输顺序收到根会话及所有已知后代的通知,其中包括嵌套 subagent 的生命周期事件与会话事件。`RunResult.events` 只包含根会话事件,因此后代消息不会覆盖根会话回复。底层 `session_prompt()` 会立即返回已排队消息的 `MessageId`;绕过 `Session.run()` 的调用方必须自行负责后续的活动边界。 diff --git a/python/sdk/pyproject.toml b/python/sdk/pyproject.toml index eeef355e90..48ffbf2499 100644 --- a/python/sdk/pyproject.toml +++ b/python/sdk/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling>=1.30.1"] build-backend = "hatchling.build" [project] -name = "deepseek-harness" +name = "deepseek-harness-sdk" version = "0.0.0.dev0" description = "Python SDK for DeepSeek Harness" readme = "README.md" diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index 7e5f660070..cf5a6cf57b 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -39,6 +39,18 @@ def test_repository_version_rejects_non_stable_versions(tmp_path: Path) -> None: build_python_release.repository_version(tmp_path) +def test_stage_sdk_keeps_distribution_module_and_runtime_pin_distinct(tmp_path: Path) -> None: + destination = tmp_path / "staging" + + build_python_release.stage_sdk(destination, "1.2.3") + + pyproject = (destination / "pyproject.toml").read_text() + assert 'name = "deepseek-harness-sdk"' in pyproject + assert 'version = "1.2.3"' in pyproject + assert '"deepseek-harness-runtime-bin==1.2.3"' in pyproject + assert (destination / "src" / "deepseek_harness" / "__init__.py").is_file() + + @pytest.mark.parametrize(("target", "with_helper"), [("linux-x64", False), ("macos-arm64", True)]) def test_stage_runtime_copies_platform_payload( tmp_path: Path, target: str, with_helper: bool diff --git a/python/sdk/uv.lock b/python/sdk/uv.lock index 94219b95ad..e2a62a9fe0 100644 --- a/python/sdk/uv.lock +++ b/python/sdk/uv.lock @@ -21,7 +21,12 @@ wheels = [ ] [[package]] -name = "deepseek-harness" +name = "deepseek-harness-runtime-bin" +version = "0.0.0.dev0" +source = { editable = "../sdk-runtime" } + +[[package]] +name = "deepseek-harness-sdk" version = "0.0.0.dev0" source = { editable = "." } dependencies = [ @@ -43,17 +48,12 @@ requires-dist = [ [package.metadata.requires-dev] test = [{ name = "pytest", specifier = ">=8.0" }] -[[package]] -name = "deepseek-harness-runtime-bin" -version = "0.0.0.dev0" -source = { editable = "../sdk-runtime" } - [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 536342dc89..a16aa93c97 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -8,7 +8,7 @@ import { spawn } from 'node:child_process' import { existsSync, statSync } from 'node:fs' -import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { chmod, copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises' import { basename, dirname, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' @@ -16,8 +16,8 @@ const root = resolve(import.meta.dirname, '..') /** The closure manifest whose dependencies define the executable. */ const DEPLOY_ROOT_PACKAGE = 'dsh-jsonrpc-agent-pkg' -/** The app entry inside the deployed closure. */ -const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js' +/** The closed-runtime app entry inside the deployed closure. */ +const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js' const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg' /** Default Node major; SEA mode requires at least Node 22. */ const DEFAULT_NODE_RANGE = 'node24' @@ -28,6 +28,8 @@ const OUT_DIR = 'dist-exe' const PYTHON_RUNTIME_DIR = 'python/sdk-runtime/src/deepseek_harness_runtime/runtime' /** The deployed closure doubles as the node-mode carrier. */ const PYTHON_NODE_SUBDIR = 'node' +/** Legacy deploy may hoist peer-specialized workspace packages back here. */ +const DEPLOY_SOURCE_NODE_MODULES = 'python/sdk-runtime/node_modules' /** Documentation excluded from the generated runtime directory. */ const DEPLOY_ONLY_DOCS = ['README.md', 'README.zh.md', 'README.i18n.yaml'] @@ -256,6 +258,8 @@ class SingleExeBuild { '--config.link-workspace-packages=true', this.staging, ]) + await this.restoreLegacyHoists() + await this.materializeStagedLinks() if (this.cli.dryRun) { for (const name of DEPLOY_ONLY_DOCS) console.log(`build-exe-for-python-sdk: [dry-run] rm -f ${join(this.staging, name)}`) } else { @@ -263,6 +267,94 @@ class SingleExeBuild { } } + /** + * Restore direct packages that pnpm's legacy hoister places beside the deploy + * source instead of in the target. The runtime manifest supplies every peer, + * so package-local node_modules trees are omitted to preserve one flat Cordis + * instance and a symlink-free packaged payload. + */ + private async restoreLegacyHoists(): Promise { + if (this.cli.dryRun) { + console.log('build-exe-for-python-sdk: [dry-run] restore direct dependencies omitted by legacy deploy') + return + } + const manifestPath = join(this.staging, 'package.json') + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { + dependencies?: Record + } + const sourceNodeModules = resolve(root, DEPLOY_SOURCE_NODE_MODULES) + const restored: string[] = [] + for (const dependency of Object.keys(manifest.dependencies ?? {}).sort()) { + const destination = join(this.staging, 'node_modules', dependency) + if (existsSync(destination)) continue + const source = join(sourceNodeModules, dependency) + if (!existsSync(source)) { + throw new Error( + `build-exe-for-python-sdk: deployed dependency ${dependency} is absent from both ${destination} and ${source}.`, + ) + } + await mkdir(dirname(destination), { recursive: true }) + const nestedNodeModules = join(source, 'node_modules') + await cp(source, destination, { + recursive: true, + dereference: true, + filter: path => path !== nestedNodeModules && !path.startsWith(nestedNodeModules + sep), + }) + restored.push(dependency) + } + const stillMissing = Object.keys(manifest.dependencies ?? {}) + .filter(dependency => !existsSync(join(this.staging, 'node_modules', dependency))) + if (stillMissing.length > 0) { + throw new Error(`build-exe-for-python-sdk: staged dependencies remain missing: ${stillMissing.join(', ')}.`) + } + if (restored.length > 0) { + console.log(`build-exe-for-python-sdk: restored legacy deploy hoists: ${restored.join(', ')}`) + } + } + + /** Replace deploy-time package links with files and reject any remaining link. */ + private async materializeStagedLinks(): Promise { + if (this.cli.dryRun) { + console.log('build-exe-for-python-sdk: [dry-run] materialize staged package links') + return + } + const nodeModules = join(this.staging, 'node_modules') + let remaining = await this.findSymlink(nodeModules) + while (remaining !== undefined) { + const segments = remaining.slice(nodeModules.length + 1).split(sep) + const binIndex = segments.lastIndexOf('.bin') + if (binIndex >= 0) { + await rm(join(nodeModules, ...segments.slice(0, binIndex + 1)), { recursive: true, force: true }) + remaining = await this.findSymlink(nodeModules) + continue + } + const destination = remaining + const source = await realpath(destination) + const nestedNodeModules = join(source, 'node_modules') + await rm(destination, { recursive: true, force: true }) + await cp(source, destination, { + recursive: true, + dereference: true, + filter: path => path !== nestedNodeModules && !path.startsWith(nestedNodeModules + sep), + }) + remaining = await this.findSymlink(nodeModules) + } + } + + /** Return the first symbolic link below a directory, if one exists. */ + private async findSymlink(directory: string): Promise { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + const metadata = await lstat(path) + if (metadata.isSymbolicLink()) return path + if (metadata.isDirectory()) { + const nested = await this.findSymlink(path) + if (nested !== undefined) return nested + } + } + return undefined + } + /** Add the executable entry and pkg assets to the staged manifest. */ async injectPkgConfig(): Promise { const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS } } diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index ec049cdd4f..c9ee5e31c0 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -17,6 +17,8 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +SDK_DISTRIBUTION = "deepseek-harness-sdk" +RUNTIME_DISTRIBUTION = "deepseek-harness-runtime-bin" PLATFORMS = { "linux-x64": ("manylinux_2_28_x86_64", "dsh-jsonrpc-agent-pkg-linux-x64"), "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), @@ -53,7 +55,7 @@ def main() -> None: if args.package == "sdk": stage_sdk(staging, version) environment = None - expected = output_dir / f"deepseek_harness-{version}-py3-none-any.whl" + expected = output_dir / f"deepseek_harness_sdk-{version}-py3-none-any.whl" else: platform_tag, executable_name = PLATFORMS[args.platform] stage_runtime(staging, version, args.runtime_exe.resolve(), executable_name) @@ -160,6 +162,11 @@ def verify_wheel( raise RuntimeError(f"{wheel} has wrong WHEEL tags: {wheel_metadata.get_all('Tag')}") if metadata.get("Version") != version: raise RuntimeError(f"{wheel} has version {metadata.get('Version')}, expected {version}") + expected_distribution = SDK_DISTRIBUTION if package == "sdk" else RUNTIME_DISTRIBUTION + if metadata.get("Name") != expected_distribution: + raise RuntimeError( + f"{wheel} has distribution name {metadata.get('Name')}, expected {expected_distribution}" + ) runtime_files = [ name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name ] @@ -177,7 +184,7 @@ def verify_wheel( raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") if package == "sdk": requirements = metadata.get_all("Requires-Dist") or [] - expected_requirement = f"deepseek-harness-runtime-bin=={version}" + expected_requirement = f"{RUNTIME_DISTRIBUTION}=={version}" if expected_requirement not in requirements: raise RuntimeError(f"{wheel} does not pin {expected_requirement}; found {requirements}") diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index bb06639e65..49ff148922 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -126,9 +126,13 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-headless': ['cordis.patch.yml'], '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], '@deepseek-ai/dsh-helper': ['lib/assets'], + // The Python runtime uses a distinct closed-resolution bin; the public CLI + // keeps config-owned bare-package resolution through lib/bin.js. + '@deepseek-ai/dsh-jsonrpc-demo': ['lib/packaged-bin.js'], // The argv-prefix runner entry ships beside the lib as its own bundle; - // sandbox-local resolves it through the package's ./runner export. - '@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js'], + // sandbox-local resolves it through the package's ./runner export. tsdown + // also shares its generated FFI code through a hashed runtime chunk. + '@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js', 'lib/types-*.js'], '@deepseek-ai/dsh-skill-badge': ['assets'], '@deepseek-ai/dsh-subprocess-local': ['scripts/ensure-spawn-helper.mjs'], '@deepseek-ai/dsh-scripts': [ @@ -161,6 +165,9 @@ function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { ...exportDefault(manifest, './loader') === './lib/loader.js' ? ['lib/loader.js'] : [], // web-react's store subpath ships its own bundle (single-entry builds; no shared chunk). ...exportDefault(manifest, './store') === './lib/store/index.js' ? ['lib/store/index.js'] : [], + // A surface bundle's startup row is its own bundle: the Loader imports it + // as a row module, so it cannot ride inside the package entry. + ...exportDefault(manifest, './startup') === './lib/startup.js' ? ['lib/startup.js'] : [], ...extras, // Subpaths whose runtime default is the tsc-emitted tree (lib/types/*.js — // browser-safe source channels rehomed off src so plain Node can import diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 7ee9320cea..79419be5ed 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -111,6 +111,8 @@ export const SERVICE_PAGE: Record = { */ export const SERVICE_WALK_EXEMPTIONS: Record = { agent: 'not a service: the DX accessor field on Agent.ctx (root accessor defaulting to undefined) — docs/subsystems/core.md owns the Agent handle', + appExit: 'not a service: launcher-provided bounded process-exit callback — packages/boot/cmdline/README.md owns the launcher contract', + cmdlineArgs: 'not a service: launcher-provided immutable app argument accessor — packages/boot/cmdline/README.md owns the launcher contract', configuredAgentIdentities: 'not a service: launcher-provided boot-context value (ConfiguredAgentIdentities | undefined) — packages/core/agent-loop/README.md owns this launcher contract', launcherSessionQueryPath: 'not a service: launcher-provided boot-context value (string | undefined) — packages/session-query/session-query-sqlite/README.md owns this launcher contract', dshHomePath: 'not a service: boot-provided root accessor function (typeof dshHomePath | undefined) for Loader !!js config expressions — packages/boot/app-boot/README.md owns the boot contract', diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index eec3a4d942..4063219723 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -231,7 +231,7 @@ describe('collectPythonDependencies', () => { it('excludes normalized local project names without exempting a third-party prefix', () => { const pyprojects = [ '[project]\nname = "deepseek-harness-runtime-bin"\ndependencies = ["pydantic"]\n', - '[project]\nname = "deepseek-harness"\ndependencies = ["DeepSeek.Harness_Runtime-Bin", "deepseek-unrelated"]\n', + '[project]\nname = "deepseek-harness-sdk"\ndependencies = ["DeepSeek.Harness_Runtime-Bin", "deepseek-unrelated"]\n', ] expect(() => collectPythonDependencies(pyprojects)).toThrow( 'python dependency deepseek-unrelated is missing from PYTHON_METADATA', diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index e161902974..faf8c756dd 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -83,7 +83,7 @@ const OVERRIDES: Record = { * the generator fails when a manifest names a package this map misses. */ const PYTHON_METADATA: Record = { - pydantic: { license: 'MIT', repo: 'https://github.com/pydantic/pydantic', role: 'runtime dependency of `deepseek-harness`' }, + pydantic: { license: 'MIT', repo: 'https://github.com/pydantic/pydantic', role: 'runtime dependency of `deepseek-harness-sdk`' }, hatchling: { license: 'MIT', repo: 'https://github.com/pypa/hatch', role: 'build backend' }, pytest: { license: 'MIT', repo: 'https://github.com/pytest-dev/pytest', role: 'test-only' }, } diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index e083d375e3..bca3183795 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -126,7 +126,7 @@ const POSTCONDITIONS: readonly PostCondition[] = [ { file: 'packages/boot/app-boot/tsdown.config.ts', text: '[\'@deepseek-ai/cordis-plugin-include\']', count: 1 }, { file: 'tsconfig.base.json', text: '"@deepseek-ai/cordis-plugin-loader": ["./vendor/loader/src"]', count: 1 }, // One insertion, once: a duplicated log entry is what a non-idempotent apply produced. - { file: 'vendor/README.md', text: '15. **`@deepseek-ai` rescope**', count: 1 }, + { file: 'vendor/README.md', text: '17. **`@deepseek-ai` rescope**', count: 1 }, { file: 'knip.json', text: '@cordisjs', count: 0 }, { file: 'pnpm-workspace.yaml', text: 'cordis@4.0.0-rc.7', count: 0 }, // The preset ids in this table are product data, not package names. @@ -319,7 +319,7 @@ const EXACT_EDITS: readonly ExactEdit[] = [ id: 'vendor-readme-local-modification-log', file: 'vendor/README.md', find: '\n## Sync procedure', - replace: '15. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table\'s `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for(\'schemastery\')` and Schemastery\'s `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table\'s two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).\n\n## Sync procedure', + replace: '17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table\'s `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for(\'schemastery\')` and Schemastery\'s `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table\'s two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).\n\n## Sync procedure', expect: 1, }, { diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 415784cd7a..910b4ffc0a 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -17,7 +17,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Callable if TYPE_CHECKING: - from deepseek_harness import TurnResult + from deepseek_harness import RunResult EXPECTED_TEXT = "runtime smoke ok" @@ -25,10 +25,14 @@ CODE_PROMPT = "Use run_code to compute the packaged worker smoke value." CODE_WORKER_TEXT = "code worker smoke ok" WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value without agents." WORKFLOW_WORKER_TEXT = "workflow worker smoke ok" -PERSISTENT_TOOLS_PROMPT = "Exercise the packaged persistent Bash and string-replacement editor." -PERSISTENT_TOOLS_TEXT = "persistent tools smoke ok" -PERSISTENT_EDITOR_PATH_PREFIX = "Editor path: " -PERSISTENT_BASH_COMMAND = ( +MINIMAL_PROMPT = "Exercise the packaged minimal agent's persistent Bash and string-replacement editor." +MINIMAL_TEXT = "minimal agent smoke ok" +MINIMAL_EDITOR_PATH_PREFIX = "Editor path: " +MINIMAL_SYSTEM_PROMPT = "You are a helpful software engineer assistant." +MINIMAL_CORDIS = ( + Path(__file__).resolve().parent.parent / "examples" / "jsonrpc-agent" / "minimal.cordis.yml" +) +MINIMAL_BASH_COMMAND = ( "counter=$(( ${counter:-0} + 1 )); export counter; " "printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; " "if [ \"$counter\" -eq 1 ]; then cd /tmp; fi" @@ -103,51 +107,6 @@ CUSTOM_CORDIS = """\ - id: cordis-tool name: '@deepseek-ai/dsh-tool-cordis' """ -PERSISTENT_TOOLS_CORDIS = """\ -- id: jsonrpc - name: '@deepseek-ai/dsh-jsonrpc' -- id: llm - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL -- id: sandbox - name: '@deepseek-ai/dsh-sandbox-local' -- id: sandbox-policy - name: '@deepseek-ai/dsh-sandbox-policy' - config: - mode: danger-full-access - workspaceRoot: !!js process.env.DSH_CWD -- id: pty - name: '@deepseek-ai/dsh-pty' -- id: pty-local - name: '@deepseek-ai/dsh-pty-local' -- id: fs - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.env.DSH_CWD -- id: agent-core - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - includeHarnessIdentity: false - persona: 'You are a helpful software engineer assistant.' - workspaceContext: false - skills: - enabled: false - toolBash: false - toolTasks: false -- id: sessions - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: !!js process.env.DSH_SESSION_ROOT - compression: 'none' -- id: persistent-bash - name: '@deepseek-ai/dsh-tool-bash-persistent' -- id: str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' -""" - - class MockModelHandler(BaseHTTPRequestHandler): """Return deterministic text, worker, and orchestration completions.""" @@ -182,9 +141,9 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: if latest.get("role") == "tool": call_id, tool_name = latest_tool_call(messages) tool_text = message_text(latest.get("content")) - persistent = persistent_tool_followup(body, call_id, tool_name, tool_text) - if persistent is not None: - return persistent + minimal = minimal_tool_followup(body, call_id, tool_name, tool_text) + if minimal is not None: + return minimal advanced = advanced_tool_followup(body, call_id, tool_name, tool_text) if advanced is not None: return advanced @@ -196,16 +155,35 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: return text_chunks(WORKFLOW_WORKER_TEXT) raise AssertionError(f"unexpected tool follow-up: {tool_name}") - prompt = message_text(latest.get("content")) - if prompt.startswith(f"{PERSISTENT_TOOLS_PROMPT}\n{PERSISTENT_EDITOR_PATH_PREFIX}"): + minimal_prompt = next( + ( + message_text(message.get("content")) + for message in reversed(messages) + if isinstance(message, dict) + and message.get("role") == "user" + and message_text(message.get("content")).startswith( + f"{MINIMAL_PROMPT}\n{MINIMAL_EDITOR_PATH_PREFIX}" + ) + ), + None, + ) + if minimal_prompt is not None: names = advertised_tool_names(body) if names != {"bash", "str_replace_editor"}: - raise AssertionError(f"persistent tools smoke advertised unexpected tools: {names}") + raise AssertionError(f"minimal agent smoke advertised unexpected tools: {names}") + system_prompts = [ + message_text(message.get("content")) + for message in messages + if isinstance(message, dict) and message.get("role") == "system" + ] + if system_prompts != [MINIMAL_SYSTEM_PROMPT]: + raise AssertionError(f"minimal agent smoke assembled unexpected system prompts: {system_prompts}") return tool_call_chunks( - "persistent-bash-1", + "minimal-bash-1", "bash", - {"command": PERSISTENT_BASH_COMMAND}, + {"command": MINIMAL_BASH_COMMAND}, ) + prompt = message_text(latest.get("content")) if prompt == SNAPSHOT_DIRECT_CHILD_PROMPT: return text_chunks("DIRECT_CHILD_OK") if prompt == SNAPSHOT_WORKFLOW_CHILD_PROMPT: @@ -240,24 +218,24 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: return text_chunks(EXPECTED_TEXT) -def persistent_tool_followup( +def minimal_tool_followup( body: dict[str, object], call_id: str, tool_name: str, tool_text: str, ) -> list[dict[str, object]] | None: - """Verify packaged PTY persistence, then invoke the packaged editor.""" - if not call_id.startswith("persistent-"): + """Verify the checked-in minimal composition's PTY and editor.""" + if not call_id.startswith("minimal-"): return None - if call_id == "persistent-bash-1" and tool_name == "bash": + if call_id == "minimal-bash-1" and tool_name == "bash": if "COUNT=1" not in tool_text: raise AssertionError(f"first persistent bash call lost its output: {tool_text}") return tool_call_chunks( - "persistent-bash-2", + "minimal-bash-2", "bash", - {"command": PERSISTENT_BASH_COMMAND}, + {"command": MINIMAL_BASH_COMMAND}, ) - if call_id == "persistent-bash-2" and tool_name == "bash": + if call_id == "minimal-bash-2" and tool_name == "bash": if "COUNT=2 CWD=/tmp" not in tool_text: raise AssertionError(f"persistent bash did not retain state: {tool_text}") messages = body.get("messages") @@ -265,18 +243,18 @@ def persistent_tool_followup( raise AssertionError("persistent editor smoke request has no messages") editor_path = next( ( - text.split(PERSISTENT_EDITOR_PATH_PREFIX, 1)[1].strip() + text.split(MINIMAL_EDITOR_PATH_PREFIX, 1)[1].strip() for message in messages if isinstance(message, dict) and message.get("role") == "user" for text in [message_text(message.get("content"))] - if PERSISTENT_EDITOR_PATH_PREFIX in text + if MINIMAL_EDITOR_PATH_PREFIX in text ), None, ) if editor_path is None: raise AssertionError("persistent editor smoke prompt has no editor path") return tool_call_chunks( - "persistent-editor", + "minimal-editor", "str_replace_editor", { "command": "create", @@ -284,11 +262,11 @@ def persistent_tool_followup( "file_text": "created by packaged editor\n", }, ) - if call_id == "persistent-editor" and tool_name == "str_replace_editor": + if call_id == "minimal-editor" and tool_name == "str_replace_editor": if "New file created successfully" not in tool_text: raise AssertionError(f"packaged editor did not create its file: {tool_text}") - return text_chunks(PERSISTENT_TOOLS_TEXT) - raise AssertionError(f"unexpected persistent-tools follow-up: {call_id} {tool_name}: {tool_text}") + return text_chunks(MINIMAL_TEXT) + raise AssertionError(f"unexpected minimal-agent follow-up: {call_id} {tool_name}: {tool_text}") def advanced_tool_followup( @@ -470,14 +448,14 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--scenario", - choices=("all", "sdk-default", "sdk-custom", "sdk-persistent", "sdk-snapshot", "direct"), + choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-snapshot", "direct"), default="all", ) parser.add_argument("--exe", type=Path) parser.add_argument("--update-snapshots", action="store_true") args = parser.parse_args() - if args.scenario in {"all", "sdk-custom", "sdk-persistent", "sdk-snapshot", "direct"} and args.exe is None: - parser.error("--exe is required for custom, persistent, snapshot, and direct scenarios") + if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-snapshot", "direct"} and args.exe is None: + parser.error("--exe is required for custom, minimal, snapshot, and direct scenarios") if args.update_snapshots and args.scenario not in {"all", "sdk-snapshot"}: parser.error("--update-snapshots requires --scenario sdk-snapshot or all") if args.exe is not None and not args.exe.is_file(): @@ -489,9 +467,9 @@ def main() -> None: if args.scenario in {"all", "sdk-custom"}: assert args.exe is not None smoke_sdk_custom(model.url, args.exe.resolve()) - if args.scenario in {"all", "sdk-persistent"}: + if args.scenario in {"all", "sdk-minimal"}: assert args.exe is not None - smoke_sdk_persistent_tools(model.url, args.exe.resolve()) + smoke_sdk_minimal(model.url, args.exe.resolve()) if args.scenario in {"all", "sdk-snapshot"}: assert args.exe is not None smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots) @@ -519,7 +497,6 @@ def smoke_sdk_default(base_url: str) -> None: request_timeout_seconds=60, ) as harness: result = harness.run("reply with the smoke text", session_id="default-smoke") - assert result.status == "ok", result assert result.final_response == EXPECTED_TEXT, result.final_response assert_zstd_session_log(sessions) @@ -546,46 +523,40 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None: text_result = harness.run("reply with the smoke text", session_id="custom-smoke") code_result = harness.run(CODE_PROMPT, session_id="custom-smoke") workflow_result = harness.run(WORKFLOW_PROMPT, session_id="custom-smoke") - assert text_result.status == "ok", text_result assert text_result.final_response == EXPECTED_TEXT, text_result.final_response - assert code_result.status == "ok", code_result assert code_result.final_response == CODE_WORKER_TEXT, code_result.final_response - assert workflow_result.status == "ok", workflow_result assert workflow_result.final_response == WORKFLOW_WORKER_TEXT, workflow_result.final_response assert_session_log(sessions, root, EXPECTED_TEXT, CODE_WORKER_TEXT, WORKFLOW_WORKER_TEXT) -def smoke_sdk_persistent_tools(base_url: str, executable: Path) -> None: - """Exercise native PTY state and the editor through the packaged executable.""" +def smoke_sdk_minimal(base_url: str, executable: Path) -> None: + """Exercise the checked-in minimal composition through the packaged executable.""" from deepseek_harness import DeepSeekHarness - with tempfile.TemporaryDirectory(prefix="dsh-sdk-persistent-tools-") as temporary: + with tempfile.TemporaryDirectory(prefix="dsh-sdk-minimal-") as temporary: root = Path(temporary).resolve() editor_path = root / "created.txt" - prompt = f"{PERSISTENT_TOOLS_PROMPT}\n{PERSISTENT_EDITOR_PATH_PREFIX}{editor_path}" + prompt = f"{MINIMAL_PROMPT}\n{MINIMAL_EDITOR_PATH_PREFIX}{editor_path}" sessions = root / "sessions" - cordis = root / "cordis.yml" - cordis.write_text(PERSISTENT_TOOLS_CORDIS) with DeepSeekHarness( - provider="deepseek", + provider="deepseek-official", model="smoke-model", cwd=str(root), session_root=str(sessions), - cordis=str(cordis), + cordis=str(MINIMAL_CORDIS), runtime_bin=str(executable), api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60, ) as harness: - result = harness.run(prompt, session_id="persistent-tools-smoke") + result = harness.run(prompt, session_id="minimal-agent-smoke") - assert result.status == "ok", result event_text = json.dumps(result.events) - if PERSISTENT_TOOLS_TEXT not in event_text: - raise AssertionError(f"packaged tools run emitted no final response: {result.events}") + if MINIMAL_TEXT not in event_text: + raise AssertionError(f"minimal agent run emitted no final response: {result.events}") if editor_path.read_text() != "created by packaged editor\n": raise AssertionError(f"packaged editor wrote unexpected content: {editor_path.read_text()!r}") - assert_session_log(sessions, root, PERSISTENT_TOOLS_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp") + assert_session_log(sessions, root, MINIMAL_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp") def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None: @@ -610,7 +581,6 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) ) as harness: result = harness.run(SNAPSHOT_PROMPT, session_id=SNAPSHOT_SESSION_ID) - assert result.status == "ok", result assert result.final_response == SNAPSHOT_FINAL_TEXT, result.final_response methods = [notification.method for notification in result.notifications] if methods.count("subagent.started") != 2 or methods.count("subagent.finished") != 2: @@ -657,8 +627,8 @@ def smoke_direct(base_url: str, executable: Path) -> None: "params": {"sessionId": "direct-smoke", "contentBlocks": [{"type": "text", "text": "reply with the smoke text"}]}, }) messages = peer.read_until(lambda message: message.get("id") == "prompt") - if not any(message.get("method") == "session.finished" and message.get("params", {}).get("status") == "ok" for message in messages): - messages.extend(peer.read_until(lambda message: message.get("method") == "session.finished")) + if not any(is_idle_notification(message) for message in messages): + messages.extend(peer.read_until(is_idle_notification)) event_text = json.dumps(messages) if EXPECTED_TEXT not in event_text: raise AssertionError(f"direct runtime emitted no final response: {messages}") @@ -669,6 +639,16 @@ def smoke_direct(base_url: str, executable: Path) -> None: assert_session_log(sessions, root, EXPECTED_TEXT) +def is_idle_notification(message: dict[str, object]) -> bool: + """Return whether a JSON-RPC notification marks a session idle.""" + params = message.get("params") + return ( + message.get("method") == "session.status" + and isinstance(params, dict) + and params.get("status") == "idle" + ) + + class RuntimePeer: def __init__(self, argv: list[str], cwd: Path, environment: dict[str, str]) -> None: self.process = subprocess.Popen( @@ -776,7 +756,7 @@ def read_session_logs(sessions: Path) -> dict[str, list[dict[str, object]]]: return logs -def snapshot_child_ids(result: "TurnResult") -> list[str]: +def snapshot_child_ids(result: "RunResult") -> list[str]: """Return the two child session ids in their SDK notification order.""" child_ids: list[str] = [] for notification in result.notifications: @@ -794,7 +774,7 @@ def snapshot_child_ids(result: "TurnResult") -> list[str]: def build_snapshot_files( - result: "TurnResult", + result: "RunResult", logs: dict[str, list[dict[str, object]]], child_ids: list[str], cwd: Path, @@ -809,7 +789,6 @@ def build_snapshot_files( result_value = { "session_id": result.session_id, - "status": result.status, "final_response": result.final_response, "events": result.events, "notifications": [ @@ -834,7 +813,7 @@ def build_snapshot_files( return files -def snapshot_agent_id(result: "TurnResult", child_id: str) -> str: +def snapshot_agent_id(result: "RunResult", child_id: str) -> str: """Find the successful subagent id paired with one child session.""" for notification in result.notifications: if notification.method != "subagent.finished": diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index 79daa0570c..dff04d578e 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -1,25 +1,62 @@ { "session_id": "{{parent}}", - "status": "ok", "final_response": "ADVANCED_EXECUTABLE_OK", "events": [ { - "type": "turn/start", + "type": "agent/inbox/spliced", "seq": 0, "time": 0, "data": { - "turn": 1, - "trigger": { - "kind": "message", - "source": { - "kind": "user" + "target": "next-turn", + "start": 0, + "inserted": [ + { + "content": [ + { + "type": "text", + "text": "Run the advanced packaged-runtime snapshot scenario." + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "{{messageId}}" } - } + ] + } + }, + { + "type": "turn/start", + "seq": 1, + "time": 0, + "data": { + "turn": 1 + } + }, + { + "type": "agent/inbox/spliced", + "seq": 2, + "time": 0, + "data": { + "target": "next-turn", + "start": 0, + "removedCount": 1, + "inserted": [] + } + }, + { + "type": "step/start", + "seq": 3, + "time": 0, + "data": { + "turn": 1, + "step": 1 } }, { "type": "user/message", - "seq": 1, + "seq": 4, "time": 0, "data": { "content": [ @@ -38,38 +75,34 @@ }, { "type": "session/title", - "seq": 2, + "seq": 5, "time": 0, "data": { "title": "Run the advanced packaged-runtime snapsh", "messageSeqs": [ - 1 + 4 ], "source": { "kind": "fallback" } } }, - { - "type": "step/start", - "seq": 3, - "time": 0, - "data": { - "turn": 1, - "step": 1 - } - }, { "type": "request/header", - "seq": 4, + "seq": 6, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -86,9 +119,19 @@ "reason": "initial" } }, + { + "type": "request/context", + "seq": 7, + "time": 0, + "data": { + "provider": "deepseek-official", + "model": "smoke-model", + "contextWindow": 1000000 + } + }, { "type": "assistant/chunk", - "seq": 5, + "seq": 8, "time": 0, "data": { "turn": 1, @@ -102,7 +145,7 @@ }, { "type": "assistant/chunk", - "seq": 6, + "seq": 9, "time": 0, "data": { "turn": 1, @@ -118,7 +161,7 @@ }, { "type": "assistant/chunk", - "seq": 7, + "seq": 10, "time": 0, "data": { "turn": 1, @@ -137,7 +180,7 @@ }, { "type": "assistant/chunk", - "seq": 8, + "seq": 11, "time": 0, "data": { "turn": 1, @@ -153,7 +196,7 @@ }, { "type": "assistant/chunk", - "seq": 9, + "seq": 12, "time": 0, "data": { "turn": 1, @@ -168,7 +211,7 @@ }, { "type": "assistant/message", - "seq": 10, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -185,7 +228,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -196,17 +239,17 @@ } }, "sourceEventSeqs": [ - 5, - 6, - 7, 8, - 9 + 9, + 10, + 11, + 12 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 11, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -218,7 +261,7 @@ }, { "type": "tool/result", - "seq": 12, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -246,13 +289,13 @@ } }, "sourceEventSeqs": [ - 11 + 14 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 13, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -261,7 +304,7 @@ }, { "type": "step/start", - "seq": 14, + "seq": 17, "time": 0, "data": { "turn": 1, @@ -270,15 +313,20 @@ }, { "type": "request/header", - "seq": 15, + "seq": 18, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -298,7 +346,7 @@ }, { "type": "assistant/chunk", - "seq": 16, + "seq": 19, "time": 0, "data": { "turn": 1, @@ -312,7 +360,7 @@ }, { "type": "assistant/chunk", - "seq": 17, + "seq": 20, "time": 0, "data": { "turn": 1, @@ -328,7 +376,7 @@ }, { "type": "assistant/chunk", - "seq": 18, + "seq": 21, "time": 0, "data": { "turn": 1, @@ -347,7 +395,7 @@ }, { "type": "assistant/chunk", - "seq": 19, + "seq": 22, "time": 0, "data": { "turn": 1, @@ -363,7 +411,7 @@ }, { "type": "assistant/chunk", - "seq": 20, + "seq": 23, "time": 0, "data": { "turn": 1, @@ -378,7 +426,7 @@ }, { "type": "assistant/message", - "seq": 21, + "seq": 24, "time": 0, "data": { "turn": 1, @@ -395,7 +443,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -406,17 +454,17 @@ } }, "sourceEventSeqs": [ - 16, - 17, - 18, 19, - 20 + 20, + 21, + 22, + 23 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 22, + "seq": 25, "time": 0, "data": { "turn": 1, @@ -428,9 +476,10 @@ }, { "type": "tool/code-dispatch-start", - "seq": 23, + "seq": 26, "time": 0, "data": { + "rootCallId": "advanced-code", "parentCallId": "advanced-code", "subCallId": "advanced-code:code:1", "name": "snapshot_double", @@ -441,9 +490,10 @@ }, { "type": "tool/code-dispatch", - "seq": 24, + "seq": 27, "time": 0, "data": { + "rootCallId": "advanced-code", "parentCallId": "advanced-code", "subCallId": "advanced-code:code:1", "name": "snapshot_double", @@ -461,7 +511,7 @@ }, { "type": "tool/result", - "seq": 25, + "seq": 28, "time": 0, "data": { "turn": 1, @@ -489,13 +539,13 @@ } }, "sourceEventSeqs": [ - 22 + 25 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 26, + "seq": 29, "time": 0, "data": { "turn": 1, @@ -504,7 +554,7 @@ }, { "type": "step/start", - "seq": 27, + "seq": 30, "time": 0, "data": { "turn": 1, @@ -513,7 +563,7 @@ }, { "type": "assistant/chunk", - "seq": 28, + "seq": 31, "time": 0, "data": { "turn": 1, @@ -527,7 +577,7 @@ }, { "type": "assistant/chunk", - "seq": 29, + "seq": 32, "time": 0, "data": { "turn": 1, @@ -543,7 +593,7 @@ }, { "type": "assistant/chunk", - "seq": 30, + "seq": 33, "time": 0, "data": { "turn": 1, @@ -562,7 +612,7 @@ }, { "type": "assistant/chunk", - "seq": 31, + "seq": 34, "time": 0, "data": { "turn": 1, @@ -578,7 +628,7 @@ }, { "type": "assistant/chunk", - "seq": 32, + "seq": 35, "time": 0, "data": { "turn": 1, @@ -593,7 +643,7 @@ }, { "type": "assistant/message", - "seq": 33, + "seq": 36, "time": 0, "data": { "turn": 1, @@ -610,7 +660,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -621,17 +671,17 @@ } }, "sourceEventSeqs": [ - 28, - 29, - 30, 31, - 32 + 32, + 33, + 34, + 35 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 34, + "seq": 37, "time": 0, "data": { "turn": 1, @@ -643,7 +693,7 @@ }, { "type": "tool/result", - "seq": 35, + "seq": 38, "time": 0, "data": { "turn": 1, @@ -671,13 +721,13 @@ } }, "sourceEventSeqs": [ - 34 + 37 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 36, + "seq": 39, "time": 0, "data": { "turn": 1, @@ -686,7 +736,7 @@ }, { "type": "step/start", - "seq": 37, + "seq": 40, "time": 0, "data": { "turn": 1, @@ -695,7 +745,7 @@ }, { "type": "assistant/chunk", - "seq": 38, + "seq": 41, "time": 0, "data": { "turn": 1, @@ -709,7 +759,7 @@ }, { "type": "assistant/chunk", - "seq": 39, + "seq": 42, "time": 0, "data": { "turn": 1, @@ -725,7 +775,7 @@ }, { "type": "assistant/chunk", - "seq": 40, + "seq": 43, "time": 0, "data": { "turn": 1, @@ -744,7 +794,7 @@ }, { "type": "assistant/chunk", - "seq": 41, + "seq": 44, "time": 0, "data": { "turn": 1, @@ -760,7 +810,7 @@ }, { "type": "assistant/chunk", - "seq": 42, + "seq": 45, "time": 0, "data": { "turn": 1, @@ -775,7 +825,7 @@ }, { "type": "assistant/message", - "seq": 43, + "seq": 46, "time": 0, "data": { "turn": 1, @@ -792,7 +842,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -803,17 +853,17 @@ } }, "sourceEventSeqs": [ - 38, - 39, - 40, 41, - 42 + 42, + 43, + 44, + 45 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 44, + "seq": 47, "time": 0, "data": { "turn": 1, @@ -825,7 +875,7 @@ }, { "type": "tool/result", - "seq": 45, + "seq": 48, "time": 0, "data": { "turn": 1, @@ -853,13 +903,13 @@ } }, "sourceEventSeqs": [ - 44 + 47 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 46, + "seq": 49, "time": 0, "data": { "turn": 1, @@ -868,7 +918,7 @@ }, { "type": "step/start", - "seq": 47, + "seq": 50, "time": 0, "data": { "turn": 1, @@ -877,7 +927,7 @@ }, { "type": "assistant/chunk", - "seq": 48, + "seq": 51, "time": 0, "data": { "turn": 1, @@ -891,7 +941,7 @@ }, { "type": "assistant/chunk", - "seq": 49, + "seq": 52, "time": 0, "data": { "turn": 1, @@ -907,7 +957,7 @@ }, { "type": "assistant/chunk", - "seq": 50, + "seq": 53, "time": 0, "data": { "turn": 1, @@ -926,7 +976,7 @@ }, { "type": "assistant/chunk", - "seq": 51, + "seq": 54, "time": 0, "data": { "turn": 1, @@ -942,7 +992,7 @@ }, { "type": "assistant/chunk", - "seq": 52, + "seq": 55, "time": 0, "data": { "turn": 1, @@ -957,7 +1007,7 @@ }, { "type": "assistant/message", - "seq": 53, + "seq": 56, "time": 0, "data": { "turn": 1, @@ -974,7 +1024,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -985,17 +1035,17 @@ } }, "sourceEventSeqs": [ - 48, - 49, - 50, 51, - 52 + 52, + 53, + 54, + 55 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 54, + "seq": 57, "time": 0, "data": { "turn": 1, @@ -1007,7 +1057,7 @@ }, { "type": "tool/result", - "seq": 55, + "seq": 58, "time": 0, "data": { "turn": 1, @@ -1035,13 +1085,13 @@ } }, "sourceEventSeqs": [ - 54 + 57 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 56, + "seq": 59, "time": 0, "data": { "turn": 1, @@ -1050,7 +1100,7 @@ }, { "type": "step/start", - "seq": 57, + "seq": 60, "time": 0, "data": { "turn": 1, @@ -1059,15 +1109,20 @@ }, { "type": "request/header", - "seq": 58, + "seq": 61, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -1086,7 +1141,7 @@ }, { "type": "assistant/chunk", - "seq": 59, + "seq": 62, "time": 0, "data": { "turn": 1, @@ -1100,7 +1155,7 @@ }, { "type": "assistant/chunk", - "seq": 60, + "seq": 63, "time": 0, "data": { "turn": 1, @@ -1114,7 +1169,7 @@ }, { "type": "assistant/chunk", - "seq": 61, + "seq": 64, "time": 0, "data": { "turn": 1, @@ -1131,7 +1186,7 @@ }, { "type": "assistant/chunk", - "seq": 62, + "seq": 65, "time": 0, "data": { "turn": 1, @@ -1147,7 +1202,7 @@ }, { "type": "assistant/chunk", - "seq": 63, + "seq": 66, "time": 0, "data": { "turn": 1, @@ -1162,7 +1217,7 @@ }, { "type": "assistant/message", - "seq": 64, + "seq": 67, "time": 0, "data": { "turn": 1, @@ -1177,7 +1232,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -1188,17 +1243,17 @@ } }, "sourceEventSeqs": [ - 59, - 60, - 61, 62, - 63 + 63, + 64, + 65, + 66 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 65, + "seq": 68, "time": 0, "data": { "turn": 1, @@ -1207,7 +1262,7 @@ }, { "type": "turn/end", - "seq": 66, + "seq": 69, "time": 0, "data": { "turn": 1, @@ -1223,17 +1278,48 @@ "payload": { "sessionId": "{{parent}}", "event": { - "type": "turn/start", + "type": "agent/inbox/spliced", "seq": 0, "time": 0, "data": { - "turn": 1, - "trigger": { - "kind": "message", - "source": { - "kind": "user" + "target": "next-turn", + "start": 0, + "inserted": [ + { + "content": [ + { + "type": "text", + "text": "Run the advanced packaged-runtime snapshot scenario." + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "{{messageId}}" } - } + ] + } + } + } + }, + { + "method": "session.status", + "payload": { + "sessionId": "{{parent}}", + "status": "running" + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "turn/start", + "seq": 1, + "time": 0, + "data": { + "turn": 1 } } } @@ -1243,42 +1329,14 @@ "payload": { "sessionId": "{{parent}}", "event": { - "type": "user/message", - "seq": 1, - "time": 0, - "data": { - "content": [ - { - "type": "text", - "text": "Run the advanced packaged-runtime snapshot scenario." - } - ], - "source": { - "kind": "user" - }, - "role": "user", - "id": "{{messageId}}" - }, - "surfaceOp": "append" - } - } - }, - { - "method": "session.event", - "payload": { - "sessionId": "{{parent}}", - "event": { - "type": "session/title", + "type": "agent/inbox/spliced", "seq": 2, "time": 0, "data": { - "title": "Run the advanced packaged-runtime snapsh", - "messageSeqs": [ - 1 - ], - "source": { - "kind": "fallback" - } + "target": "next-turn", + "start": 0, + "removedCount": 1, + "inserted": [] } } } @@ -1303,16 +1361,66 @@ "payload": { "sessionId": "{{parent}}", "event": { - "type": "request/header", + "type": "user/message", "seq": 4, "time": 0, + "data": { + "content": [ + { + "type": "text", + "text": "Run the advanced packaged-runtime snapshot scenario." + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "{{messageId}}" + }, + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "session/title", + "seq": 5, + "time": 0, + "data": { + "title": "Run the advanced packaged-runtime snapsh", + "messageSeqs": [ + 4 + ], + "source": { + "kind": "fallback" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "request/header", + "seq": 6, + "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -1331,13 +1439,29 @@ } } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "request/context", + "seq": 7, + "time": 0, + "data": { + "provider": "deepseek-official", + "model": "smoke-model", + "contextWindow": 1000000 + } + } + } + }, { "method": "session.event", "payload": { "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 5, + "seq": 8, "time": 0, "data": { "turn": 1, @@ -1357,7 +1481,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 6, + "seq": 9, "time": 0, "data": { "turn": 1, @@ -1379,7 +1503,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 7, + "seq": 10, "time": 0, "data": { "turn": 1, @@ -1404,7 +1528,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 8, + "seq": 11, "time": 0, "data": { "turn": 1, @@ -1426,7 +1550,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 9, + "seq": 12, "time": 0, "data": { "turn": 1, @@ -1447,7 +1571,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 10, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -1464,7 +1588,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -1475,11 +1599,11 @@ } }, "sourceEventSeqs": [ - 5, - 6, - 7, 8, - 9 + 9, + 10, + 11, + 12 ], "surfaceOp": "append" } @@ -1491,7 +1615,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 11, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -1509,7 +1633,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 12, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -1537,7 +1661,7 @@ } }, "sourceEventSeqs": [ - 11 + 14 ], "surfaceOp": "append" } @@ -1549,7 +1673,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 13, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -1564,7 +1688,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 14, + "seq": 17, "time": 0, "data": { "turn": 1, @@ -1579,15 +1703,20 @@ "sessionId": "{{parent}}", "event": { "type": "request/header", - "seq": 15, + "seq": 18, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -1613,7 +1742,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 16, + "seq": 19, "time": 0, "data": { "turn": 1, @@ -1633,7 +1762,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 17, + "seq": 20, "time": 0, "data": { "turn": 1, @@ -1655,7 +1784,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 18, + "seq": 21, "time": 0, "data": { "turn": 1, @@ -1680,7 +1809,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 19, + "seq": 22, "time": 0, "data": { "turn": 1, @@ -1702,7 +1831,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 20, + "seq": 23, "time": 0, "data": { "turn": 1, @@ -1723,7 +1852,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 21, + "seq": 24, "time": 0, "data": { "turn": 1, @@ -1740,7 +1869,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -1751,11 +1880,11 @@ } }, "sourceEventSeqs": [ - 16, - 17, - 18, 19, - 20 + 20, + 21, + 22, + 23 ], "surfaceOp": "append" } @@ -1767,7 +1896,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 22, + "seq": 25, "time": 0, "data": { "turn": 1, @@ -1785,9 +1914,10 @@ "sessionId": "{{parent}}", "event": { "type": "tool/code-dispatch-start", - "seq": 23, + "seq": 26, "time": 0, "data": { + "rootCallId": "advanced-code", "parentCallId": "advanced-code", "subCallId": "advanced-code:code:1", "name": "snapshot_double", @@ -1804,9 +1934,10 @@ "sessionId": "{{parent}}", "event": { "type": "tool/code-dispatch", - "seq": 24, + "seq": 27, "time": 0, "data": { + "rootCallId": "advanced-code", "parentCallId": "advanced-code", "subCallId": "advanced-code:code:1", "name": "snapshot_double", @@ -1830,7 +1961,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 25, + "seq": 28, "time": 0, "data": { "turn": 1, @@ -1858,7 +1989,7 @@ } }, "sourceEventSeqs": [ - 22 + 25 ], "surfaceOp": "append" } @@ -1870,7 +2001,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 26, + "seq": 29, "time": 0, "data": { "turn": 1, @@ -1885,7 +2016,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 27, + "seq": 30, "time": 0, "data": { "turn": 1, @@ -1900,7 +2031,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 28, + "seq": 31, "time": 0, "data": { "turn": 1, @@ -1920,7 +2051,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 29, + "seq": 32, "time": 0, "data": { "turn": 1, @@ -1942,7 +2073,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 30, + "seq": 33, "time": 0, "data": { "turn": 1, @@ -1967,7 +2098,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 31, + "seq": 34, "time": 0, "data": { "turn": 1, @@ -1989,7 +2120,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 32, + "seq": 35, "time": 0, "data": { "turn": 1, @@ -2010,7 +2141,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 33, + "seq": 36, "time": 0, "data": { "turn": 1, @@ -2027,7 +2158,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -2038,11 +2169,11 @@ } }, "sourceEventSeqs": [ - 28, - 29, - 30, 31, - 32 + 32, + 33, + 34, + 35 ], "surfaceOp": "append" } @@ -2054,7 +2185,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 34, + "seq": 37, "time": 0, "data": { "turn": 1, @@ -2078,17 +2209,97 @@ "payload": { "sessionId": "{{child-1}}", "event": { - "type": "turn/start", + "type": "agent/inbox/spliced", "seq": 0, "time": 0, "data": { - "turn": 1, - "trigger": { - "kind": "message", - "source": { - "kind": "user" + "target": "next-turn", + "start": 0, + "inserted": [ + { + "content": [ + { + "type": "text", + "text": "Reply with exactly DIRECT_CHILD_OK and nothing else." + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "{{messageId}}" } - } + ] + } + } + } + }, + { + "method": "session.status", + "payload": { + "sessionId": "{{child-1}}", + "status": "running" + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "turn/start", + "seq": 1, + "time": 0, + "data": { + "turn": 1 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "agent/inbox/spliced", + "seq": 2, + "time": 0, + "data": { + "target": "next-turn", + "start": 0, + "removedCount": 1, + "inserted": [] + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "subagent/descriptor", + "seq": 3, + "time": 0, + "data": { + "version": 2, + "mode": "one-shot", + "provider": "spawn", + "label": "Check direct child" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "step/start", + "seq": 4, + "time": 0, + "data": { + "turn": 1, + "step": 1 } } } @@ -2099,7 +2310,7 @@ "sessionId": "{{child-1}}", "event": { "type": "user/message", - "seq": 1, + "seq": 5, "time": 0, "data": { "content": [ @@ -2124,12 +2335,12 @@ "sessionId": "{{child-1}}", "event": { "type": "session/title", - "seq": 2, + "seq": 6, "time": 0, "data": { "title": "Reply with exactly DIRECT_CHILD_OK and", "messageSeqs": [ - 1 + 5 ], "source": { "kind": "fallback" @@ -2138,36 +2349,26 @@ } } }, - { - "method": "session.event", - "payload": { - "sessionId": "{{child-1}}", - "event": { - "type": "step/start", - "seq": 3, - "time": 0, - "data": { - "turn": 1, - "step": 1 - } - } - } - }, { "method": "session.event", "payload": { "sessionId": "{{child-1}}", "event": { "type": "request/header", - "seq": 4, + "seq": 7, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -2187,13 +2388,29 @@ } } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "request/context", + "seq": 8, + "time": 0, + "data": { + "provider": "deepseek-official", + "model": "smoke-model", + "contextWindow": 1000000 + } + } + } + }, { "method": "session.event", "payload": { "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 5, + "seq": 9, "time": 0, "data": { "turn": 1, @@ -2213,7 +2430,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 6, + "seq": 10, "time": 0, "data": { "turn": 1, @@ -2233,7 +2450,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 7, + "seq": 11, "time": 0, "data": { "turn": 1, @@ -2256,7 +2473,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 8, + "seq": 12, "time": 0, "data": { "turn": 1, @@ -2278,7 +2495,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 9, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -2299,7 +2516,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/message", - "seq": 10, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -2314,7 +2531,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -2325,11 +2542,11 @@ } }, "sourceEventSeqs": [ - 5, - 6, - 7, - 8, - 9 + 9, + 10, + 11, + 12, + 13 ], "surfaceOp": "append" } @@ -2341,7 +2558,7 @@ "sessionId": "{{child-1}}", "event": { "type": "step/end", - "seq": 11, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -2356,7 +2573,7 @@ "sessionId": "{{child-1}}", "event": { "type": "turn/end", - "seq": 12, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -2367,6 +2584,13 @@ } } }, + { + "method": "session.status", + "payload": { + "sessionId": "{{child-1}}", + "status": "idle" + } + }, { "method": "subagent.finished", "payload": { @@ -2390,7 +2614,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 35, + "seq": 38, "time": 0, "data": { "turn": 1, @@ -2418,7 +2642,7 @@ } }, "sourceEventSeqs": [ - 34 + 37 ], "surfaceOp": "append" } @@ -2430,7 +2654,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 36, + "seq": 39, "time": 0, "data": { "turn": 1, @@ -2445,7 +2669,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 37, + "seq": 40, "time": 0, "data": { "turn": 1, @@ -2460,7 +2684,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 38, + "seq": 41, "time": 0, "data": { "turn": 1, @@ -2480,7 +2704,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 39, + "seq": 42, "time": 0, "data": { "turn": 1, @@ -2502,7 +2726,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 40, + "seq": 43, "time": 0, "data": { "turn": 1, @@ -2527,7 +2751,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 41, + "seq": 44, "time": 0, "data": { "turn": 1, @@ -2549,7 +2773,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 42, + "seq": 45, "time": 0, "data": { "turn": 1, @@ -2570,7 +2794,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 43, + "seq": 46, "time": 0, "data": { "turn": 1, @@ -2587,7 +2811,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -2598,11 +2822,11 @@ } }, "sourceEventSeqs": [ - 38, - 39, - 40, 41, - 42 + 42, + 43, + 44, + 45 ], "surfaceOp": "append" } @@ -2614,7 +2838,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 44, + "seq": 47, "time": 0, "data": { "turn": 1, @@ -2638,17 +2862,96 @@ "payload": { "sessionId": "{{child-2}}", "event": { - "type": "turn/start", + "type": "agent/inbox/spliced", "seq": 0, "time": 0, "data": { - "turn": 1, - "trigger": { - "kind": "message", - "source": { - "kind": "user" + "target": "next-turn", + "start": 0, + "inserted": [ + { + "content": [ + { + "type": "text", + "text": "Reply with exactly WORKFLOW_CHILD_OK and nothing else." + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "{{messageId}}" } - } + ] + } + } + } + }, + { + "method": "session.status", + "payload": { + "sessionId": "{{child-2}}", + "status": "running" + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "turn/start", + "seq": 1, + "time": 0, + "data": { + "turn": 1 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "agent/inbox/spliced", + "seq": 2, + "time": 0, + "data": { + "target": "next-turn", + "start": 0, + "removedCount": 1, + "inserted": [] + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "subagent/descriptor", + "seq": 3, + "time": 0, + "data": { + "version": 2, + "mode": "one-shot", + "provider": "spawn" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "step/start", + "seq": 4, + "time": 0, + "data": { + "turn": 1, + "step": 1 } } } @@ -2659,7 +2962,7 @@ "sessionId": "{{child-2}}", "event": { "type": "user/message", - "seq": 1, + "seq": 5, "time": 0, "data": { "content": [ @@ -2684,12 +2987,12 @@ "sessionId": "{{child-2}}", "event": { "type": "session/title", - "seq": 2, + "seq": 6, "time": 0, "data": { "title": "Reply with exactly WORKFLOW_CHILD_OK and", "messageSeqs": [ - 1 + 5 ], "source": { "kind": "fallback" @@ -2698,36 +3001,26 @@ } } }, - { - "method": "session.event", - "payload": { - "sessionId": "{{child-2}}", - "event": { - "type": "step/start", - "seq": 3, - "time": 0, - "data": { - "turn": 1, - "step": 1 - } - } - } - }, { "method": "session.event", "payload": { "sessionId": "{{child-2}}", "event": { "type": "request/header", - "seq": 4, + "seq": 7, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -2747,13 +3040,29 @@ } } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "request/context", + "seq": 8, + "time": 0, + "data": { + "provider": "deepseek-official", + "model": "smoke-model", + "contextWindow": 1000000 + } + } + } + }, { "method": "session.event", "payload": { "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 5, + "seq": 9, "time": 0, "data": { "turn": 1, @@ -2773,7 +3082,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 6, + "seq": 10, "time": 0, "data": { "turn": 1, @@ -2793,7 +3102,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 7, + "seq": 11, "time": 0, "data": { "turn": 1, @@ -2816,7 +3125,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 8, + "seq": 12, "time": 0, "data": { "turn": 1, @@ -2838,7 +3147,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 9, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -2859,7 +3168,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/message", - "seq": 10, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -2874,7 +3183,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -2885,11 +3194,11 @@ } }, "sourceEventSeqs": [ - 5, - 6, - 7, - 8, - 9 + 9, + 10, + 11, + 12, + 13 ], "surfaceOp": "append" } @@ -2901,7 +3210,7 @@ "sessionId": "{{child-2}}", "event": { "type": "step/end", - "seq": 11, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -2916,7 +3225,7 @@ "sessionId": "{{child-2}}", "event": { "type": "turn/end", - "seq": 12, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -2927,6 +3236,13 @@ } } }, + { + "method": "session.status", + "payload": { + "sessionId": "{{child-2}}", + "status": "idle" + } + }, { "method": "subagent.finished", "payload": { @@ -2950,7 +3266,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 45, + "seq": 48, "time": 0, "data": { "turn": 1, @@ -2978,7 +3294,7 @@ } }, "sourceEventSeqs": [ - 44 + 47 ], "surfaceOp": "append" } @@ -2990,7 +3306,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 46, + "seq": 49, "time": 0, "data": { "turn": 1, @@ -3005,7 +3321,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 47, + "seq": 50, "time": 0, "data": { "turn": 1, @@ -3020,7 +3336,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 48, + "seq": 51, "time": 0, "data": { "turn": 1, @@ -3040,7 +3356,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 49, + "seq": 52, "time": 0, "data": { "turn": 1, @@ -3062,7 +3378,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 50, + "seq": 53, "time": 0, "data": { "turn": 1, @@ -3087,7 +3403,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 51, + "seq": 54, "time": 0, "data": { "turn": 1, @@ -3109,7 +3425,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 52, + "seq": 55, "time": 0, "data": { "turn": 1, @@ -3130,7 +3446,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 53, + "seq": 56, "time": 0, "data": { "turn": 1, @@ -3147,7 +3463,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -3158,11 +3474,11 @@ } }, "sourceEventSeqs": [ - 48, - 49, - 50, 51, - 52 + 52, + 53, + 54, + 55 ], "surfaceOp": "append" } @@ -3174,7 +3490,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 54, + "seq": 57, "time": 0, "data": { "turn": 1, @@ -3192,7 +3508,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 55, + "seq": 58, "time": 0, "data": { "turn": 1, @@ -3220,7 +3536,7 @@ } }, "sourceEventSeqs": [ - 54 + 57 ], "surfaceOp": "append" } @@ -3232,7 +3548,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 56, + "seq": 59, "time": 0, "data": { "turn": 1, @@ -3247,7 +3563,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 57, + "seq": 60, "time": 0, "data": { "turn": 1, @@ -3262,15 +3578,20 @@ "sessionId": "{{parent}}", "event": { "type": "request/header", - "seq": 58, + "seq": 61, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -3295,7 +3616,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 59, + "seq": 62, "time": 0, "data": { "turn": 1, @@ -3315,7 +3636,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 60, + "seq": 63, "time": 0, "data": { "turn": 1, @@ -3335,7 +3656,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 61, + "seq": 64, "time": 0, "data": { "turn": 1, @@ -3358,7 +3679,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 62, + "seq": 65, "time": 0, "data": { "turn": 1, @@ -3380,7 +3701,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 63, + "seq": 66, "time": 0, "data": { "turn": 1, @@ -3401,7 +3722,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 64, + "seq": 67, "time": 0, "data": { "turn": 1, @@ -3416,7 +3737,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -3427,11 +3748,11 @@ } }, "sourceEventSeqs": [ - 59, - 60, - 61, 62, - 63 + 63, + 64, + 65, + 66 ], "surfaceOp": "append" } @@ -3443,7 +3764,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 65, + "seq": 68, "time": 0, "data": { "turn": 1, @@ -3458,7 +3779,7 @@ "sessionId": "{{parent}}", "event": { "type": "turn/end", - "seq": 66, + "seq": 69, "time": 0, "data": { "turn": 1, @@ -3470,13 +3791,10 @@ } }, { - "method": "session.finished", + "method": "session.status", "payload": { "sessionId": "{{parent}}", - "status": "ok", - "reason": { - "kind": "completed" - } + "status": "idle" } } ], diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl index 2929f8664c..3cfcda4d28 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl @@ -1,14 +1,18 @@ -{"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","delegationDepth":1} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":12,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","origin":"subagent","delegationDepth":1} +{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} +{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":3,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} +{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":0,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} +{"type":"request/context","seq":8,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":16,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl index a5da33d006..926acbcecc 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl @@ -1,14 +1,18 @@ -{"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","delegationDepth":1} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":12,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","origin":"subagent","delegationDepth":1} +{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} +{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":3,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} +{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":0,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} +{"type":"request/context","seq":8,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":16,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl index 1f2f890b3c..65f31b21b6 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl @@ -1,68 +1,71 @@ {"type":"session","version":0,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} +{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[11],"surfaceOp":"append"} -{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} -{"type":"request/header","seq":15,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}} -{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} -{"type":"tool/call","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}} -{"type":"tool/code-dispatch-start","seq":23,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21}}} -{"type":"tool/code-dispatch","seq":24,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}} -{"type":"tool/result","seq":25,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} -{"type":"step/end","seq":26,"time":0,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":27,"time":0,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} -{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":33,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"} -{"type":"tool/call","seq":34,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} -{"type":"tool/result","seq":35,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[34],"surfaceOp":"append"} -{"type":"step/end","seq":36,"time":0,"data":{"turn":1,"step":3}} -{"type":"step/start","seq":37,"time":0,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}} -{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}} -{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":43,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"} -{"type":"tool/call","seq":44,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}} -{"type":"tool/result","seq":45,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[44],"surfaceOp":"append"} -{"type":"step/end","seq":46,"time":0,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":47,"time":0,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\": \"dyn-1\"}"}}} -{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}} -{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":53,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"} -{"type":"tool/call","seq":54,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}} -{"type":"tool/result","seq":55,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[54],"surfaceOp":"append"} -{"type":"step/end","seq":56,"time":0,"data":{"turn":1,"step":5}} -{"type":"step/start","seq":57,"time":0,"data":{"turn":1,"step":6}} -{"type":"request/header","seq":58,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}} -{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}} -{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}} -{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":64,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} -{"type":"step/end","seq":65,"time":0,"data":{"turn":1,"step":6}} -{"type":"turn/end","seq":66,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":5,"time":0,"data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} +{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}} +{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}} +{"type":"request/header","seq":18,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}} +{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}} +{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":24,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"tool/call","seq":25,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}} +{"type":"tool/code-dispatch-start","seq":26,"time":0,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21}}} +{"type":"tool/code-dispatch","seq":27,"time":0,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}} +{"type":"tool/result","seq":28,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"step/end","seq":29,"time":0,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":30,"time":0,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} +{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":34,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":36,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[31,32,33,34,35],"surfaceOp":"append"} +{"type":"tool/call","seq":37,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} +{"type":"tool/result","seq":38,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[37],"surfaceOp":"append"} +{"type":"step/end","seq":39,"time":0,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":40,"time":0,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}} +{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}} +{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":46,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[41,42,43,44,45],"surfaceOp":"append"} +{"type":"tool/call","seq":47,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}} +{"type":"tool/result","seq":48,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[47],"surfaceOp":"append"} +{"type":"step/end","seq":49,"time":0,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":50,"time":0,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\": \"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":53,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":54,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":56,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[51,52,53,54,55],"surfaceOp":"append"} +{"type":"tool/call","seq":57,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}} +{"type":"tool/result","seq":58,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[57],"surfaceOp":"append"} +{"type":"step/end","seq":59,"time":0,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":60,"time":0,"data":{"turn":1,"step":6}} +{"type":"request/header","seq":61,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}} +{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}} +{"type":"assistant/chunk","seq":64,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}} +{"type":"assistant/chunk","seq":65,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":66,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":67,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[62,63,64,65,66],"surfaceOp":"append"} +{"type":"step/end","seq":68,"time":0,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":69,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 63050b2079..f033ece889 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh --profile headless \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh --profile headless \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" }, { "role": "user", diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 5613ed035e..ab7370c033 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -39,18 +39,6 @@ function requiredConfig() { }) } -function queuedReadinessConfig( - ctx: Context, - onPublished: (dispose: () => void) => void, -) { - return z.transform(z.any(), () => { - queueMicrotask(() => { - onPublished(ctx.provide(TEST_INVARIANT_READY_SERVICE, true)) - }) - return {} - }, true) -} - function invalidConfigApply(): never { throw new Error('invalid plugin apply executed') } @@ -189,84 +177,55 @@ describe('global test invariant host', () => { expect(apply).not.toHaveBeenCalled() }) - it('disposes invalid config when readiness refresh wins the rejection-handler race', async () => { + it('disposes invalid config after delayed invariant readiness', async () => { await withDelayedFirstCompanion( async ({ started, release }) => { const ctx = new Context() const apply = vi.fn(invalidConfigApply) - let disposeQueuedReadiness: (() => void) | undefined const plugin = { apply, - Config: z.intersect([ - queuedReadinessConfig(ctx, (dispose) => { - disposeQueuedReadiness = dispose - }), - requiredConfig(), - ]), + Config: requiredConfig(), } const fiber = ctx.plugin(plugin, {}) - const firstError = await rejectionOf(fiber) - expectRequiredConfigValidation(firstError) - expect(fiber.state).toBe(FiberState.DISPOSED) + const returnedError = rejectionOf(fiber) + await started + expect(fiber.state).toBe(FiberState.PENDING) expect(apply).not.toHaveBeenCalled() - await started - if (disposeQueuedReadiness === undefined) throw new Error('queued readiness was not published') - disposeQueuedReadiness() release() - await ctx.plugin(TestInvariantProbe) - - const secondError = await rejectionOf(fiber) - expect(secondError).toBe(firstError) + expectRequiredConfigValidation(await returnedError) expect(fiber.state).toBe(FiberState.DISPOSED) expect(apply).not.toHaveBeenCalled() }, ) }) - it('retains a valid plugin failure when readiness wins the initial-probe race', async () => { + it('retains a valid plugin failure after delayed invariant readiness', async () => { await withDelayedFirstCompanion( async ({ started, release }) => { const ctx = new Context() const failure = new Error('valid plugin apply failed') - const applied = deferred() const apply = vi.fn(function validConfigApply() { - applied.resolve() throw failure }) - let disposeQueuedReadiness: (() => void) | undefined const plugin = { apply, - Config: queuedReadinessConfig(ctx, (dispose) => { - disposeQueuedReadiness = dispose - }), + Config: z.object({}), } const fiber = ctx.plugin(plugin, {}) const returnedError = rejectionOf(fiber) - try { - await Promise.all([started, applied.promise]) - expect(fiber.state).toBe(FiberState.FAILED) - expect(apply).toHaveBeenCalledOnce() - expect(ctx.registry.has(plugin)).toBe(true) - expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1) + await started + expect(fiber.state).toBe(FiberState.PENDING) + expect(apply).not.toHaveBeenCalled() - if (disposeQueuedReadiness === undefined) throw new Error('queued readiness was not published') - Reflect.deleteProperty(fiber.inject, TEST_INVARIANT_READY_SERVICE) - disposeQueuedReadiness() - release() - - expect(await returnedError).toBe(failure) - expect(fiber.state).toBe(FiberState.FAILED) - expect(apply).toHaveBeenCalledOnce() - expect(ctx.registry.has(plugin)).toBe(true) - expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1) - } finally { - Reflect.deleteProperty(fiber.inject, TEST_INVARIANT_READY_SERVICE) - disposeQueuedReadiness?.() - release() - } + release() + expect(await returnedError).toBe(failure) + expect(fiber.state).toBe(FiberState.FAILED) + expect(apply).toHaveBeenCalledOnce() + expect(ctx.registry.has(plugin)).toBe(true) + expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1) }, ) }) diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index fa3c3cc7e4..5b447f5f4a 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -6,7 +6,7 @@ */ import { expect } from 'vitest' -import { FiberState, Inject, RegistryService } from '@deepseek-ai/cordis' +import { FiberState, Inject, RegistryService, ValidationError } from '@deepseek-ai/cordis' import type { Context, Plugin } from '@deepseek-ai/cordis' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { @@ -248,22 +248,25 @@ function withInvariantReadiness(plugin: Plugin, callback: PluginCallback): Plugi function joinInvariantStartup( fiber: PluginFiber, invariantReady: Promise, - disposeInitialFailure = false, + disposePendingValidationFailure = false, ): PluginFiber { // RegistryService returns a thenable wrapper whose context still points to // the raw Fiber. Calling inherited await() on the wrapper would return and // assimilate that thenable, accidentally following later plugin startup. const rawFiber = fiber.ctx.fiber - const initialized = disposeInitialFailure - ? rawFiber.await().catch(async (error: unknown) => { - // Config validation is the only failure recorded while a gated fiber - // is initially PENDING. Dispose it even if queued readiness publication - // changes its state before this rejection handler runs. - await rawFiber.dispose() + const readiness = invariantReady.then(async () => { + try { + return await rawFiber.await() + } catch (error) { + // Config resolves only after the readiness injection activates. Dispose + // validation failures owned by an initially pending target; ordinary + // callback failures remain inspectable. + if (disposePendingValidationFailure && error instanceof ValidationError) { + await rawFiber.dispose() + } throw error - }) - : Promise.resolve() - const readiness = initialized.then(() => invariantReady).then(() => rawFiber.await()) + } + }) const joined = Object.create(fiber) as PluginFiber joined.then = readiness.then.bind(readiness) return joined diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 32f77c1b86..66a2b142b0 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -147,6 +147,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/tasks/tasks-local': { kind: 'indirect', reason: 'The registry backend delegates model rendering to producer plugins and dsh-tool-tasks.' }, 'packages/examples/acp-demo': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-spine-demo and dsh-acp.' }, 'packages/boot/app-boot': { kind: 'indirect', reason: 'Only the loaded plugin tree contributes model context.' }, + 'packages/boot/cmdline': { kind: 'none', reason: 'Resolves the process command line before any session exists; configured rows own every model-visible consequence.' }, 'packages/examples/jsonrpc-demo': { kind: 'indirect', reason: 'Only the externally configured plugin tree contributes model context.' }, 'packages/interaction/permission': { kind: 'indirect', reason: 'The service writes mechanism events rendered by dsh-user-approval and dsh-tool-bash.' }, 'packages/interaction/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 5167782b95..f3fedb68c4 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -131,6 +131,8 @@ // group prefix with a dedicated wildcard per group instead. "@deepseek-ai/dsh-host-*/invariant": ["./packages/host/*/src/invariant.ts"], "@deepseek-ai/dsh-client-*/invariant": ["./packages/client/*/src/invariant.ts"], + "@deepseek-ai/dsh-headless/startup": ["./packages/bundle/headless/src/startup.ts"], + "@deepseek-ai/dsh-web-app/startup": ["./packages/bundle/web-app/src/startup.ts"], "@deepseek-ai/dsh-client-*/client": ["./packages/client/*/src/client"], // One wildcard maps every @deepseek-ai/dsh- to its source. Package // dir names are unique across groups, so first-on-disk-wins resolution is diff --git a/tsconfig.host.json b/tsconfig.host.json index ba81e2c123..bb31d82b57 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -13,7 +13,7 @@ "apps/web/tests/declared-reasoning.e2e.ts", "apps/web/tests/support.ts", "apps/web/tests/scaffold-hermetic.e2e.ts", - "apps/web/tests/core-web-profile.snapshot.ts", + "apps/web/tests/minimal-preset.snapshot.ts", "apps/web/tests/live-interactions.e2e.ts", "apps/web/tests/question-composer.e2e.ts", "apps/web/tests/approval-composer.e2e.ts", @@ -223,6 +223,7 @@ { "path": "./packages/bundle/headless" }, { "path": "./packages/bundle/web-app" }, { "path": "./packages/boot/app-boot" }, + { "path": "./packages/boot/cmdline" }, { "path": "./packages/scaffold/server" }, { "path": "./packages/examples/jsonrpc-demo" }, { "path": "./packages/support/llm-replay" }, diff --git a/tsdown.config.ts b/tsdown.config.ts index 2042e81db3..6dd12f3eb4 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -17,7 +17,7 @@ export default defineConfig(({ env }) => { const client = isBuildFaceClient(env?.DSH_BUILD_FACE) return { workspace: ['vendor/*', 'packages/*/*', 'apps/cli'], - entry: client ? '' : ['lib/types/{index,invariant}.js'], + entry: client ? '' : ['lib/types/{index,invariant,startup}.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/vendor/README.md b/vendor/README.md index 470b517549..e83f9b4140 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -37,14 +37,16 @@ Keep this log exhaustive — every divergence from upstream must be listed. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. 6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. `Fiber.update()` returns its `internal/update` waterfall result, allowing Loader callers to await a restart while preserving synchronous config validation. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. -8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. +8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, contain sibling-start failures after their owning tree is disposed, undo changes and additions on live-update failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. 9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Module watches realpath their existing base directory, attach change listeners before declaring the service ready, and use that spelling for Node module-cache identity; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while exact-config callbacks keep the requested filename. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`. 10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. 12. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. 13. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. 14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. -15. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md). +15. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include adds a static entry-config resolver so its own options interpolate while nested row `!!js` nodes remain deferred. Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. Covered by `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`. +16. **In-memory Loader entry activation in `loader/src/config/entry.ts`**: an invocation can activate a row shipped with `disabled: true` without mutating its serialized options. The override belongs to the mounted entry object, survives Include config reapplication, respects disabled ancestors, and disappears with the entry. Covered by `packages/boot/cmdline/tests/cmdline.spec.ts` and `apps/web/tests/hmr-live.e2e.ts`. +17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md). ## Sync procedure diff --git a/vendor/cordis/src/events.ts b/vendor/cordis/src/events.ts index e18940a830..e9bd85e3e1 100644 --- a/vendor/cordis/src/events.ts +++ b/vendor/cordis/src/events.ts @@ -331,6 +331,12 @@ export interface Events { 'internal/plugin'(fiber: Fiber): void /** A fiber changed lifecycle state; receives the fiber and its previous state. */ 'internal/status'(fiber: Fiber, oldValue: FiberState): void + /** + * Resolve raw plugin config after the fiber's injections become active. + * @param config - the raw config for this activation. + * @mode waterfall + */ + 'internal/config'(this: Fiber, config: any, next: () => any): any /** Interception hook for a service binding (no core producer). */ 'internal/service'(this: Context, name: string, value: any): void /** Waterfall: a fiber config update is being applied; skip `next()` to veto. */ diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts index a8c804207d..38a3197e29 100644 --- a/vendor/cordis/src/fiber.ts +++ b/vendor/cordis/src/fiber.ts @@ -188,6 +188,8 @@ export class Fiber { public readonly ctx: Context /** The validated plugin config (updated by `update()`). */ public config: any + /** The raw plugin config, re-resolved before each activation. */ + public _config: any /** Current lifecycle state; transitions emit `internal/status`. */ public state = FiberState.PENDING /** Dispose this fiber: unload the plugin, then settle once cleanup finished. */ @@ -224,6 +226,7 @@ export class Fiber { public runtime: Plugin.Runtime | null, getOuterStack: () => string[], ) { + this._config = config const collect = (dispose: Disposable) => { this._disposables.push(dispose) } @@ -259,16 +262,8 @@ export class Fiber { collect, } - let shouldRefresh = false this.dispose = parent.fiber.effect(() => { const remove = runtime.fibers.push(this) - try { - this.config = resolveConfig(runtime, config) - shouldRefresh = true - } catch (error) { - this.ctx.logger.error(error) - this._error = error - } return async () => { this.uid = null emitPluginDisposed(this.context, this) @@ -320,7 +315,7 @@ export class Fiber { for (const name of Object.keys(this.inject)) { this._checkImpl(name) } - if (shouldRefresh) this._refresh() + this._refresh() } } else { this.uid = 0 @@ -643,6 +638,11 @@ export class Fiber { }) } + private _resolveConfig(config: any) { + config = this.context.waterfall(this, 'internal/config', config, () => config) + return this.runtime ? resolveConfig(this.runtime, config) : config + } + private async _reload() { this.store = { ...this._store } const oldEpoch = this._runner.epoch @@ -652,7 +652,9 @@ export class Fiber { // the load. Do not run plugin code for a stale epoch; the state update // below will drain any effects collected while the fiber was PENDING. if (this._runner.epoch === oldEpoch) { + this.config = this._resolveConfig(this._config) await this._execute(this._runner) + this._error = undefined } } catch (reason) { // impl guarantees that the error is non-null (?) @@ -733,7 +735,16 @@ export class Fiber { */ update(config: any, noSave = false) { this.assertActive() - config = resolveConfig(this.runtime!, config) + this._config = config + if (this.state !== FiberState.ACTIVE) { + // Config resolution may access injected services, so defer it until the + // fiber can activate. + this._error = undefined + this._setEpoch(INACTIVE) + this._refresh() + return + } + config = this._resolveConfig(config) return this.context.waterfall(this, 'internal/update', config, noSave, () => { this.config = config this._error = undefined diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index 290899ba1f..f79d8344dc 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -502,7 +502,7 @@ class Hmr extends Service { const reload = (plugin: any, runtime: Plugin.Runtime) => { if (!runtime) return for (const oldFiber of runtime.fibers) { - const fiber = oldFiber.parent.registry.plugin(plugin, oldFiber.config, this.getOuterStack) + const fiber = oldFiber.parent.registry.plugin(plugin, oldFiber._config, this.getOuterStack) fiber.entry = oldFiber.entry if (fiber.entry) fiber.entry.fiber = fiber } diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 04079cc598..c67b591978 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -1,4 +1,4 @@ -import { EntryTree, isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { EntryConfigResolver, EntryTree, interpolate, isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { Context, Service } from '@deepseek-ai/cordis' import { extname } from 'node:path' import { access, constants, readFile, rename, writeFile } from 'node:fs/promises' @@ -174,6 +174,21 @@ export namespace Include { export class Include extends EntryTree { static inject = ['loader'] + /** + * Resolve Include's own options while preserving nested entry expressions. + * @param ctx - the Include plugin context. + * @param config - the raw Include config. + * @returns resolved Include options with `initial` and `patches` untouched. + */ + static [EntryConfigResolver](ctx: Context, config: Include.Config): Include.Config { + const { initial, patches, ...own } = config + return { + ...interpolate(ctx, own), + ...(initial === undefined ? {} : { initial }), + ...(patches === undefined ? {} : { patches }), + } + } + public filename: string private type?: string private readonly: boolean diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index 215198468f..3fc74177f9 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -3,7 +3,18 @@ import { deepEqual, isNullable } from '@deepseek-ai/cosmokit' import { Loader } from '../index.ts' import { EntryGroup } from './group.ts' import { EntryTree } from './tree.ts' -import { evaluate, interpolate } from './utils.ts' +import { evaluate } from './utils.ts' + +/** Static plugin hook for resolving a container config while preserving nested entry configs. */ +export const EntryConfigResolver = Symbol.for('cordis.loader.entry-config-resolver') + +/** + * Resolve a container's own config while preserving any nested entry configs. + * @param ctx - the container plugin context. + * @param config - the container's raw config. + * @returns the config to validate for this activation. + */ +export type EntryConfigResolver = (ctx: Context, config: any) => any /** Serialized plugin entry options stored in loader config files. */ export interface EntryOptions { @@ -62,6 +73,8 @@ export class Entry { _initTask?: Promise _disposing = 0 + private runtimeEnabled = false + private runtimeEnableTask?: Promise constructor(public loader: Loader) { this.ctx = loader.ctx.extend({ [Entry.key]: this }) @@ -88,22 +101,33 @@ export class Entry { private _disabled(options: EntryOptions) { // group is always enabled if (options.group) return false - if (options.disabled) return true + if (options.disabled && !this.runtimeEnabled) return true let entry = this.parent.ctx.fiber.entry while (entry) { - if (entry.options.disabled) return true + if (entry.options.disabled && !entry.runtimeEnabled) return true entry = entry.parent.ctx.fiber.entry } return false } - evaluate(expr: string) { - return evaluate(this.ctx, expr) + /** + * Enable this in-memory entry without rewriting its configured `disabled` + * value; the override survives config reapplication for this entry object. + * @returns a promise settling after its initial activation attempt. + */ + enableRuntime(): Promise { + if (this.runtimeEnableTask !== undefined) return this.runtimeEnableTask + this.runtimeEnabled = true + this.runtimeEnableTask = this.refresh().catch((error: unknown) => { + this.runtimeEnabled = false + this.runtimeEnableTask = undefined + throw error + }) + return this.runtimeEnableTask } - _resolveConfig(plugin: any): [any, any?] { - if (plugin[EntryGroup.key]) return this.options.config - return interpolate(this.ctx, this.options.config) + evaluate(expr: string) { + return evaluate(this.ctx, expr) } private async _patchContext(diff: string[]) { @@ -111,7 +135,7 @@ export class Entry { Object.setPrototypeOf(this.ctx, this.parent.ctx) if (this.fiber?.uid && (diff.includes('config') || this.options.group)) { - await this.fiber.update(this._resolveConfig(this.fiber.runtime!.callback), true) + await this.fiber.update(this.options.config, true) } }) } @@ -258,7 +282,15 @@ export class Entry { this._initTask = undefined if (!this.loader.getTasks().length) this.ctx.reflect.notify(['loader']) } - await this.fiber?.await() + await this._await() + } + + async _await() { + try { + await this.fiber?.await() + } catch (error) { + throw updateError('apply', this.options, error) + } } private async _init() { @@ -278,17 +310,13 @@ export class Entry { private async _start(plugin: any) { let fiber: Fiber | undefined try { - fiber = await this._create(plugin) + await this._patchContext([]) + this.loader.showLog(this, 'apply') + fiber = this.fiber = this.ctx.registry.plugin(plugin, this.options.config, this.getOuterStack) await fiber.await() } catch (error) { await this._dispose(fiber) throw error } } - - private async _create(plugin: any): Promise { - await this._patchContext([]) - this.loader.showLog(this, 'apply') - return this.fiber = this.ctx.registry.plugin(plugin, this._resolveConfig(plugin), this.getOuterStack) - } } diff --git a/vendor/loader/src/config/group.ts b/vendor/loader/src/config/group.ts index a7b0997297..2e322b1e26 100644 --- a/vendor/loader/src/config/group.ts +++ b/vendor/loader/src/config/group.ts @@ -69,6 +69,10 @@ export class EntryGroup { try { const outcomes = await Promise.allSettled(config.map(options => this.create(options))) + // Disposal owns termination: sibling starts can still be settling after + // the containing tree has gone away, but their failures no longer + // describe a live update to roll back. + if (this.ctx.fiber.uid === null) return const failures = outcomes .filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') .map(outcome => outcome.reason) diff --git a/vendor/loader/src/config/tree.ts b/vendor/loader/src/config/tree.ts index 4b5ac78ef7..c1925f0b90 100644 --- a/vendor/loader/src/config/tree.ts +++ b/vendor/loader/src/config/tree.ts @@ -51,7 +51,7 @@ export abstract class EntryTree { continue } const outcomes = await Promise.allSettled( - [...this.entries()].map(entry => entry.fiber?.await()), + [...this.entries()].map(entry => entry._await()), ) const failures = outcomes .filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') diff --git a/vendor/loader/src/index.ts b/vendor/loader/src/index.ts index fa1f852cff..3fe3e57949 100644 --- a/vendor/loader/src/index.ts +++ b/vendor/loader/src/index.ts @@ -1,9 +1,16 @@ -import { Context, Inject, Service } from '@deepseek-ai/cordis' +import { Context, FiberState, Inject, Service, type Fiber } from '@deepseek-ai/cordis' import { defineProperty, isNullable, type Dict } from '@deepseek-ai/cosmokit' import { ModuleLoader } from './internal.ts' -import { Entry, type EntryOptions } from './config/entry.ts' +import { + Entry, + EntryConfigResolver, + type EntryConfigResolver as ConfigResolver, + type EntryOptions, +} from './config/entry.ts' +import { EntryGroup } from './config/group.ts' import isolate from './config/isolate.ts' import { EntryTree } from './config/tree.ts' +import { interpolate } from './config/utils.ts' /** Re-export entry node APIs. */ export * from './config/entry.ts' @@ -87,6 +94,15 @@ export class Loader extends EntryTree { ctx.reflect.provide('loader', this, this[Service.check]) + ctx.on('internal/config', function (this: Fiber, _config, next) { + const config = next() + if (!this.entry || this.parent.fiber?.entry === this.entry) return config + const plugin = this.runtime?.callback as Record | undefined + if (plugin?.[EntryGroup.key]) return config + const resolve = plugin?.[EntryConfigResolver] as ConfigResolver | undefined + return resolve ? resolve(this.ctx, config) : interpolate(this.ctx, config) + }, { global: true }) + ctx.on('internal/update', async function (config, noSave, next) { if (!this.entry || noSave || this.parent.fiber?.entry === this.entry) return next() await next() @@ -127,7 +143,8 @@ export class Loader extends EntryTree { if (!ctx.registry.has(fiber.runtime!.callback)) return // case 5: the entry's tree is being disposed - if (!fiber.entry.parent.tree.ctx.fiber.uid) return + const treeOwner = fiber.entry.parent.tree.ctx.fiber + if (!treeOwner.uid || treeOwner.state === FiberState.UNLOADING) return // case 6: Loader is replacing or removing this exact fiber if (fiber.entry._disposing) return diff --git a/website/docs.ts b/website/docs.ts index 9fcdc7c1a7..0019fd3a28 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -138,13 +138,21 @@ const homeAndGuide = pairedPages([ section: { root: '入门', en: 'Guide' }, order: 3, }, + { + source: 'docs/user/guide/python-sdk.md', + route: 'guide/python-sdk.md', + label: { root: 'Python SDK', en: 'Python SDK' }, + sidebar: { root: 'zh-guide', en: 'en-guide' }, + section: { root: '入门', en: 'Guide' }, + order: 4, + }, { source: 'docs/user/guide/config.md', route: 'guide/config.md', label: { root: '配置文件', en: 'Configuration' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, section: { root: '入门', en: 'Guide' }, - order: 4, + order: 5, }, ])