From 9d5eb37638b36948f2fd8d134e40d75d9fd3c3c7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:13:58 +0800 Subject: [PATCH 1/2] fix(headless): dsh run is a direct core front door --- ...-model-catalog-and-acp-selection.i18n.yaml | 4 +- ...-15-llm-model-catalog-and-acp-selection.md | 4 +- ...-llm-model-catalog-and-acp-selection.zh.md | 4 +- ...19-gui-layering-and-rpc-protocol.i18n.yaml | 4 +- ...026-07-19-gui-layering-and-rpc-protocol.md | 8 +- ...-07-19-gui-layering-and-rpc-protocol.zh.md | 8 +- ...tree-boot-and-transport-layering.i18n.yaml | 4 +- ...config-tree-boot-and-transport-layering.md | 8 +- ...fig-tree-boot-and-transport-layering.zh.md | 8 +- ...026-08-05-profile-plugin-bundles.i18n.yaml | 4 +- .../2026-08-05-profile-plugin-bundles.md | 2 +- .../2026-08-05-profile-plugin-bundles.zh.md | 2 +- ...-headless-direct-core-front-door.i18n.yaml | 6 + ...6-08-09-headless-direct-core-front-door.md | 44 +++ ...8-09-headless-direct-core-front-door.zh.md | 44 +++ ...07-24-web-session-model-selector.i18n.yaml | 4 +- .../2026-07-24-web-session-model-selector.md | 20 +- ...026-07-24-web-session-model-selector.zh.md | 20 +- ...default-model-follows-the-picker.i18n.yaml | 4 +- ...-08-07-default-model-follows-the-picker.md | 40 ++- ...-07-default-model-follows-the-picker.zh.md | 40 ++- ...6-08-08-dsh-run-headless-command.i18n.yaml | 4 +- .../2026-08-08-dsh-run-headless-command.md | 30 +- .../2026-08-08-dsh-run-headless-command.zh.md | 30 +- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/composition.md | 3 + apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 4 +- apps/cli/reference/README.zh.md | 4 +- apps/cli/tests/built-bin.e2e.ts | 16 +- apps/cli/tests/fixtures/never-dispose.mjs | 1 + apps/cli/tests/headless-shutdown.e2e.ts | 7 +- apps/web/tests/declared-reasoning.e2e.ts | 6 +- apps/web/tests/declared-reasoning.overlay.yml | 2 +- apps/web/tests/default-model.e2e.ts | 8 +- apps/web/tests/default-model.overlay.yml | 2 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 1 + docs/architecture.zh.md | 1 + docs/capability-seams.md | 7 + docs/config-catalog.md | 43 ++- docs/event-producer-consumer.md | 2 +- docs/i18n/terminology.md | 1 + docs/module-graph.md | 18 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 24 ++ docs/subsystems/core.zh.md | 24 ++ docs/user/guide/providers.i18n.yaml | 4 +- docs/user/guide/providers.md | 6 +- docs/user/guide/providers.zh.md | 6 +- docs/user/guide/quickstart.i18n.yaml | 4 +- docs/user/guide/quickstart.md | 4 +- docs/user/guide/quickstart.zh.md | 4 +- .../tests/fixtures/dsh-run.cordis.yml | 2 +- .../headless-agent/tests/headless.snapshot.ts | 2 +- .../snapshots/dsh-run/session.expected.jsonl | 4 +- knip.json | 5 + packages/acp/acp/src/index.ts | 4 +- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/boot/app-boot/src/profile.ts | 33 +- packages/boot/app-boot/tests/profile.spec.ts | 27 ++ packages/bundle/README.i18n.yaml | 4 +- packages/bundle/README.md | 2 +- packages/bundle/README.zh.md | 2 +- packages/bundle/base/README.i18n.yaml | 4 +- packages/bundle/base/README.md | 2 +- packages/bundle/base/README.zh.md | 2 +- packages/bundle/base/cordis.patch.yml | 8 + packages/bundle/base/package.json | 1 + packages/bundle/headless/README.i18n.yaml | 4 +- packages/bundle/headless/README.md | 8 +- packages/bundle/headless/README.zh.md | 8 +- packages/bundle/headless/cordis.patch.yml | 31 +- packages/bundle/headless/package.json | 11 +- packages/bundle/headless/src/index.ts | 212 +++++------ .../bundle/headless/tests/headless.spec.ts | 335 ++++++++---------- packages/bundle/headless/tsconfig.json | 12 +- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/cordis.patch.yml | 6 +- packages/client/connection/src/client/api.ts | 2 +- .../client/connection/src/client/fixture.ts | 24 +- .../client/connection/src/client/index.ts | 2 +- packages/client/connection/tests/fake-api.ts | 12 +- .../client/connection/tests/fixture.spec.ts | 2 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 4 +- packages/client/runtime/README.zh.md | 4 +- packages/client/runtime/tests/fake-api.ts | 6 +- packages/client/runtime/tests/manager.spec.ts | 2 +- packages/client/runtime/tests/session.spec.ts | 12 +- packages/client/ui-model/README.i18n.yaml | 4 +- packages/client/ui-model/README.md | 6 +- packages/client/ui-model/README.zh.md | 6 +- .../ui-model/src/client/ModelSelect.tsx | 18 +- .../client/ui-model/src/client/directory.ts | 28 +- packages/client/ui-model/src/client/index.ts | 20 +- packages/client/ui-model/src/client/slots.ts | 8 +- .../ui-model/tests/browser-plugin.spec.ts | 6 +- .../ui-model/tests/model-select.spec.tsx | 10 +- packages/core/README.i18n.yaml | 4 +- packages/core/README.md | 5 +- packages/core/README.zh.md | 5 +- .../core/agent-default-model/README.i18n.yaml | 6 + packages/core/agent-default-model/README.md | 25 ++ .../core/agent-default-model/README.zh.md | 25 ++ .../core/agent-default-model/package.json | 44 +++ .../core/agent-default-model/src/index.ts | 107 ++++++ .../core/agent-default-model/src/invariant.ts | 30 ++ .../tests/agent-default-model.spec.ts | 98 +++++ .../core/agent-default-model/tsconfig.json | 30 ++ .../core/agent-default-model/tsdown.config.ts | 25 ++ packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 2 +- packages/core/agent/README.zh.md | 2 +- packages/core/agent/src/index.ts | 2 +- .../src/{llm-target.ts => model-selection.ts} | 42 +-- ...target.spec.ts => model-selection.spec.ts} | 14 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 20 +- packages/host/apiproxy/README.zh.md | 22 +- packages/host/apiproxy/package.json | 1 + packages/host/apiproxy/src/api-proxy.ts | 116 +++--- packages/host/apiproxy/src/api/index.ts | 2 +- packages/host/apiproxy/src/api/llm.ts | 4 +- .../host/apiproxy/src/api/sessions.schema.ts | 12 +- packages/host/apiproxy/src/api/sessions.ts | 12 +- packages/host/apiproxy/src/index.ts | 97 +---- .../apiproxy/tests/api-proxy-approval.spec.ts | 4 +- .../apiproxy/tests/api-proxy-blank.spec.ts | 2 +- .../apiproxy/tests/api-proxy-cold.spec.ts | 26 +- .../apiproxy/tests/api-proxy-commands.spec.ts | 2 +- .../apiproxy/tests/api-proxy-config.spec.ts | 17 +- .../tests/api-proxy-default-route.spec.ts | 108 ------ .../apiproxy/tests/api-proxy-fork.spec.ts | 4 +- .../apiproxy/tests/api-proxy-models.spec.ts | 26 +- .../tests/api-proxy-projections.spec.ts | 2 +- .../apiproxy/tests/api-proxy-question.spec.ts | 2 +- .../apiproxy/tests/api-proxy-rename.spec.ts | 2 +- .../apiproxy/tests/api-proxy-search.spec.ts | 2 +- .../tests/api-proxy-subagents.spec.ts | 2 +- .../apiproxy/tests/api-proxy-view.spec.ts | 10 +- .../tests/api-proxy-workspace.spec.ts | 2 +- .../apiproxy/tests/client-handler.spec.ts | 2 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 2 +- packages/host/apiproxy/tsconfig.json | 3 + .../tool-cordis/src/api-catalog.ts | 18 + .../todo/tool-todo/tests/projection.spec.ts | 2 +- pnpm-lock.yaml | 41 ++- scripts/gen-cordis-catalog.ts | 2 + scripts/gen-doc-graphs.ts | 8 + .../request-response.expected.json | 2 +- .../verify-package-readme-model-experience.ts | 5 +- tsconfig.host.json | 1 + 159 files changed, 1508 insertions(+), 1042 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.md create mode 100644 .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.zh.md create mode 100644 packages/core/agent-default-model/README.i18n.yaml create mode 100644 packages/core/agent-default-model/README.md create mode 100644 packages/core/agent-default-model/README.zh.md create mode 100644 packages/core/agent-default-model/package.json create mode 100644 packages/core/agent-default-model/src/index.ts create mode 100644 packages/core/agent-default-model/src/invariant.ts create mode 100644 packages/core/agent-default-model/tests/agent-default-model.spec.ts create mode 100644 packages/core/agent-default-model/tsconfig.json create mode 100644 packages/core/agent-default-model/tsdown.config.ts rename packages/core/agent/src/{llm-target.ts => model-selection.ts} (55%) rename packages/core/agent/tests/{llm-target.spec.ts => model-selection.spec.ts} (86%) delete mode 100644 packages/host/apiproxy/tests/api-proxy-default-route.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml index faade67459..f38ba32c11 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.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-15-llm-model-catalog-and-acp-selection.md -2026-07-15-llm-model-catalog-and-acp-selection.md: 9edc723b0dfafeaf395eb9325373835138ddbc41 -2026-07-15-llm-model-catalog-and-acp-selection.zh.md: aefd0af3e4de8fac4f5819e0ee279d3343aec6be +2026-07-15-llm-model-catalog-and-acp-selection.md: 41174209d2c44bedb5dd0615c5f877ecebd43639 +2026-07-15-llm-model-catalog-and-acp-selection.zh.md: 43442487649f7a552d66bd66b64e0e5072f5e00b diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md index 9edc723b0d..41174209d2 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md @@ -34,9 +34,9 @@ The ACP automation transport is not a catalog consumer. Its deployment config su ### Prompt/request consistency and durability -`installAgentLlmTarget` (in `dsh-agent`) installs scoped `system-prompt/assemble` and `agent/request` listeners for a front-door-owned target. Prompt assembly snapshots the selected pair once per step, overwrites the assembled `provider` and `model` variables after downstream prompt listeners, and the request listener applies that same snapshot after downstream request listeners. A selection during asynchronous assembly therefore starts on the next step rather than splitting prompt text from routing. Other call-config fields remain untouched. +`installModelSelection` (in `dsh-agent`) installs scoped `system-prompt/assemble` and `agent/request` listeners for a front-door-owned selection. Prompt assembly snapshots the selected pair once per step, overwrites the assembled `provider` and `model` variables after downstream prompt listeners, and the request listener applies that same snapshot after downstream request listeners. A selection during asynchronous assembly therefore starts on the next step rather than splitting prompt text from routing. Other call-config fields remain untouched. -The request header remains the durable source of truth. When a selected target is actually used, the existing full `request/header` snapshot records it, and a front door initializes its selection from the folded last request header before falling back to creation options. A selection that is never used by a request is intentionally in-memory only because it never became model-visible state. +The request header remains the durable source of truth. When a selection is actually used, the existing full `request/header` snapshot records it, and a front door initializes its selection from the folded last request header before falling back to creation options. A selection that is never used by a request is intentionally in-memory only because it never became model-visible state. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md index aefd0af3e4..4344248764 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md @@ -34,9 +34,9 @@ ACP 自动化传输层不是目录消费方。它通过部署配置为新创建 ### 提示词/请求一致性与持久化 -`installAgentLlmTarget`(位于 `dsh-agent`)为前门拥有的目标安装 agent 作用域的 `system-prompt/assemble` 与 `agent/request` 监听器。提示词组装在每个步骤对所选组合做一次快照,在下游提示词监听器之后覆写组装出的 `provider` 与 `model` 变量;请求监听器在下游请求监听器之后应用同一快照。因此,发生在异步组装期间的选择会从下一个步骤生效,而不会让提示词文本与路由分裂。其他调用配置字段保持不变。 +`installModelSelection`(位于 `dsh-agent`)为前门拥有的选择安装 agent 作用域的 `system-prompt/assemble` 与 `agent/request` 监听器。提示词组装在每个步骤对所选组合做一次快照,在下游提示词监听器之后覆写组装出的 `provider` 与 `model` 变量;请求监听器在下游请求监听器之后应用同一快照。因此,发生在异步组装期间的选择会从下一个步骤生效,而不会让提示词文本与路由分裂。其他调用配置字段保持不变。 -请求头仍是持久化的真源。当所选目标真正被使用时,现有的完整 `request/header` 快照会记录它;前门先从折叠后的最后一个请求头初始化其选择,然后才回退到创建选项。从未被请求使用的选择有意只保留在内存中,因为它从未成为模型可见状态。 +请求头仍是持久化的真源。当某个选择真正被使用时,现有的完整 `request/header` 快照会记录它;前门先从折叠后的最后一个请求头初始化其选择,然后才回退到创建选项。从未被请求使用的选择有意只保留在内存中,因为它从未成为模型可见状态。 ## 考虑过的替代方案 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 4578e9e268..13da6f9711 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: 8e020e4fe9b60100671c0cf0e98e28532d850f94 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: 49ebd642264c663377f16b8669fedc112d3bb6c7 +2026-07-19-gui-layering-and-rpc-protocol.md: ceb8e5db593aef14d6db259f6293381fe83defe2 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: ecd577f7ec5af5d588d0dbf03f94010e00782732 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 8e020e4fe9..ceb8e5db59 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 @@ -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 `dshClient` 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 application shapes, 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 shapes: `dsh web` = startHost + webserver + the built `dsh-frontend` dist; `dsh run` = headless in-process calls, zero HTTP. + - `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh run` = [a direct core Agent/Session front door](2026-08-09-headless-direct-core-front-door.md), with zero Host, HTTP, or browser layer. - A future Electron shape 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 shape'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 shapes are the template: `apps/cli/src/web.ts` (startHost + dist location + startWebServer + signal shutdown) and `headless.ts` (startHost + InProcessApiClient isomorphic direct calls, zero HTTP zero ports). ACP-class protocol bridges do not follow this checklist: they expose core to the external ecosystem, mount via `ctx.plugin(front-door plugin)` directly, and wear no fetch. +The two existing shapes preserve the boundary: the Web shape 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, mount via `ctx.plugin(front-door plugin)` directly, and wear no 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 — `dsh run` headless is the protocol's second real consumer | +| `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 | | `WebApiClient` | dsh-client-connection | `globalThis.fetch` uplink + one same-origin WebSocket downlink per logical stream | the browser shape; 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) | | (future) IPC bridge subclass | apps/electron | IPC serialization round trip | swaps only doFetch; contract and base class unchanged | @@ -242,7 +242,7 @@ Every client shape consumes one contract: adding a unary method is a five-step m |---|---| | Packaging by "product shape" (a web family, an electron family) | What shapes share is host/client capability, not the shape itself; capability-provider layering means a new shape needs zero new packages | | A package per mixture (e.g. a standalone headless package) | A mixture has exactly one consumer (its own app); packaging it is ownerless abstraction, while assembly in the app is readable and disposable | -| Consuming clients connecting to ctx directly (skipping the apiproxy layer) | A second command plane bypasses the contract, losing wire validation/observability/multi-client consistency; ctx keeps exactly two formal uses — front doors and headless event subscription | +| Consuming clients connecting to ctx directly (skipping the apiproxy layer) | Client shapes require wire validation, observability, and multi-client consistency. Direct headless is a local front door with no client boundary and uses the public Agent/Session seams rather than a client command plane | | webserver depending on runtime (saving the handler injection) | Structural-typing injection keeps webserver reusable by sidecars/tests with zero workspace deps; a package dependency would drag assembly knowledge into the carrier layer | | Package names without the group prefix (continuing dsh-) | `dsh-runtime`/`dsh-web-ui` lose their belonging in the flat npm namespace; the cost is one explicit paths entry per package | | Reusing the in-repo JSON-RPC 2.0 (dsh-jsonrpc) | Numeric error codes degrade to a single fallback code, contracts get aligned by hand in two copies, and naming drifts without a convention | 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 49ebd64226..ecd577f7ec 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 @@ -29,7 +29,7 @@ Status: implemented - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现住在 `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` = startHost + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = headless 进程内直调,零 HTTP。 + - `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = [直接使用核心 Agent/Session 的前门](2026-08-09-headless-direct-core-front-door.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`**,否则零端口。 -现有两形态即模板:`apps/cli/src/web.ts`(startHost + dist 定位 + startWebServer + 信号停机)与 `headless.ts`(startHost + InProcessApiClient 同构直调,零 HTTP 零端口)。ACP 类协议桥不走本清单:它把 core 暴露给外部生态,直接 `ctx.plugin(前门插件)` 挂载、不套 fetch。 +现有两种形态保持这一边界:Web 形态挂载 Host、载体与浏览器组合,而 `dsh run` 挂载直接使用核心服务的 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` headless 即协议第二真实消费者 | +| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧;载体测试与调用方可以在不打开端口的情况下运行这套协议,而产品 `dsh run` 直接驱动 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 桥子类 | apps/electron | IPC 序列化往返 | 仅换 doFetch,约定/基类零改 | @@ -240,7 +240,7 @@ export type ResponseValue = |---|---| | 按「产品形态」分包(web 一族、electron 一族) | 形态间共享的是 host/client 两侧能力而非形态本身;能力支持方分层让新形态零新包 | | 混合体建包(如 headless 独立包) | 混合体只有一个消费者(它自己的 app),建包是无主抽象;拼装写在 app 里可读可弃 | -| 消费型 client 直连 ctx(省 apiproxy 一层) | 第二命令面绕开约定,wire 校验/观测/多端一致性全失;ctx 只留给前门与 headless 事件订阅两个正式用途 | +| 消费型 client 直连 ctx(省 apiproxy 一层) | client 形态需要 wire 校验、观测与多 client 一致性。直接 headless 是没有 client 边界的本地前门,使用公开的 Agent/Session seam,而不是 client 命令面 | | webserver 依赖 runtime(省 handler 注入) | 结构 typing 注入让 webserver 可被 sidecar/测试复用且零 workspace 依赖;包依赖会把装配知识拖进承载层 | | 包名不带组前缀(沿用 dsh-<尾段>) | `dsh-runtime`/`dsh-web-ui` 在扁平 npm 命名空间里失去归属信息;代价只是每包一条显式 paths | | 复用仓内 JSON-RPC 2.0(dsh-jsonrpc) | 数字错误码退化成单码兜底、约定双份人肉对齐、命名无 convention 自然漂移 | diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index 2c1f309a79..2ee0de0625 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.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-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: 88f94b1f58ae7a3451c7772f4a9ff7d6564254c0 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 5f03dfbb8e5eaeeb52076584721e70ea66a292df +2026-07-24-web-config-tree-boot-and-transport-layering.md: 496499a691dbca012e5e953cbb6eb1d0bf25b635 +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 4a83db6f797ad2a0b048ef996d10dbc5ea0a33dd diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index 88f94b1f58..496499a691 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -16,23 +16,23 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) **Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. -**Config sources have one declaration place each.** yml static values are engineering defaults; the profile json (`./.dsh-tmp-profile/config.json`, read-only, never created, cwd-anchored until the `$DSH_HOME` migration) is user config mapped through a static `PROFILE_MAPPINGS` table onto target rows (`provider`/`model` → the `api-gateway` row, `persistenceRoot` → the jsonl row); CLI flags map onto the `webserver` row with a field set disjoint from the json's; env values enter through yml `!!js` expressions, never through the mapping table. Patches replace a row's config wholesale, so the entry class re-reads the yml row's static values (bypass parse) and merges overrides on top. An unmapped json key fails loud. The resolved frontend `distIndex` rides the same patch channel — an assembly fact, not user config. +**Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless front door](2026-08-09-headless-direct-core-front-door.md) and the Web gateway consume the same state. -**The transport splits five ways.** `dsh-host-apiproxy` upgraded to the gateway plugin (`api-gateway` row): default-exports `ApiProxyService`, config `{provider, model}`, provides `ctx.apiProxy`, transport-agnostic and registers no routes — `createApiProxy` moved here from the retired runtime package. `dsh-host-webserver` shrank to a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, per-request failures answer 400 and log without exiting, and knows no harness concepts. The connection node half owns the binding: it injects both services and registers `toFetchHandler(ctx.apiProxy)` under the `/api` prefix — future IPC carriers swap connection's transport while the gateway stays untouched. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns the graph: incremental per-package scanning (no full-rescan code path — `internal/plugin` marks the fiber's entry name dirty, a flush reconciles each name against live entries, package metadata including negative verdicts is cached forever, re-hashing is reachable only through `rebuilt(id)`), the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload: `fs.watchFile` stat-polling driven by `onGraphChanged` membership, and the `/plugins/events` SSE route. +**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{workspaceRoot?}`, consumes the base layer's front-door-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns incremental package scanning, the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. **Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`. ## Consequences - Recomposing a web deployment is a yml/patch edit; the retired pieces (`mountWebPlugins`, `CLIENT_PACKAGES`, `createHostWebPluginRegistry`, `startWebServer`, the webserver's graph/SSE/api knowledge) are deleted. -- Headless boots the same composition through the same entry (landed in the stacked follow-up): port 0 is its only surface difference, the model face gains `ask_user_question`/workspace context/model titles per the unification ruling, and `bootHost`/`startHost` retired with the `dsh-host-runtime` package. The profile write path, the `$DSH_HOME` profile relocation, and IPC carriers remain recorded deferrals. +- [Headless is a direct core front door](2026-08-09-headless-direct-core-front-door.md): its shipped profile contains the shared base Agent capabilities and omits Host, HTTP, Web, and browser layers. The transport split in this note is the browser surface's contract. - A TypeScript pitfall worth remembering: a `declare module 'cordis'` augmentation in a file with **no cordis import** is demoted to a standalone module declaration and silently shatters the program-wide `Context` merge (`ctx.on`/`ctx.effect` vanish across the program). Anchor with `import type {} from 'cordis'`. ## Alternatives considered | Rejected | One-line reason | |---|---| -| Dedicated `dsh-host-profile` receiver package | The profile json is consumed at patch time; the only runtime consumer of `{provider, model}` is the gateway itself — its config is the receiver | +| Dedicated `dsh-host-profile` receiver package | User model state belongs to the Settings-backed `ctx.agentDefaultModel`; an extra Host receiver would duplicate ownership and exclude direct front doors | | Runtime `assembly` shim plugin providing an `apiHandler` service | Existed only because `createApiProxy` lived in runtime; moving it into apiproxy made the gateway self-hosting, and `toFetchHandler` is a pure function the binding side calls | | Full-rescan + incremental scan coexisting | Two implementations, two semantics; the single per-package path covers the activation pass too | | A bespoke `./impl` export on the modules package | Non-uniform export surface; the standard `./client` carries the whole browser half | diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index 5f03dfbb8e..4a83db6f79 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -16,23 +16,23 @@ Status: implemented **boot 胶水由两个类组成。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有那些必须独立于 cordis、提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest(元数据清单)、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐一创建图行、settle、sweep。 -**每个配置源有唯一声明位置。** yml 静态值是工程默认;profile json(`./.dsh-tmp-profile/config.json`,只读、绝不创建、暂锚 cwd 直至 `$DSH_HOME` 迁移)是用户配置,经静态 `PROFILE_MAPPINGS` 表映射到目标行(`provider`/`model` → `api-gateway` 行,`persistenceRoot` → jsonl 行);CLI(命令行界面)flags 映射到 `webserver` 行、字段集与 json 不相交;env 值经 yml `!!js` 表达式进入,绝不进映射表。patch 整体替换行 config,故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。未映射的 json 键 fail loud。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。 +**每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 前门](2026-08-09-headless-direct-core-front-door.md)与 Web 网关消费同一份状态。 -**传输五分。** `dsh-host-apiproxy` 升格网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,config `{provider, model}`,provide `ctx.apiProxy`,传输无关、不注册路由——`createApiProxy` 从已退役的运行时包迁入。`dsh-host-webserver` 缩成朴素路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败答 400 并记日志,不退出进程,不认识任何 harness 概念。connection node 半拥有绑定:inject 两个服务,把 `toFetchHandler(ctx.apiProxy)` 注册在 `/api` 前缀下——将来 IPC 载体只换 connection 的传输,网关零改动。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有图:单包增量扫描(无全量重扫路径——`internal/plugin` 把 fiber 的 entry 名标脏,flush 逐名对账 live entries,包括否定结论在内的包元数据会永久缓存,重哈希唯一入口 `rebuilt(id)`)、bundle 路由、index tap、`onRebuilt`/`onGraphChanged` 通知。HMR node 半拥有开发期重载:`fs.watchFile` stat 轮询、watch 集合跟随 `onGraphChanged`、`/plugins/events` SSE 路由。 +**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{workspaceRoot?}`,消费 base 层不偏向特定前门的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有单包增量扫描、bundle 路由、index tap 与 `onRebuilt`/`onGraphChanged` 通知。HMR node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。 **包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设专用子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读取该槽位(缺少时显式抛错)并 provide `ctx.modules`。 ## 后果 - 重组一个 web 部署 = 改 yml/patch;退役件(`mountWebPlugins`、`CLIENT_PACKAGES`、`createHostWebPluginRegistry`、`startWebServer`、webserver 的图/SSE/api 知识)全部删除。 -- headless 已在 stacked 后续轮迁入同一组合同一入口:唯一面差异是 port 0,模型面按统一裁决获得 `ask_user_question`/workspace context/模型标题,`bootHost`/`startHost` 随 `dsh-host-runtime` 包退役。profile 写入路径、profile 迁 `$DSH_HOME`、IPC 载体仍为挂账项。 +- [Headless 是直接 core 前门](2026-08-09-headless-direct-core-front-door.md):其随附 profile 包含共享的 base Agent 能力,并省去 Host、HTTP、Web 与浏览器层。本笔记的传输划分是浏览器 surface 的契约。 - 一个值得记住的 TypeScript 坑:`declare module 'cordis'` augmentation 所在文件若**没有任何 cordis import**,会被降级成独立 module declaration,无声打散全程序的 `Context` merge(`ctx.on`/`ctx.effect` 全程序消失)。用 `import type {} from 'cordis'` 锚定。 ## 考虑过的替代方案 | 弃案 | 一行理由 | |---|---| -| 专门的 `dsh-host-profile` 受体包 | profile json 在 patch 阶段消费完;`{provider, model}` 的唯一运行时消费方是网关自己——受体即网关 config | +| 专门的 `dsh-host-profile` 受体包 | 用户模型状态归 Settings 支撑的 `ctx.agentDefaultModel` 所有;额外的 Host 受体会重复归属,并排除直接前门 | | 运行时里的 `assembly` 垫层插件(provide `apiHandler`) | 它的存在只因 `createApiProxy` 住运行时;本体迁入 apiproxy 后网关自持插件身份,且 `toFetchHandler` 是绑定方自己调的纯函数 | | 全量重扫与增量扫描并存 | 两条实现两份语义;单包路径足以覆盖激活初扫 | | modules 包特设 `./impl` 出口 | 出口面不统一;标准 `./client` 承载完整浏览器半 | 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 3e1d038514..342bcac5e0 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: 8613e600ad633818abb4319e614230340c3b3876 -2026-08-05-profile-plugin-bundles.zh.md: 03a771364c4f3262801f28e68b96ec5de633e5ec +2026-08-05-profile-plugin-bundles.md: f183fc5ff5f3c8d850e913d9a9b836f075da7a3b +2026-08-05-profile-plugin-bundles.zh.md: a186a27aff9ff3b4202ce5327f88637caa52b11d 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 8613e600ad..f183fc5ff5 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 @@ -12,7 +12,7 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y 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`. -The shipped compositions became bundles: `@deepseek-ai/dsh-base` (the former base rows as one insert), `@deepseek-ai/dsh-web-app` (the former web overlay plus a runtime glue plugin that owns what used to be launcher code — frontend-dist resolution, the web-surface prompt section, bash runtime variables, the URL line), and `@deepseek-ai/dsh-headless` (a one-shot runner plugin over base + web-app). `dsh web` stays as an alias for `--profile web` carrying the Web flag family; `dsh run [--profile ] "task"` owns one-shot execution and defaults to the headless profile, while generic `dsh --profile ` boots without a task; `dsh --config` is removed (its uses migrate to `--patch`). `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` after `add`/`remove` (a bundle-less package warns and stays a plain dependency). +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 front door](2026-08-09-headless-direct-core-front-door.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. 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 03a771364c..a186a27aff 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 @@ -12,7 +12,7 @@ Status: implemented 一切都变成 **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` 使用完全相同的路径。 -已交付的组合改造成了组合包:`@deepseek-ai/dsh-base`(原有基础行合并为一次插入)、`@deepseek-ai/dsh-web-app`(原 web overlay,外加一个接管原启动器代码的运行时粘合插件——前端 dist 解析、web 表层提示词段落、bash 运行时变量、URL 行)、`@deepseek-ai/dsh-headless`(叠加在 base + web-app 之上的一次性 runner 插件)。`dsh web` 保留为携带 Web flag 家族的 `--profile web` 别名;`dsh run [--profile ] "task"` 负责一次性执行,默认使用 headless profile,而通用的 `dsh --profile ` 只启动 profile,不携带任务;`dsh --config` 被移除(其用途迁移到 `--patch`)。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并在 `add`/`remove` 后调和 `dsh.profile.bundles`(没有组合包声明的包会给出警告,保持为普通依赖)。 +随附的组合包是 `@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-front-door.md)负责 headless 组合契约。 [`dsh run` 命令决策](../feature/2026-08-08-dsh-run-headless-command.md)负责一次性语法;本 Agent Note 负责该语法所选择的 profile 组合。 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.i18n.yaml new file mode 100644 index 0000000000..cab1c8e338 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.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-09-headless-direct-core-front-door.md +2026-08-09-headless-direct-core-front-door.md: cae17970633d6eda9d4f823118827ee2bfe32abf +2026-08-09-headless-direct-core-front-door.zh.md: 8ff5e870b7f0fbc82065c6c55b4635cdc586036c diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.md new file mode 100644 index 0000000000..cae1797063 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.md @@ -0,0 +1,44 @@ +# Agent Note: headless is a direct core front door + +Status: implemented + +English | [中文](2026-08-09-headless-direct-core-front-door.zh.md) + +## Problem + +The `headless` product contract is one local task with final assistant text on stdout, a success-sensitive exit code, empty stderr on success, and no listening port. A composition containing Workspace Host services, ApiProxy, HTTP, the Web runtime, or browser plugins contradicts that contract and makes local completion depend on an unrelated transport tree. + +The direct front door still needs the same deployment model state as Web-created Agents. A separate provider/model default would give one deployment two answers, while deriving completion before the Agent and Session persistence are quiescent permits stdout and the exit code to observe incomplete state. + +## Decision + +The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The headless bundle supplies its persona and tool mode, disables HMR, mounts the Code Mode worker explicitly, and inserts `headless-runner`. Its tree contains no `@deepseek-ai/dsh-host-*` package, ApiProxy, HTTP server, Web runtime, or browser client. Code Mode and Session persistence are one-shot Agent capabilities independent of Web presentation. + +`headless-runner` is a direct core front door. After Loader settlement, it reads `ctx.agentDefaultModel.currentSelection()`, creates a fresh persisted Agent through `ctx.agents.create`, installs that `ModelSelection` in the Agent scope, waits for startup quiescence, anchors the Session sequence, submits one ordinary user message, and waits for quiescence again. It awaits `ctx.sessions.flush`, folds its durable event interval for the last non-empty assistant text and final `turn/end` reason, writes the text plus one newline to stdout, and requests bounded launcher shutdown with exit 0 exactly when the reason is `completed`. Unexpected driver failures use stderr and exit 1. + +`@deepseek-ai/dsh-agent-default-model` owns the transport-independent default used for an Agent without a session-local selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel` and registers the `agent-default-model` Settings section. Composition config supplies `{provider, model}`; user settings may also supply `reasoningEffort`. `currentSelection()` returns the live complete selection and `saveSelection()` writes it as a complete section, so a selection without an effort clears any stored effort. `dsh-base` supplies the composition entry. Direct and ApiProxy front doors consume this service; ApiProxy alone owns session-local precedence, model validation, and persistence of accepted Web selections. + +`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. + +## 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, non-completed exits, direct failures, Loader-time disposal, and flush-before-exit ordering. The keyless assembled snapshot drives `dsh run` through a replayed tool round trip and records direct user-message provenance. 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 + +| Alternative | Contract mismatch | +|---|---| +| Keep `dsh-web-app` but suppress its observation line | The process still opens a port and carries the Host, Web, and browser trees. | +| Build a Host-only one-shot bundle around ApiProxy | ApiProxy is a client protocol gateway; a local one-shot front door has no client boundary. | +| Use `InProcessApiClient` for product-level protocol coverage | Product execution would depend on an unrelated protocol solely to exercise that protocol. | +| Give headless a separate provider/model config | Direct and Web creation would have independent defaults and persistence. | +| Omit Code Mode and Session persistence | Both capabilities belong to one-shot Agent execution rather than Web presentation. | +| Normalize every tuple containing Web and headless bundles | Bundle lists are an extension surface; only the exact installation-owned tuple is safe to classify. | + +## 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 has direct user provenance 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-front-door.zh.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.zh.md new file mode 100644 index 0000000000..8ff5e870b7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.zh.md @@ -0,0 +1,44 @@ +# Agent Note: headless 是直接使用核心服务的入口 + +Status: implemented + +[English](2026-08-09-headless-direct-core-front-door.md) | 中文 + +## 问题 + +`headless` 的产品契约是一个本地任务:最终 assistant 文本写入 stdout,退出状态反映成功与否,成功时 stderr 为空,并且不打开监听端口。包含 Workspace Host 服务、ApiProxy、HTTP、Web 运行时或浏览器插件的组合违背这一契约,也使本地完成状态依赖无关的传输树。 + +直接前门仍需要与 Web 所创建 Agent 相同的部署模型状态。独立的提供方/模型默认值会让同一部署产生两种答案,而在 Agent 与会话持久化完全停稳之前推导完成状态,会让 stdout 与退出状态观察到不完整状态。 + +## 决策 + +随附的 `headless` profile 包含 `dsh-base` 与 `dsh-headless`。headless 组合包提供自身的 persona 与工具模式、禁用 HMR(热模块替换)、显式挂载 Code Mode worker,并插入 `headless-runner`。其插件树不包含任何 `@deepseek-ai/dsh-host-*` 包、ApiProxy、HTTP server、Web 运行时或浏览器客户端。Code Mode 与会话持久化均为独立于 Web 呈现的一次性 Agent 能力。 + +`headless-runner` 是直接使用核心服务的入口。Loader 完全加载后,它读取 `ctx.agentDefaultModel.currentSelection()`,通过 `ctx.agents.create` 创建一个新的持久化 Agent,在 Agent 作用域中安装该 `ModelSelection`,等待启动工作完全停稳,锚定会话事件序号,提交一条普通用户消息,再次等待完全停稳。随后,它等待 `ctx.sessions.flush`,折叠自身持有的持久事件区间,以取得最后一条非空 assistant 文本和最终 `turn/end` 结束原因,将文本连同一个换行写入 stdout,并且仅在结束原因为 `completed` 时请求启动器以退出状态 0 有界关闭。驱动器的意外失败写入 stderr 并以 1 退出。 + +`@deepseek-ai/dsh-agent-default-model` 拥有与传输无关的默认值,供没有会话级选择的 Agent 使用。`AgentDefaultModelService` 提供 `ctx.agentDefaultModel` 并注册 `agent-default-model` Settings 分节。组合配置提供 `{provider, model}`,用户设置还可以提供 `reasoningEffort`。`currentSelection()` 返回当前的完整选择,`saveSelection()` 则写入完整分节,因此不含强度的选择会清除已存强度。`dsh-base` 提供组合条目。直接前门与 ApiProxy 前门均消费该服务;只有 ApiProxy 负责会话级优先级、模型校验与已接受 Web 选择的持久化。 + +`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 工厂使用真实的会话存储与 Agent 注册表,固定空闲态到空闲态的聚合、延迟异步完成、未完成退出、直接失败、Loader 加载期间的 dispose(资源释放),以及退出前 flush 的顺序。组装后的无密钥快照通过回放的工具往返驱动 `dsh run`,并记录直接用户消息的来源。构建后二进制验收通过已发布入口访问 mock 提供方,并要求最终文本出现在 stdout、退出状态为 0 且 stderr 为空。配置转储验收排除随附 headless 树中的所有 Host、Web 与 Client 包;PTY 关闭覆盖要求不出现观察行,并在有界时间内完成 dispose。 + +## 考虑过的替代方案 + +| 替代方案 | 契约不匹配之处 | +|---|---| +| 保留 `dsh-web-app`,但隐藏观察行 | 进程仍会打开端口并携带 Host、Web 与浏览器插件树。 | +| 围绕 ApiProxy 构建纯 Host 一次性组合包 | ApiProxy 是客户端协议网关,而本地一次性前门没有客户端边界。 | +| 使用 `InProcessApiClient` 实现产品级协议覆盖 | 产品执行会仅为测试无关协议而依赖该协议。 | +| 为 headless 单独提供提供方/模型配置 | 直接创建与 Web 创建会拥有彼此独立的默认值和持久化。 | +| 省略 Code Mode 与会话持久化 | 两项能力都属于一次性 Agent 执行,而不是 Web 呈现。 | +| 规范化所有包含 Web 与 headless 组合包的元组 | 组合包列表是扩展面;只有精确的安装过程所属元组可以安全分类。 | + +## 后果 + +`dsh run` 提供本地 Agent 任务,而不是浏览器观察、Host API 或 HTTP。需要这些能力的用户选择 `dsh web`。成功时 stderr 为空,完成结果在持久化 flush 后推导,持久化会话仍可供后续工具使用。初始用户消息直接来自用户,因此不携带 ApiProxy `rpcId`。 + +ApiProxy 载体覆盖保留在 ApiProxy 包中。自定义一次性 profile 可以显式包含 Host 或 Web 组合包;随附 profile 与可识别的安装过程所属元组均不含 Web。 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml index 77104f258e..626d98b3c8 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.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-24-web-session-model-selector.md -2026-07-24-web-session-model-selector.md: 05e923fb3b5df72485ffb8d6ff5aa0104b19a8a0 -2026-07-24-web-session-model-selector.zh.md: c1fd59ada0340a984ddf4a9dcc4c357f9609acb1 +2026-07-24-web-session-model-selector.md: 78017b14a806f8e609a85e340094cd2a349d47e1 +2026-07-24-web-session-model-selector.zh.md: 87a342721f5e47f5bcedfafb578eb6916101d2f5 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md index 05e923fb3b..78017b14a8 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md @@ -6,17 +6,17 @@ English | [中文](2026-07-24-web-session-model-selector.zh.md) ## Problem -The Web conversation displayed and sent through the Host's fixed provider/model route without exposing that route or letting a user change it. The TUI already had a session-local route target, but copying its presentation or hardcoding DeepSeek models in the browser would split model discovery and step-boundary semantics across front doors. A switch made while a response is running also needs one atomic boundary: prompt variables and request routing cannot observe different targets. +The Web conversation needs a visible, mutable session model selection sourced from the Host. Copying TUI presentation or hardcoding DeepSeek models in the browser would split model discovery and step-boundary semantics across front doors. A switch made while a response is running also needs one atomic boundary: prompt variables and request routing cannot observe different selections. ## Decision -The Web Host reuses `installAgentLlmTarget` for every created or resumed agent. The provider/model/reasoning target starts from the latest `request/header` when the session has used a model, otherwise from the Host default route. `session.selectModel` changes the session-local mutable target, and prompt assembly captures it with request routing; a switch during a running step therefore applies to the next assembled step. The next consumed target persists through the existing full `request/header` snapshot, while a choice that has not reached a request remains process-local. +The Web Host installs `ModelSelection` for every created or resumed Agent. The provider/model/reasoning selection comes from the latest `request/header` when the session has used a model, otherwise from `ctx.agentDefaultModel`. `session.selectModel` assigns the session-local selection, and prompt assembly captures it with request routing; a switch during a running step therefore applies to the next assembled step. The next consumed selection persists through the full `request/header` snapshot, while a choice that has not reached a request remains process-local. -The session RPC domain exposes a `session.models` directory and `session.selectModel`. The directory is built dynamically from the LLM registry and grouped by provider; each listed model's exact metadata adds adapter-owned reasoning effort ids, names, descriptions, and optional default. Provider catalogs and exact metadata load concurrently by provider and fail independently, so successful groups remain usable alongside retryable failure records. Catalog membership stays advisory: `session.models.current` is returned independently and can remain routable when absent from every group, but the Host does not synthesize an unlisted row after its provider stops advertising it. The two surfaces answer that state differently on purpose: the TUI still renders the unlisted current model as its own row and marks it current, while Web shows the unset trigger label and asks for a replacement. Web is the surface where a catalog is edited, so a target the user just deleted should read as a decision to make rather than a selection to keep; the TUI, which only picks from what exists, has no such edit to reconcile. The cost is real and accepted — a Web composer showing the unset label can still send to the routed target — and the divergence is deliberate, not a missed migration. Exact resolution decides whether a route and explicit effort are available. Selection uses `resolveCallConfig` to reject unsupported effort ids and materialize an adapter-configured default before updating the target. +The session RPC domain exposes a `session.models` directory and `session.selectModel`. The directory is built dynamically from the LLM registry and grouped by provider; each listed model's exact metadata adds adapter-owned reasoning effort ids, names, descriptions, and optional default. Provider catalogs and exact metadata load concurrently by provider and fail independently, so successful groups remain usable alongside retryable failure records. Catalog membership stays advisory: `session.models.current` is returned independently and can remain routable when absent from every group, but the Host does not synthesize an unlisted row after its provider stops advertising it. The two surfaces answer that state differently: the TUI renders the unlisted current model as its own row, while Web shows the unset trigger label and asks for a replacement. Web is the surface where a catalog is edited, so an absent row presents a selection decision; the TUI only picks from existing rows. A Web composer showing the unset label can still send with its current routable selection. Exact resolution decides whether a provider/model pair and explicit effort are available. Selection uses `resolveCallConfig` to reject unsupported effort ids and materialize an adapter-configured default before assigning the selection. -The browser `ModelService` owns one `ModelDirectory` per live session. Its snapshot contains the current complete target, grouped catalog, provider failures, operation error, and `idle`/`loading`/`ready`/`selecting`/`error` state. Mounting primes the trigger label and each menu open refreshes the directory. Directory and selection calls share an operation generation so older responses cannot replace a newer result; connection reset discards the process-local projection before restoring the Host target. Failures retain the previous current target and usable groups. +The browser `ModelService` owns one `ModelDirectory` per live session. Its snapshot contains the current complete `ModelSelection`, grouped catalog, provider failures, operation error, and `idle`/`loading`/`ready`/`selecting`/`error` state. Mounting primes the trigger label and each menu open refreshes the directory. Directory and selection calls share an operation generation so an older response cannot replace a newer result; connection reset discards the process-local projection before restoring the Host selection. Failures retain the previous selection and usable groups. -`@deepseek-ai/dsh-client-ui-conversation` declares the session-scoped single slot `conversation.input.model` as a child of its composer-bar entry. InputBar renders the seat in its trailing controls immediately before the pending indicator and primary button; the seat receives the bar's `locked` owner prop and session scope. `@deepseek-ai/dsh-client-ui-model` occupies that seat and also contributes `/model` over the same directory. Its compact trigger displays the exact catalog model name and effective reasoning label. When the current target is absent from the groups, the trigger instead displays `Select model`, the model list marks no row active, and the Effort row stays absent; choosing a listed model replaces the complete target through the existing selection path. The upward menu otherwise first offers Model and Effort; Model drills into provider groups, while Effort drills into the adapter-ordered levels. The provider-default row appears only when the adapter does not configure a model default. +`@deepseek-ai/dsh-client-ui-conversation` declares the session-scoped single slot `conversation.input.model` as a child of its composer-bar entry. InputBar renders the seat in its trailing controls immediately before the pending indicator and primary button; the seat receives the bar's `locked` owner prop and session scope. `@deepseek-ai/dsh-client-ui-model` occupies that seat and also contributes `/model` over the same directory. Its compact trigger displays the exact catalog model name and effective reasoning label. When the current selection is absent from the groups, the trigger displays `Select model`, the model list marks no row active, and the Effort row stays absent; choosing a listed model assigns the complete selection through the shared selection path. The upward menu otherwise first offers Model and Effort; Model drills into provider groups, while Effort drills into the adapter-ordered levels. The provider-default row appears only when the adapter does not configure a model default. The production browser roster is assembled from `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml`; the model feature is one `dshClient` row rather than a package hardcoded in Web boot code. Its package manifest orders it after the runtime and command feature, while Cordis service injection waits for the conversation slot before registering the composer occupant. @@ -26,18 +26,18 @@ The production browser roster is assembled from `apps/cli/config/base.cordis.yml **Hardcode the current DeepSeek catalog in the Web client.** This would drift from registered adapters and exclude deployment-owned providers. The LLM registry remains the source of provider and model metadata, including partial lookup failures. -**Keep `High`/`Max` as client-local UI state.** Static DeepSeek labels cannot represent `off`, pi-ai provider vocabularies, adapter defaults, validation, resume, or the next provider request. Exact-model metadata owns the selectable vocabulary, and the session target owns the selected id. +**Keep `High`/`Max` as client-local UI state.** Static DeepSeek labels cannot represent `off`, pi-ai provider vocabularies, adapter defaults, validation, resume, or the next provider request. Exact-model metadata owns the selectable vocabulary, and the session selection owns the selected id. -**Make the selection a global default.** A global mutation would unexpectedly redirect other open conversations. The target belongs to one live session, while Host configuration remains the default for sessions without a logged request. +**Use only a global default.** A default mutation would unexpectedly redirect blank conversations. The session selection belongs to one live session, while `ctx.agentDefaultModel` supplies the fallback for sessions without a logged request. -**Reject changes while an agent is running.** The shared atomic target already separates the assembled step from the next selection. Keeping the selector available lets the user prepare the following step without altering the in-flight request. +**Reject changes while an Agent is running.** The atomic selection snapshot separates the assembled step from the next selection. Keeping the selector available lets the user prepare the following step without altering the in-flight request. **Persist every click as a new session event.** A choice is not model-visible until prompt assembly consumes it. Persisting unused UI intent would add a durable event that does not reconstruct a model request; the existing `request/header` records the first request that actually uses the route. ## Consequences -Any Host-backed Web conversation, including a blank session, can switch among dynamically discovered provider groups and adapter-owned reasoning levels without displaying duplicated `provider/model` labels. The current consumed target survives resume and reconnect; catalog names remain presentation-only, while selection and persistence use provider/model/effort ids. A provider catalog or exact-metadata outage degrades only that group. Route changes can reduce provider-side cache reuse, but the selector adds no prompt content and does not disturb the in-flight step. A model without reasoning metadata has no Effort row. +Any Host-backed Web conversation, including a blank session, can switch among dynamically discovered provider groups and adapter-owned reasoning levels without displaying duplicated `provider/model` labels. The consumed selection survives resume and reconnect; catalog names remain presentation-only, while selection and persistence use provider/model/effort ids. A provider catalog or exact-metadata outage degrades only that group. Provider/model changes can reduce provider-side cache reuse, but the selector adds no prompt content and does not disturb the in-flight step. A model without reasoning metadata has no Effort row. ## Testing -Host tests pin grouped discovery, catalog and exact-metadata failure isolation, logged effort restoration without stale-row injection, advisory unlisted selection, unsupported effort rejection, default materialization, and next-assembly switching. Client tests pin the shared directory, reconnect restoration, and complete-target submission. Component tests pin dynamic effort labels, descriptions, provider-default exposure, effort submission, and the `Select model` fallback for a removed row. The keyless built-app fixture loads the production model plugin, selects OpenAI's GPT-5 and its Max effort, sends a turn, and verifies that the next generated response reports both ids; the DeepSeek configuration fixture removes the active catalog row and pins the fallback before choosing a replacement. +Host tests pin grouped discovery, catalog and exact-metadata failure isolation, logged effort restoration without stale-row injection, advisory unlisted selection, unsupported effort rejection, default materialization, and next-assembly switching. Client tests pin the shared directory, reconnect restoration, and complete-selection submission. Component tests pin dynamic effort labels, descriptions, provider-default exposure, effort submission, and the `Select model` fallback for an absent row. The keyless built-app fixture loads the production model plugin, selects OpenAI's GPT-5 and its Max effort, sends a turn, and verifies that the next generated response reports both ids; the DeepSeek configuration fixture omits the active catalog row and pins the fallback before choosing a replacement. diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md index c1fd59ada0..87a342721f 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md @@ -6,17 +6,17 @@ Status: implemented ## 问题 -Web 对话原本通过 Host 固定的提供方与模型路由显示并发送消息,既不呈现该路由,也不允许用户更改。TUI 已经具备会话级的路由目标,但如果照搬其呈现方式,或在浏览器中硬编码 DeepSeek 模型,就会让模型发现逻辑和步骤边界语义分散到不同前门中。响应运行期间发生的切换还需要一个原子边界:提示词变量与请求路由不能观测到不同的目标。 +Web 对话需要一项由 Host 提供、可见且可更改的会话模型选择。如果照搬 TUI 的呈现方式,或在浏览器中硬编码 DeepSeek 模型,就会让模型发现逻辑和步骤边界语义分散到不同前门中。响应运行期间发生的切换还需要一个原子边界:提示词变量与请求路由不能观测到不同的选择。 ## 决策 -Web Host 为每个新建或恢复的 agent(智能体)复用 `installAgentLlmTarget`。如果会话已经使用过模型,提供方/模型/推理(reasoning)目标从最新的 `request/header` 开始;否则采用 Host 默认路由。`session.selectModel` 会更改会话级可变目标,提示词组装则将该目标与请求路由一并捕获,因此运行中步骤发生的切换会应用于下一个组装步骤。下一个实际采用的目标通过现有的完整 `request/header` 快照持久化;尚未进入请求的选择则仅保存在当前进程中。 +Web Host 为每个新建或恢复的 Agent 安装 `ModelSelection`。如果会话已经使用过模型,提供方/模型/推理(reasoning)选择来自最新的 `request/header`;否则来自 `ctx.agentDefaultModel`。`session.selectModel` 会赋值会话级选择,提示词组装则将它与请求路由一并捕获,因此运行中步骤发生的切换会应用于下一个组装步骤。下一个实际采用的选择通过完整的 `request/header` 快照持久化;尚未进入请求的选择则仅保存在当前进程中。 -会话 RPC 领域公开 `session.models` 模型目录与 `session.selectModel`。该目录从 LLM(大语言模型)注册表动态构建,并按提供方分组;每个已列出模型的精确元数据还会加入由适配器持有的推理强度 ID、名称、说明和可选默认值。各提供方的目录与精确元数据会按提供方并发加载,且彼此独立失败,因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考:`session.models.current` 独立返回,即使不在任何分组中也仍然可以路由,但提供方停止公布该模型后,Host 不会合成未列出行。两个前门有意对这一状态给出不同回答:TUI 仍把未列出的当前模型渲染为独立一行并标记为当前,Web 则显示未设置状态的触发器标签并要求选择替代模型。Web 正是编辑目录所在的前门,因此用户刚刚删除的目标应当读作一个有待作出的决定,而不是一项可以保留的选择;TUI 只在已存在的模型中挑选,没有这类编辑需要调和。这一代价真实存在且已被接受——显示未设置标签的 Web composer 仍会发送到实际路由的目标——这一分歧是有意为之,而不是一处遗漏的迁移。精确解析决定路由与显式推理强度是否可用。选择操作通过 `resolveCallConfig` 拒绝不支持的推理强度 ID,并在更新目标前具体化适配器配置的默认值。 +会话 RPC 领域公开 `session.models` 模型目录与 `session.selectModel`。该目录从 LLM(大语言模型)注册表动态构建,并按提供方分组;每个已列出模型的精确元数据还会加入由适配器持有的推理强度 ID、名称、说明和可选默认值。各提供方的目录与精确元数据会按提供方并发加载,且彼此独立失败,因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考:`session.models.current` 独立返回,即使不在任何分组中也仍然可以路由,但提供方停止公布该模型后,Host 不会合成未列出行。两个前门对这一状态给出不同回答:TUI 把未列出的当前模型渲染为独立一行,Web 则显示未设置状态的触发器标签并要求选择替代模型。Web 是编辑目录的 surface,因此缺席的目录行代表一项待作出的选择;TUI 只从现有行中选择。显示未设置标签的 Web composer 仍可以使用当前可路由选择发送消息。精确解析决定提供方/模型组合与显式推理强度是否可用。选择操作通过 `resolveCallConfig` 拒绝不支持的推理强度 ID,并在赋值该选择前具体化适配器配置的默认值。 -浏览器中的 `ModelService` 为每个实时会话持有一个 `ModelDirectory`。其快照包含当前完整目标、分组目录、提供方失败记录、操作错误,以及 `idle`、`loading`、`ready`、`selecting`、`error` 状态。挂载时会预先填充触发器标签,此后每次打开菜单都会刷新目录。目录与选择调用共用操作代次,防止较早响应覆盖较新结果;连接重置会先丢弃当前进程中的投影,再恢复 Host 目标。失败时保留先前的当前目标和可用分组。 +浏览器中的 `ModelService` 为每个实时会话持有一个 `ModelDirectory`。其快照包含当前完整的 `ModelSelection`、分组目录、提供方失败记录、操作错误,以及 `idle`、`loading`、`ready`、`selecting`、`error` 状态。挂载时会预先填充触发器标签,此后每次打开菜单都会刷新目录。目录与选择调用共用操作代次,防止较早响应覆盖较新结果;连接重置会先丢弃当前进程中的投影,再恢复 Host 选择。失败时保留先前的选择和可用分组。 -`@deepseek-ai/dsh-client-ui-conversation` 将会话作用域的单实例 slot `conversation.input.model` 声明为其输入栏 entry 的子 slot。InputBar 在尾部控件区将该 seat 渲染于 pending 指示器与主按钮之前;该 seat 接收输入栏的 `locked` owner prop 与会话作用域。`@deepseek-ai/dsh-client-ui-model` 占用该 seat,并在同一目录上提供 `/model`。其紧凑型触发器显示目录中精确模型的名称与生效的推理强度标签。当前目标不在分组中时,触发器改为显示 `Select model`,模型列表不标记任何活动行,Effort 行也保持隐藏;选择一个已列出的模型,会通过现有选择路径替换完整目标。除此情形外,向上展开的菜单会首先提供 Model 与 Effort;Model 可深入提供方分组,Effort 可深入适配器排序的级别。仅当适配器没有配置模型默认值时,才显示提供方默认值行。 +`@deepseek-ai/dsh-client-ui-conversation` 将会话作用域的单实例 slot `conversation.input.model` 声明为其输入栏 entry 的子 slot。InputBar 在尾部控件区将该 seat 渲染于 pending 指示器与主按钮之前;该 seat 接收输入栏的 `locked` owner prop 与会话作用域。`@deepseek-ai/dsh-client-ui-model` 占用该 seat,并在同一目录上提供 `/model`。其紧凑型触发器显示目录中精确模型的名称与生效的推理强度标签。当前选择不在分组中时,触发器显示 `Select model`,模型列表不标记任何活动行,Effort 行也保持隐藏;选择一个已列出的模型,会通过共享的选择路径赋值完整选择。除此情形外,向上展开的菜单会首先提供 Model 与 Effort;Model 可深入提供方分组,Effort 可深入适配器排序的级别。仅当适配器没有配置模型默认值时,才显示提供方默认值行。 生产环境的浏览器名册由 `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同组装;模型功能对应其中一行 `dshClient` 配置项,而不是 Web boot 代码中硬编码的包。其包 manifest(元数据清单)将加载顺序置于运行时与命令功能之后;Cordis 服务注入则等待 conversation slot 可用,再注册 composer 占用方。 @@ -26,18 +26,18 @@ Web Host 为每个新建或恢复的 agent(智能体)复用 `installAgentLlm **在 Web 客户端中硬编码当前 DeepSeek 目录。** 该目录会与已注册适配器发生偏离,也会排除部署自有的提供方。LLM 注册表继续作为提供方与模型元数据的真源,也负责呈现部分查询失败。 -**将 `High`/`Max` 保留为客户端本地 UI 状态。** 静态 DeepSeek 标签无法覆盖 `off`、pi-ai 的提供方词汇、适配器默认值与校验,也不能参与恢复或下一次提供方请求。精确模型元数据拥有可选词汇,会话目标则拥有已选择的 ID。 +**将 `High`/`Max` 保留为客户端本地 UI 状态。** 静态 DeepSeek 标签无法覆盖 `off`、pi-ai 的提供方词汇、适配器默认值与校验,也不能参与恢复或下一次提供方请求。精确模型元数据拥有可选词汇,会话选择则拥有已选择的 ID。 -**将选择设为全局默认值。** 全局变更会意外改道其他已打开的对话。目标仅属于一个实时会话;对于没有已记录请求的会话,Host 配置仍是默认值。 +**只使用全局默认值。** 默认值变更会意外改道空白对话。会话选择仅属于一个实时会话;对于没有已记录请求的会话,`ctx.agentDefaultModel` 提供回退值。 -**agent 运行期间拒绝更改。** 共享原子目标已经将当前组装步骤与下一次选择分离。保持选择器可用,可以让用户为下一个步骤预先选择模型,而不会改变正在执行的请求。 +**Agent 运行期间拒绝更改。** 原子选择快照将当前组装步骤与下一次选择分离。保持选择器可用,可以让用户为下一个步骤预先选择模型,而不会改变正在执行的请求。 **将每次点击作为新的会话事件持久化。** 只有提示词组装采用某项选择后,该选择才对模型可见。持久化尚未使用的 UI 意图,会增加一个无法重建模型请求的持久事件;现有 `request/header` 会记录首次实际使用该路由的请求。 ## 影响 -任何由 Host 支撑的 Web 对话(包括空白会话)都可以在动态发现的提供方分组和适配器持有的推理级别之间切换,而无需显示重复的 `provider/model` 标签。当前实际采用的目标会在恢复和重连后保留;目录名称仅用于呈现,而选择和持久化使用提供方/模型/推理强度 ID。某个提供方的目录或精确元数据不可用时,只有相应分组会降级。路由变更可能降低提供方侧的缓存复用率,但选择器不会添加任何提示词内容,也不会干扰正在执行的步骤。没有推理元数据的模型不显示 Effort 行。 +任何由 Host 支撑的 Web 对话(包括空白会话)都可以在动态发现的提供方分组和适配器持有的推理级别之间切换,而无需显示重复的 `provider/model` 标签。实际采用的选择会在恢复和重连后保留;目录名称仅用于呈现,而选择和持久化使用提供方/模型/推理强度 ID。某个提供方的目录或精确元数据不可用时,只有相应分组会降级。提供方/模型变更可能降低提供方侧的缓存复用率,但选择器不会添加任何提示词内容,也不会干扰正在执行的步骤。没有推理元数据的模型不显示 Effort 行。 ## 测试 -Host 测试固定分组发现、目录与精确元数据失败隔离、已记录推理强度恢复且不注入陈旧行、不受目录约束的未列出模型选择、不支持的推理强度拒绝、默认值具体化,以及切换仅影响下一次组装。客户端测试固定共享目录、重连恢复与完整目标提交。组件测试固定动态推理强度标签、说明、提供方默认值展示、推理强度提交,以及已删除模型行的 `Select model` 回退。无密钥 built-app fixture(测试前置数据)加载生产模型插件,选择 OpenAI 的 GPT-5 及其 Max 推理强度,发起一个轮次,并验证下一条生成的响应会报告两个 ID;DeepSeek 配置 fixture 会删除活动目录行,在选择替代模型之前固定该回退。 +Host 测试固定分组发现、目录与精确元数据失败隔离、已记录推理强度恢复且不注入陈旧行、不受目录约束的未列出模型选择、不支持的推理强度拒绝、默认值具体化,以及切换仅影响下一次组装。客户端测试固定共享目录、重连恢复与完整选择提交。组件测试固定动态推理强度标签、说明、提供方默认值展示、推理强度提交,以及缺席模型行的 `Select model` 回退。无密钥 built-app fixture(测试前置数据)加载生产模型插件,选择 OpenAI 的 GPT-5 及其 Max 推理强度,发起一个轮次,并验证下一条生成的响应会报告两个 ID;DeepSeek 配置 fixture 会省略活动目录行,在选择替代模型之前固定该回退。 diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml index f513e90666..0f28e14222 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.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-07-default-model-follows-the-picker.md -2026-08-07-default-model-follows-the-picker.md: d20f0ab8b8c8bd19f596e6ef73f0a58d96c24d38 -2026-08-07-default-model-follows-the-picker.zh.md: 0d2821cb63407fe766e6fe3d36de31d9fc6f1c13 +2026-08-07-default-model-follows-the-picker.md: ed7e7a424d2cacadea890506fd9150ffdf7a993c +2026-08-07-default-model-follows-the-picker.zh.md: 52308518f06a140a9f79440e48229356bc61dca5 diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md index d20f0ab8b8..ed7e7a424d 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md @@ -6,38 +6,42 @@ English | [中文](2026-08-07-default-model-follows-the-picker.zh.md) ## Problem -The route a new session started from was frozen into the gateway's composition entry (`api-gateway` in the web-app bundle patch). Switching models in a conversation reached that conversation only: the next session went back to the shipped default, and the only way to change it was to hand-edit a `cordis.yml` row and restart. There was no user-settings tier between the composition and the per-session choice. +A session model picker and a deployment default are two layers of the same preference. If the picker affects only its addressed session, the next blank session can select a different model with no user-facing way to align the default. If the default lives inside a Host gateway, direct Agent front doors cannot share it without depending on Host or duplicating state. + +Reasoning effort makes the persistence shape significant: a model selection without an effort must clear a stored effort, or the next Agent may apply an effort that its selected model does not accept. ## Decision -`ApiProxyService` registers its `{provider, model, reasoningEffort?}` slice as the `api-gateway` settings section: the composition entry is the `base` layer and `settings.yaml` layers the user's choice over it. `workspaceRoot` stays outside the section — a launcher fact, not a preference. `reasoningEffort` is the mirror case: it lives in the section but NOT in the plugin config, because the seam merges the user layer over the composition entry per field and an absent key cannot override a present one. A composition-set effort would therefore survive every later switch to a model without one — precisely the stranding the wholesale `replace` exists to prevent. Effort is a per-model fact anyway; a deployment default for it belongs on the adapter profile, which resolves per model. +`AgentDefaultModelService` provides `ctx.agentDefaultModel` and registers `{provider, model, reasoningEffort?}` as the `agent-default-model` Settings section. Its `{provider, model}` composition entry is the base layer and `settings.yaml` supplies the user layer. The service is front-door-neutral, so direct creation and ApiProxy-backed creation share one default ([headless direct core front door](../architecture/2026-08-09-headless-direct-core-front-door.md)). `workspaceRoot` remains ApiProxy config because it is a Host launcher fact rather than model state. -`session.selectModel` records an accepted switch as the new default. There is no separate gesture: switching models in the composer IS how the default is chosen. The write is `replace`, not `update` — switching to a model with no reasoning effort has to clear a stored one, and a merged patch would strand it for the next session to fail on. A storage failure is logged without undoing the switch, which already applies to its own session, and a deployment with no settings provider keeps the entry with the switch staying process-local. +`reasoningEffort` belongs to the Settings section but not to the plugin config. Settings layers merge by field, so a configured effort would survive a user selection that omits it. `saveSelection()` instead writes the complete user section; absence therefore clears a stored effort. A deployment-wide effort default belongs to the adapter profile, which resolves it per model. -`ApiProxyDefaults` carries `defaultTarget()` and `persistDefaultTarget()` closures instead of flat `provider`/`model` fields, so `createApiProxy` needs no knowledge of the settings seam. +`session.selectModel` applies an accepted `ModelSelection` to its session and calls `saveDefaultModelSelection()` for the shared Agent default. A storage failure is logged without undoing the session selection. A deployment with no settings provider retains the composition entry and keeps the accepted selection only in that session. -`targetFor` resolves its tiers on **every** read rather than seeding a ref once: an explicit selection in this process, else the session's own latest logged `request/header`, else the live default. Both directions depend on the re-read. A session that has run a turn derives from its log forever after, so changing the default never retargets it. A session still blank starts from a default saved after it was created — which matters because New Session reuses a blank session rather than minting another, so a creation-time seed would show the superseded model in exactly the flow the feature exists for. +`ApiProxyDefaults` carries `defaultModelSelection()` and `saveDefaultModelSelection()` closures, so `createApiProxy` has no dependency on the Settings seam. `ApiProxyService` wires them to `ctx.agentDefaultModel.currentSelection()` and `ctx.agentDefaultModel.saveSelection()`. -The stored route is not validated against the registry. A default naming a route the Models page has since removed still reaches `session.models` as `current`, matching no advertised group — which is what makes the composer seat's existing fallback prompt for a selection instead of naming a model the deployment cannot reach. +`selectionFor(agent)` resolves its tiers on every read: a process-local session selection, otherwise the session's latest logged `request/header`, otherwise the live Agent default. A session with a logged request remains bound to that durable selection. A blank session observes the current default even when it was created before the preference was saved; this matches the New Session surface, which may reuse a blank session. + +The stored selection does not require catalog membership. A provider route may serve a model omitted from its advisory catalog. `session.models` therefore reports the stored selection independently of advertised groups and separately reports whether an adapter serves its provider. ## Consequences -`ApiProxyDefaults` changed shape, updating ~40 test construction sites. `host.describe` now reports the live default rather than a captured one, which is what it always meant. `settings.yaml` gains an `api-gateway:` section the moment a user switches models; the `api-gateway` namespace is deliberately NOT added to the gateway's exposed-namespace allowlist, so the Settings page neither reads nor writes it — the model picker is its editor. +`host.describe` reports the live Agent default. A successful model switch stores an `agent-default-model:` section in `settings.yaml`. The gateway does not expose that namespace through its Settings-page allowlist; the model picker is its editor. -## Follow-up: blocking a session that cannot send +## A session that cannot send -A default naming a route the Models page has since removed leaves the composer saying "Select model" while the input still accepts a message, which then fails inside the adapter mid-turn. Two changes close it. +`session.prompt` refuses with `model-unavailable` before opening a turn when no adapter serves the session's selected provider. This method is the enforcement boundary; a disabled composer is only a client affordance. -The Host refuses. `session.prompt` checks whether an adapter serves the session's route and answers `model-unavailable` before opening a turn. This is the enforcement boundary: a client that disables its composer is an affordance, and the method stays callable regardless. +`session.models` reports `routable`. The ui-model plugin projects an unroutable selection through `ctx.conversation.blocks`, and the composer becomes inert while leaving the model seat available. An unknown client-side routability state, including an initial or failed catalog load, does not block input. -The composer goes inert. `session.models` reports `routable`, and ui-model pushes a block through the new `ctx.conversation.blocks` registry; the bar renders the same disabled textarea it already renders without a workspace, with the blocker's own localized reason as the placeholder — except the model seat, which a block deliberately leaves live, because choosing a model is how the user clears it. The push direction is forced — ui-model already depends on ui-conversation, so ui-conversation cannot read it back. - -The gate is `routable`, NOT "the current target matches no advertised group". Catalog membership is advisory by design: a route serving a model it stopped advertising is absent from the groups yet perfectly usable, and blocking there would break a supported configuration (a narrowed `models` list over a live route). `routable` is also three-valued on the client — `null` before the first load or after a failed one never blocks, so a slow or unreachable Host cannot lock a working composer. +Routability is distinct from catalog membership. A live provider route can serve an unadvertised model, so absence from catalog groups does not imply that the session is unusable. ## Alternatives considered -- **Falling back to the composition entry when the stored route is unregistered.** Rejected: the composer would then name the shipped DeepSeek model instead of prompting, which is both a silent switch to a provider the user did not pick and the opposite of the requested behavior. -- **Validating and clearing a stale default.** Rejected: catalog membership is advisory by design (`buildModelCatalog` documents it), so an adapter may serve a model its own catalog stopped advertising; self-healing would break that deliberate case. -- **A `settings.update` merge patch.** Rejected: it cannot clear `reasoningEffort`, so a switch from a reasoning model to a plain one leaves an effort the next session fails on. -- **Persisting only from blank sessions.** Rejected: the most informative switch is the one made mid-conversation after seeing a model underperform, and that one would never be saved. -- **A separate "set as default" affordance.** Rejected for now: it adds a second gesture for what every comparable product infers from the switch itself. The cost is that a temporary switch in an old session also moves the default. +| Alternative | Contract mismatch | +|---|---| +| Fall back to the composition entry when the stored provider is unavailable | The product silently switches away from the user's selection. | +| Validate the stored selection against catalog membership | Catalogs are advisory and may omit requestable models. | +| Save with a merge patch | An omitted `reasoningEffort` cannot clear the stored field. | +| Save only selections made in blank sessions | An informed mid-conversation selection never becomes the deployment default. | +| Add a separate “set as default” gesture | The session picker and future-session preference can diverge despite representing the same user choice. | diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md index 0d2821cb63..52308518f0 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md @@ -6,38 +6,42 @@ Status: implemented ## 问题 -新会话的起始路由被冻结在网关的组合条目里(web-app bundle patch 中的 `api-gateway` 行)。在一段对话里切换模型只影响这段对话:下一个会话又回到出厂默认,而要改这个默认值,唯一的办法是手工编辑一条 `cordis.yml` 行并重启。组合层与每会话选择之间没有用户设置这一层。 +会话模型选择器与部署默认值是同一项偏好的两个层次。如果选择器只影响其所在会话,下一个空白会话可能选择不同模型,用户却没有途径使默认值与选择器一致。如果默认值位于 Host 网关内部,直接创建 Agent 的前门只有依赖 Host 或复制状态才能共享它。 + +推理强度使持久化形态成为契约的一部分:不含强度的模型选择必须清除已存强度,否则下一个 Agent 可能会采用所选模型不接受的强度。 ## 决定 -`ApiProxyService` 把自己的 `{provider, model, reasoningEffort?}` 切片注册为 `api-gateway` 设置段:组合条目是 `base` 层,`settings.yaml` 把用户的选择叠加其上。`workspaceRoot` 留在段外——它是启动器事实,不是偏好。`reasoningEffort` 则是镜像的一例:它在段里、但**不在**插件配置里,因为 seam 是按字段把用户层合并到组合条目之上的,缺席的键覆盖不了存在的键。组合层设的推理等级因此会在此后每一次切到不支持推理的模型时继续存活——正是整段 `replace` 想要杜绝的那种滞留。何况推理等级本就是按模型的事实,它的部署级默认值属于适配器 profile,那里是按模型解析的。 +`AgentDefaultModelService` 提供 `ctx.agentDefaultModel`,并把 `{provider, model, reasoningEffort?}` 注册为 `agent-default-model` Settings 分节。其 `{provider, model}` 组合条目是 base 层,`settings.yaml` 提供用户层。该服务不偏向特定前门,因此直接创建与 ApiProxy 支撑的创建共享同一个默认值([headless 直接 core 前门](../architecture/2026-08-09-headless-direct-core-front-door.md))。`workspaceRoot` 仍是 ApiProxy 配置,因为它是 Host 启动器事实,而不是模型状态。 -`session.selectModel` 把被接受的切换记录为新的默认值。没有另一个单独的手势:在输入框切模型**就是**选定默认值的方式。写入用 `replace` 而非 `update`——切到一个不支持推理的模型必须清掉已存的等级,而合并补丁会把它滞留下来,让下一个会话在它上面失败。存储失败只记日志,不撤销这次切换(它对自己所在的会话已经生效);没有设置提供方的部署保留组合条目,切换只停留在进程内。 +`reasoningEffort` 属于 Settings 分节,但不属于插件配置。Settings 层按字段合并,因此已配置的强度会在用户选择省略它时继续存在。`saveSelection()` 写入完整的用户分节;缺席值由此清除已存强度。部署级强度默认值属于适配器 profile,并由它按模型解析。 -`ApiProxyDefaults` 改为携带 `defaultTarget()` 与 `persistDefaultTarget()` 两个闭包,而不是扁平的 `provider`/`model` 字段,这样 `createApiProxy` 不需要知道设置这条缝的存在。 +`session.selectModel` 把被接受的 `ModelSelection` 应用于所在会话,并调用 `saveDefaultModelSelection()` 保存共享的 Agent 默认值。存储失败只记日志,不撤销会话选择。没有 Settings 提供方的部署保留组合条目,被接受的选择只停留在该会话中。 -`targetFor` 在**每一次**读取时解析各级,而不是只在创建时种一次 ref:本进程内的显式选择,其次是该会话自己最新记录的 `request/header`,最后才是活的默认值。两个方向都依赖这次重新读取。已经跑过一轮的会话此后永远从自己的日志推导,改默认值不会重定向它;而仍然空白的会话会用上它创建之后才保存的默认值——这一点很关键,因为新建会话是复用空白会话而不是再开一个,创建时种下的值恰好会在这个功能存在的意义所在的流程里显示已被取代的模型。 +`ApiProxyDefaults` 携带 `defaultModelSelection()` 与 `saveDefaultModelSelection()` 闭包,因此 `createApiProxy` 不依赖 Settings seam。`ApiProxyService` 将它们分别接到 `ctx.agentDefaultModel.currentSelection()` 与 `ctx.agentDefaultModel.saveSelection()`。 -存下来的路由不做注册表校验。默认值指向一条模型页已经删除的路由时,它照样作为 `current` 送到 `session.models`,匹配不到任何已公布的分组——而这正是让输入框选择器已有的回退提示重新选择、而不是显示一个部署根本够不着的模型的原因。 +`selectionFor(agent)` 每次读取时都解析各层:先取进程内的会话选择,其次取会话最新记录的 `request/header`,最后取当前 Agent 默认值。已有请求日志的会话持续绑定到该持久选择。空白会话即使创建于偏好保存之前,也会观察到当前默认值;这与 New Session surface 可能复用空白会话的行为一致。 + +已存选择不要求属于目录。某条提供方路由可能服务其咨询性目录未列出的模型。因此,`session.models` 会在已公布分组之外单独报告已存选择,并另行报告适配器是否服务其提供方。 ## 影响 -`ApiProxyDefaults` 形状变了,约 40 处测试构造点随之更新。`host.describe` 现在报告的是活的默认值而非捕获的快照,这本就是它一直想表达的含义。用户一旦切换模型,`settings.yaml` 就会多出一个 `api-gateway:` 段;`api-gateway` 这个 namespace 刻意**没有**加进网关的暴露名单,因此设置页既不读也不写它——模型选择器就是它的编辑器。 +`host.describe` 报告当前 Agent 默认值。模型切换成功后,`settings.yaml` 中会存有一个 `agent-default-model:` 分节。网关不通过 Settings 页 allowlist 暴露该 namespace;模型选择器是它的编辑器。 -## 后续:让发不出消息的会话禁止输入 +## 无法发送消息的会话 -默认值指向一条模型页已删除的路由时,编辑器显示「选择模型」,输入框却仍接受消息,然后这一轮在适配器内部失败。两处改动关掉这个口子。 +当没有适配器服务会话所选提供方时,`session.prompt` 会在开启轮次前以 `model-unavailable` 拒绝。这一方法是执行边界;禁用 composer 只是客户端提供的便利。 -宿主拒绝。`session.prompt` 检查是否有适配器服务该会话的路由,在开启轮次之前就以 `model-unavailable` 应答。这是执行边界:客户端禁用编辑器只是提示性设计,这个方法始终可被调用。 +`session.models` 报告 `routable`。ui-model 插件通过 `ctx.conversation.blocks` 投影不可路由的选择,composer 随之变为不可操作,同时保留模型 seat 可用。客户端不知道是否可路由时不会阻断输入,包括目录首次加载或加载失败的情况。 -编辑器变惰性。`session.models` 报告 `routable`,ui-model 经新的 `ctx.conversation.blocks` 注册表推送一个 block;输入栏渲染的仍是它在没有 Workspace 时就会渲染的那个禁用 textarea,只是把抬起方自己的本地化理由作为 placeholder——唯独模型 seat 被 block 刻意保留可用,因为用户正是靠选模型来解除它。推送方向是被迫的——ui-model 本就依赖 ui-conversation,因此 ui-conversation 读不回去。 - -闸门是 `routable`,**不是**「当前目标匹配不到任何已公布分组」。目录成员关系按设计是咨询性的:一条仍在服务、只是不再公布该模型的路由不在分组里,却完全可用,在那里阻断会破坏一种受支持的配置(对一条活着的路由收窄 `models` 列表)。`routable` 在客户端还是三值的——首次加载之前或加载失败之后的 `null` 绝不阻断,因此慢的或够不着的宿主锁不死一个本来能用的编辑器。 +可路由性与目录成员关系不同。仍在服务的提供方路由可以处理未公布的模型,因此不在目录分组中并不代表会话不可用。 ## 考虑过的替代方案 -- **存下来的路由未注册时回落到组合条目。** 否决:那样输入框会显示出厂的 DeepSeek 模型而不是提示选择,既是静默切到用户没选的提供方,也与要求的行为正好相反。 -- **校验并清空失效的默认值。** 否决:目录成员关系按设计是咨询性的(`buildModelCatalog` 有注释说明),适配器可以服务一个自己目录已不再公布的模型;自动修复会破坏这个刻意保留的情形。 -- **用 `settings.update` 合并补丁。** 否决:它清不掉 `reasoningEffort`,于是从推理模型切到普通模型会留下一个等级,让下一个会话在它上面失败。 -- **只在空白会话里持久化。** 否决:最有信息量的切换恰恰是对话到一半发现模型不行时做的那一次,而它永远存不下来。 -- **单独做一个「设为默认」的入口。** 目前否决:同类产品都从切换本身推断的事情,它却要多一个手势。代价是在老会话里的临时切换也会移动默认值。 +| 替代方案 | 契约不匹配之处 | +|---|---| +| 已存提供方不可用时回落到组合条目 | 产品会静默切离用户选择。 | +| 根据目录成员关系校验已存选择 | 目录仅供参考,可能省略仍可请求的模型。 | +| 使用合并 patch 保存 | 省略的 `reasoningEffort` 无法清除已存字段。 | +| 只保存空白会话中的选择 | 对话期间知情作出的选择不会成为部署默认值。 | +| 增加单独的「设为默认」手势 | 会话选择器与未来会话偏好虽然代表同一用户选择,却仍可能分歧。 | diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml index 145c1cf619..f4e308c8f2 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.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-08-dsh-run-headless-command.md -2026-08-08-dsh-run-headless-command.md: d1cc0573d1d94bb7b30e98aa685d2b7d89878a99 -2026-08-08-dsh-run-headless-command.zh.md: afb18fea6ac46064b6720d3bb68d7bc53a81d3f2 +2026-08-08-dsh-run-headless-command.md: 566eeb5b2a09a0d07d72a68e4a5f449d2822e708 +2026-08-08-dsh-run-headless-command.zh.md: 177410e783a37026940829da5f81343ddc61cb29 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md index d1cc0573d1..566eeb5b2a 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md @@ -6,36 +6,36 @@ English | [中文](2026-08-08-dsh-run-headless-command.zh.md) ## Problem -The product launcher attached optional task text to its generic profile boot: `dsh --profile headless "task"`. That made one argv shape mean either a long-lived profile or a one-shot run according to a row discovered only after composition. The parser's `ProfileInvocation` carried optional task state, help presented a profile implementation detail as the user command, and a custom profile could accept a task only through the same overloaded root. +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. -The former `dsh -p` spelling was already absent from the parser, so restoring it or detecting it specially would add compatibility machinery to a pre-release interface. A separate application-file proposal also used the `run` verb, leaving two incompatible owners for one top-level command. +The `run` verb must have one top-level meaning. Sharing it with application-file execution or inferring its meaning from positional shape creates the same ambiguity. ## Decision -One-shot execution owns an explicit grammar: +One-shot execution owns this grammar: ```text dsh run [--profile ] [--patch ...] ``` -`--profile` defaults to `headless` and remains available for custom one-shot compositions. `--patch` is repeatable and occupies the existing overlay layer. Commander joins the variadic task arguments with spaces and rejects a missing or blank task before boot. +`--profile` defaults to `headless` and supports custom one-shot compositions. `--patch` is repeatable and occupies the normal overlay layer. Commander joins the variadic task arguments with spaces and rejects a missing or blank task before boot. -The [profile plugin bundle decision](../architecture/2026-08-05-profile-plugin-bundles.md) owns the composition selected by this grammar. +`RunInvocation` is a distinct `DshInvocation` member. The generic profile invocation carries no task state and accepts no positional arguments. Both dispatch paths use `runProfile`: profile boot omits `task`, while `run` supplies it. A one-shot profile without `headless-runner` fails through the composed-row check, and profile boot containing that row without a task points to `dsh run --profile ""`. -`RunInvocation` is a separate `DshInvocation` member. The generic profile invocation no longer carries task text, and its root command accepts no positional arguments. Both dispatch paths call the existing deep `runProfile` module: `profile` omits `task`, while `run` supplies it. There is no shallow `run.ts` forwarding module and no alias, warning, or custom detector for former spellings; they fail through the ordinary Commander grammar. A one-shot profile without `headless-runner` still fails through the existing composed-row check, while booting a profile that contains that row without a task points to `dsh run --profile ""`. +The [profile plugin bundle decision](../architecture/2026-08-05-profile-plugin-bundles.md) owns composition. [Headless is a direct core front door](../architecture/2026-08-09-headless-direct-core-front-door.md) owns the execution contract: one fresh persisted Session, final assistant text on stdout, completed/non-completed exit mapping, empty stderr on success, no listening port, and bounded signal shutdown after Agent quiescence and Session flush. -The `run` verb belongs to one-shot task execution. Launching an application file must choose another command name; two top-level meanings selected by positional shape would recreate the ambiguity this command removes. - -The runner's user-visible contract stays the same: a fresh persisted session, browser observation URL on stderr, final assistant text on stdout, completed/non-completed exit mapping, and bounded signal shutdown. The product-level keyless acceptance exposed that the in-process mux consumer could lag the same-process `agent/status: idle` notification and derive output before reading the final frames. The idle notification now captures the authoritative final session sequence, and the runner waits until the ordered mux reaches that boundary (or the stream ends) before deriving text and exit reason. This enforces the existing idle-to-idle contract without adding a wire field or a timing delay. +The `run` verb belongs only to one-shot task execution. Application-file launch requires a distinct command name. ## Alternatives considered -- **Keep task text on `dsh --profile`.** Rejected because profile boot and one-shot execution remain one grammar whose meaning depends on a late composition check. -- **Preserve `dsh -p` or the positional profile form as aliases.** Rejected under the pre-release stance: compatibility branches would outlive the interface they were meant to retire. -- **Make `--profile headless` mandatory under `run`.** Rejected because the shipped one-shot surface should have the shortest canonical spelling, while optional `--profile` preserves plugin-defined one-shot compositions. -- **Give `dsh run` to application-file launch and choose another headless verb.** Rejected because `run` describes executing a task through the harness; application-file ownership would make the product's primary one-shot command less direct and collide with custom one-shot profiles. -- **Add `apps/cli/src/run.ts`.** Rejected because it would only forward to `runProfile`, splitting command ownership without hiding any complexity. +| Alternative | Contract mismatch | +|---|---| +| Put task text on root profile boot | Lifecycle meaning depends on a plugin row discovered after parsing. | +| Accept root aliases such as `dsh -p` | The pre-release grammar acquires compatibility branches with no current command ownership. | +| Require `--profile headless` | The shipped one-shot surface loses its shortest canonical spelling. | +| Use `dsh run` for application files | One top-level verb has two meanings and the primary task command becomes indirect. | +| Add a shallow `apps/cli/src/run.ts` forwarder | Command ownership splits without hiding any complexity. | ## Consequences -This is an intentional breaking CLI change. Documentation, help, parser tests, built-bin acceptance, PTY shutdown coverage, and the assembled keyless snapshot use `dsh run`. Existing custom one-shot profiles keep working through `--profile`; long-lived profiles and config dumps retain their existing root grammar. The competing application-file command must be renamed and rebased separately rather than sharing or overloading `run`. +Help, documentation, parser tests, built-bin acceptance, PTY shutdown coverage, and the assembled keyless snapshot use `dsh run`. Custom one-shot profiles use `--profile`; long-lived profile boot and config dumps keep the root profile grammar. Application-file execution is a separate command concern. diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md index afb18fea6a..177410e783 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md @@ -6,36 +6,36 @@ Status: implemented ## 问题 -产品启动器过去把可选任务文本挂在通用 profile 启动命令上:`dsh --profile headless "task"`。于是,同一种 argv 形态会表示常驻 profile 或一次性运行,具体含义取决于组合完成后才发现的配置行。解析器的 `ProfileInvocation` 携带可选任务状态,帮助信息把 profile 的实现细节呈现为用户命令,自定义 profile 也只能通过同一个过载的根命令接收任务。 +通用 profile 启动与一次性任务执行具有不同的生命周期约定。若根语法接受可选任务文本,同一种 argv 形态会表示常驻进程或终止式任务,具体含义取决于组合完成后才发现的插件配置行。它还会把 profile 实现细节暴露成主要用户命令,并使自定义 profile 缺少明确的一次性入口。 -解析器中已经没有原来的 `dsh -p` 写法,因此恢复该写法或加入特殊检测,会给预发布接口增加兼容机制。另一个应用文件提案也使用 `run` 动词,使同一个顶层命令同时归属两个互不兼容的功能。 +`run` 动词必须只有一种顶层含义。与应用文件执行共用该动词,或根据位置参数形态推断含义,都会产生相同的歧义。 ## 决策 -一次性执行采用明确语法: +一次性执行采用以下语法: ```text dsh run [--profile ] [--patch ...] ``` -`--profile` 默认为 `headless`,同时保留对自定义一次性组合的支持。`--patch` 可重复使用,并沿用既有 overlay 层的位置。Commander 用空格拼接可变数量的任务参数,并在启动前拒绝缺失或空白任务。 +`--profile` 默认为 `headless`,并支持自定义一次性组合。`--patch` 可重复使用,并占据正常的 overlay 层。Commander 用空格拼接可变数量的任务参数,并在启动前拒绝缺失或空白任务。 -[profile 插件组合包决策](../architecture/2026-08-05-profile-plugin-bundles.md)负责该语法所选择的组合。 +`RunInvocation` 是单独的 `DshInvocation` 成员。通用 profile 调用不携带任务状态,也不接受位置参数。两条分派路径都使用 `runProfile`:profile 启动省略 `task`,而 `run` 提供该字段。缺少 `headless-runner` 的一次性 profile 会触发组合行检查;如果启动的 profile 包含该行却未提供任务,错误会指向 `dsh run --profile ""`。 -`RunInvocation` 是单独的 `DshInvocation` 成员。通用 profile 调用不再携带任务文本,其根命令也不接受位置参数。两条分派路径都调用已有的深层 `runProfile` 模块:`profile` 省略 `task`,`run` 则提供该字段。实现中没有只负责转发的浅层 `run.ts` 模块,也没有面向旧写法的别名、警告或自定义检测器;旧写法会按普通 Commander 语法失败。缺少 `headless-runner` 的一次性 profile 仍会触发既有的组合行检查;如果启动的 profile 包含该行却未提供任务,错误会指向 `dsh run --profile ""`。 +[profile 插件组合包决策](../architecture/2026-08-05-profile-plugin-bundles.md)负责组合。[Headless 是直接 core 前门](../architecture/2026-08-09-headless-direct-core-front-door.md)负责执行约定:一个新的持久化会话、stdout 上的最终 assistant 文本、completed/非 completed 的退出状态映射、成功时为空的 stderr、无监听端口,以及 Agent 完全停稳且会话 flush 后的有界信号关闭。 -`run` 动词只负责一次性任务执行。应用文件启动必须选择其他命令名;如果让两个顶层含义由位置参数形态决定,就会重新引入本命令消除的歧义。 - -运行器面向用户的约定保持不变:创建新的持久化会话,在 stderr 打印浏览器观察 URL,在 stdout 打印最终 assistant 文本,将完成/未完成映射为退出状态,并执行有界的信号关闭。产品级无密钥验收用例发现,进程内 mux 消费方可能落后于同进程的 `agent/status: idle` 通知,在读到最终帧之前就生成输出。idle 通知现在会捕获权威的会话最终事件序号,运行器则等待有序 mux 到达该边界(或流结束),再生成文本和退出原因。这一机制在不增加 wire 字段或定时延迟的前提下,落实了既有的 idle-to-idle 约定。 +`run` 动词只负责一次性任务执行。应用文件启动需要不同的命令名。 ## 考虑过的替代方案 -- **把任务文本保留在 `dsh --profile` 上。** 不予采纳:profile 启动和一次性执行仍共用同一套语法,其含义取决于较晚发生的组合检查。 -- **保留 `dsh -p` 或位置参数 profile 形式作为别名。** 不予采纳:根据预发布立场,这些兼容分支会比本应退役的接口存续更久。 -- **要求在 `run` 下必须指定 `--profile headless`。** 不予采纳:已交付的一次性接口应采用最短的规范写法,同时用可选的 `--profile` 保留插件定义的一次性组合。 -- **把 `dsh run` 交给应用文件启动,并为 headless 选择另一个动词。** 不予采纳:`run` 描述的是通过 harness 执行任务;若归应用文件所有,产品的主要一次性命令会更不直接,并与自定义一次性 profile 冲突。 -- **新增 `apps/cli/src/run.ts`。** 不予采纳:它只会转发到 `runProfile`,拆分命令归属,却没有隐藏任何复杂度。 +| 替代方案 | 约定不匹配之处 | +|---|---| +| 把任务文本放在根 profile 启动命令上 | 生命周期含义依赖解析后才发现的插件配置行。 | +| 接受 `dsh -p` 等根命令别名 | 预发布语法获得不属于任何当前命令的兼容分支。 | +| 要求指定 `--profile headless` | 随附的一次性 surface 失去最短的规范写法。 | +| 将 `dsh run` 用于应用文件 | 一个顶层动词具有两种含义,主要任务命令也变得间接。 | +| 添加仅转发的 `apps/cli/src/run.ts` | 命令归属被拆分,却没有隐藏任何复杂度。 | ## 后果 -这是一次有意为之的 CLI(命令行界面)破坏性变更。文档、帮助信息、解析器测试、构建后二进制验收、PTY 关闭覆盖和组装应用的无密钥快照都使用 `dsh run`。现有自定义一次性 profile 可继续通过 `--profile` 工作;常驻 profile 和配置 dump 保留既有的根命令语法。与之竞争的应用文件命令必须单独改名并 rebase,不得共享或重载 `run`。 +帮助信息、文档、解析器测试、构建后二进制验收、PTY 关闭覆盖和组装应用的无密钥快照均使用 `dsh run`。自定义一次性 profile 使用 `--profile`;常驻 profile 启动与配置 dump 使用根 profile 语法。应用文件执行是独立的命令关注点。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 162ea534b7..101332555d 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: 12108fcbff4e649d0bcb3e01e688fa334ab91b14 -README.zh.md: 37967973ed40143db0bc747875dbb6dad11d73b4 +README.md: dd29f7fc03a783079ea3194de99589c1f545be5b +README.zh.md: 60e7aa1ec1ea2fad7e3f3d97a0f6bf42355adffc diff --git a/apps/cli/README.md b/apps/cli/README.md index 12108fcbff..dd29f7fc03 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -9,7 +9,7 @@ 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, print the final answer, and exit; the profile defaults to `headless`. | +| `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 plugin --profile ` | Manage a profile's plugins by forwarding to pnpm in the profile directory. | diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 37967973ed..60e7aa1ec1 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -9,7 +9,7 @@ | 命令 | 用途 | |---|---| | `dsh --profile ` | 启动位于 `$DSH_HOME/profiles/` 的指定 profile。 | -| `dsh run [--profile ] [--patch ...] "task"` | 运行一个新的持久化会话,打印最终答案并退出;profile 默认为 `headless`。 | +| `dsh run [--profile ] [--patch ...] "task"` | 直接在 core 上运行一个新的持久化会话,打印最终答案并退出;profile 默认为 `headless`,且不挂载 Web server。 | | `dsh web` | `--profile web` 的别名,附带 Web flag 系列(`--host`、`--port`、`--dev` 等)。 | | `dsh plugin --profile ` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 | diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 8bebf18040..cfcd903e32 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -32,6 +32,8 @@ flowchart LR cfg --> plugin_dsh_base_user_interaction plugin_dsh_base_agent["agent
@deepseek-ai/dsh-agent"] cfg --> plugin_dsh_base_agent + plugin_dsh_base_agent_default_model["agent-default-model
@deepseek-ai/dsh-agent-default-model"] + cfg --> plugin_dsh_base_agent_default_model plugin_dsh_base_tasks["tasks
@deepseek-ai/dsh-tasks-local"] cfg --> plugin_dsh_base_tasks plugin_dsh_base_llm_retry["llm-retry
@deepseek-ai/dsh-llm-retry"] @@ -174,6 +176,7 @@ flowchart LR | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` | | `user-interaction` | `@deepseek-ai/dsh-user-interaction` | | `agent` | `@deepseek-ai/dsh-agent` | +| `agent-default-model` | `@deepseek-ai/dsh-agent-default-model` | | `tasks` | `@deepseek-ai/dsh-tasks-local` | | `llm-retry` | `@deepseek-ai/dsh-llm-retry` | | `settings` | `@deepseek-ai/dsh-settings-local` | diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index a478ae60d2..9bfe494ba6 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: e616b420d60e0fa372bf38950207245cb60ced98 -README.zh.md: 81320ecbd19012123ab7e69967cbadb088356cfe +README.md: 4f8aa86c0f6cdb91eb843048cd5ed4943a896c73 +README.zh.md: 09bc19220a869bedfcaa98baccbf6d4f6a3f3223 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index e616b420d6..4f8aa86c0f 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -10,7 +10,7 @@ This reference defines the profile, one-shot run, web-alias, plugin-management, 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 + web-app + headless). 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). 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 `. 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. @@ -27,7 +27,7 @@ dsh --profile web --patch ./extra.yml --dump-config `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, which drives one fresh persisted session through the in-process API carrier, prints the final assistant text on stdout, and exits 0 on a completed turn, else 1. At the idle boundary, the runner waits until its mux consumer has observed the session's final event sequence before deriving that output and exit reason. The session's Web host runs on an OS-assigned port and is announced on stderr, so the run is observable in a browser. +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. ## Plugin management diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 81320ecbd1..09bc19220a 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -10,7 +10,7 @@ 组合包名称先从 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 + web-app + headless)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 +`web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + headless)。加载时,与安装所管理的 headless 元组(base + web-app + headless)完全一致的列表会规范化为随附模板;包含额外项、缺少项或调整过顺序的组合包列表由用户拥有,保持不变。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 Profile 启动不接受位置参数任务。因此,挂载了一次性运行器行(`headless-runner`)的 profile 会显式报错,并提示规范命令 `dsh run --profile ""`,而不会触发该行原始的必填字段错误。 @@ -27,7 +27,7 @@ dsh --profile web --patch ./extra.yml --dump-config `dsh run [--profile ] [--patch ...] ` 会用空格拼接任务参数,拒绝缺失或空白任务,并让 `--profile` 默认为 `headless`。可重复使用的 `--patch` overlay 与 profile 启动的 overlay 位于同一层。所选的自定义 profile 必须挂载 `headless-runner`;否则启动器会在启动前失败,并在诊断中指明缺少该行。 -启动器把任务文本 patch 进运行器行,运行器再通过进程内 API 载体驱动一个全新的持久化会话,在 stdout 打印最终 assistant 文本,并在轮次完成时以 0 退出,否则以 1 退出。到达 idle 边界时,运行器会等到 mux 消费方观察到会话的最终事件序号,再生成输出与退出原因。会话的 Web 宿主运行在 OS 分配的端口上并公布到 stderr,因此该次运行可在浏览器中观察。 +启动器把任务文本 patch 进运行器行。Loader 结算后,运行器读取共享的 `ctx.agentDefaultModel` 默认值,通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对 Session 执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。 ## 插件管理 diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index a42780581f..073f541fe0 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -222,7 +222,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', }) expect(result.code, result.stderr).toBe(0) expect(result.stdout).toBe('published dsh run reached the mock') - expect(result.stderr).toMatch(/^dsh: observing at http:\/\/127\.0\.0\.1:\d+$/u) + expect(result.stderr).toBe('') expect(server.requests.length).toBeGreaterThan(0) expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true) expect(JSON.stringify(server.requests.map(request => request.body))).toContain('answer from the published entry') @@ -470,6 +470,20 @@ 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 () => { + 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.toContain("name: '@deepseek-ai/dsh-web-app'") + expect(stdout).not.toContain("name: '@deepseek-ai/dsh-client-") + }, 30_000) + it('composes the profile user layer and a --patch overlay in order', async () => { // Auto-init the web profile first, then write its user layer. const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home }) diff --git a/apps/cli/tests/fixtures/never-dispose.mjs b/apps/cli/tests/fixtures/never-dispose.mjs index 0c29cc2967..5dc8510550 100644 --- a/apps/cli/tests/fixtures/never-dispose.mjs +++ b/apps/cli/tests/fixtures/never-dispose.mjs @@ -8,6 +8,7 @@ import { existsSync } from 'node:fs' */ export function apply(ctx) { const keepAlive = setInterval(() => {}, 60_000) + process.stderr.write('dsh-test: never-dispose ready\n') ctx.effect(() => async () => { clearInterval(keepAlive) const armFile = process.env.DSH_TEST_SHUTDOWN_ARM_FILE diff --git a/apps/cli/tests/headless-shutdown.e2e.ts b/apps/cli/tests/headless-shutdown.e2e.ts index 237554864c..b0bcbb7cae 100644 --- a/apps/cli/tests/headless-shutdown.e2e.ts +++ b/apps/cli/tests/headless-shutdown.e2e.ts @@ -22,7 +22,7 @@ if pid == 0: os.chdir(cwd) os.execvpe(node, [node, *json.loads(launch_args_json)], env) -markers = [b"dsh: observing at ", b"dsh-test: never-dispose started"] +markers = [b"dsh-test: never-dispose ready", b"dsh-test: never-dispose started"] output = bytearray() marker_index = 0 deadline = time.monotonic() + float(timeout_seconds) @@ -73,7 +73,7 @@ async function runHeadlessPtySmoke(): Promise { name: 'dsh-profile-headless', private: true, dependencies: {}, - dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless'] } }, + dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'] } }, }, undefined, 2)) await writeFile(join(profileDir, 'cordis.patch.yml'), [ '- insert:', @@ -124,7 +124,8 @@ async function runHeadlessPtySmoke(): Promise { describe.skipIf(process.platform === 'win32')('headless process shutdown (real Loader tree in a PTY)', () => { it('lets a second Ctrl+C force exit while the first signal is draining', async () => { const output = await runHeadlessPtySmoke() - expect(output).toContain('dsh: observing at ') + expect(output).not.toContain('dsh: observing at ') + expect(output).toContain('dsh-test: never-dispose ready') expect(output).toContain('dsh-test: never-dispose started') }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/apps/web/tests/declared-reasoning.e2e.ts b/apps/web/tests/declared-reasoning.e2e.ts index 664f20dfe6..f908511214 100644 --- a/apps/web/tests/declared-reasoning.e2e.ts +++ b/apps/web/tests/declared-reasoning.e2e.ts @@ -1,6 +1,6 @@ // Web e2e scenario: a hand-declared model's `reasoningEfforts` reaches the // composer's effort pane — the levels a settings profile declares are exactly -// what the picker offers, and picking one records it with the default route. +// what the picker offers, and picking one records it with the Agent default. // Zero model calls: declaring, describing, and switching are settings/llm // traffic only, so there is no fixture and a stray stream would fail loud. import { readFile } from 'node:fs/promises' @@ -77,8 +77,8 @@ describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach th const snapshot = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) - // Picking a level is the same gesture that saves the default target, so - // the effort lands in the gateway's settings section beside the route. + // Picking a level is the same gesture that saves the default selection, so + // the effort lands in the Agent default Settings section beside provider/model. await page.getByRole('menuitemradio', { name: 'High' }).click() await expect.poll( async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), diff --git a/apps/web/tests/declared-reasoning.overlay.yml b/apps/web/tests/declared-reasoning.overlay.yml index d90452178c..ff89f036b3 100644 --- a/apps/web/tests/declared-reasoning.overlay.yml +++ b/apps/web/tests/declared-reasoning.overlay.yml @@ -2,7 +2,7 @@ # deepseek-official default would be a route nothing serves. This scenario # starts the default on its own declared reasoning model so the effort pane # describes that model from the first open. -- id: api-gateway +- id: agent-default-model config: provider: acme-gateway model: acme-think diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 24ee5a1616..0c43bf3998 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -1,5 +1,5 @@ // Web e2e scenario: switching models in the composer is how this deployment's -// default is chosen. The gesture writes the `api-gateway` settings section, a +// default is chosen. The gesture writes the shared `agent-default-model` settings section, a // session created afterwards starts from it, and a session that already logged // a route keeps deriving from its own log — the tier order the gateway // resolves on every read. @@ -21,7 +21,7 @@ import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' -/** Points the shipped `api-gateway` default at this scenario's own route. */ +/** Points the shipped shared Agent default at this scenario's own route. */ const OVERLAY = fileURLToPath(new URL('./default-model.overlay.yml', import.meta.url)) /** The route this scenario starts on, patched over the shipped default. */ @@ -110,12 +110,12 @@ describe('web e2e: the composer model switch is the default for later sessions', await page.getByRole('menuitem', { name: /模型/ }).click() await page.getByRole('menuitemradio', { name: 'Acme Large' }).click() - // The switch is what sets the default: the gateway's own settings section + // The switch is what sets the default: the shared Agent-route settings section // now names it, beside the provider profiles the Models page writes. await expect.poll( async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 10_000 }, - ).toContain('api-gateway:') + ).toContain('agent-default-model:') const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') expect(document).toContain(`provider: ${ROUTE}`) expect(document).toContain(`model: ${MODEL}`) diff --git a/apps/web/tests/default-model.overlay.yml b/apps/web/tests/default-model.overlay.yml index 540e4891d6..5ec0e77405 100644 --- a/apps/web/tests/default-model.overlay.yml +++ b/apps/web/tests/default-model.overlay.yml @@ -2,7 +2,7 @@ # deepseek-official default would be a route nothing serves — which the # composer now correctly refuses to type into. This scenario declares its own # pi-ai routes and starts the default on one of them. -- id: api-gateway +- id: agent-default-model config: provider: origin-gateway model: origin-large diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 1b81c2c5da..3b279ef25a 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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/architecture.md -architecture.md: 06c15f204b3a695de49df29938781f8f033c4a7e -architecture.zh.md: 29fe94f5a94c317854366e811dc6ceb74d2fddfe +architecture.md: ed33fb215c3b4fd830bef219da74f4ee98dec81d +architecture.zh.md: 19d2500142449c63d725f89a651b22205af6b301 diff --git a/docs/architecture.md b/docs/architecture.md index 06c15f204b..ed33fb215c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,6 +17,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and variables | | `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) | | `ctx.agents` | `dsh-agent` | live agents, delegated creation, `agent/*` events, process-local initiator scope | +| `ctx.agentDefaultModel` | [`dsh-agent-default-model`](../packages/core/agent-default-model/README.md) | Settings-backed model selection shared by Agent front doors | | `ctx.agentLoop` | `dsh-agent-loop` | concrete `Agent` driver | ### Capability Services diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 29fe94f5a9..19d2500142 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -17,6 +17,7 @@ | `ctx.systemPrompt` | `dsh-system-prompt` | 有序的提示词片段、工具 schema 和变量 | | `ctx.tools` | `dsh-tools` | 工具注册表和[执行流水线](tool-execution-pipeline.md) | | `ctx.agents` | `dsh-agent` | 活跃 agent(智能体)、委托创建、`agent/*` 事件、进程内发起方作用域 | +| `ctx.agentDefaultModel` | [`dsh-agent-default-model`](../packages/core/agent-default-model/README.md) | 由 Settings 支撑、供 Agent 入口共享的模型选择 | | `ctx.agentLoop` | `dsh-agent-loop` | 实体 `Agent` 驱动器 | ### 功能服务 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 4b46a6566b..d61353210e 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -94,6 +94,9 @@ flowchart LR pkg_skill_local["skill-local"] svc_agents["ctx.agents
Agent service"] pkg_acp["acp"] + pkg_agent_default_model["agent-default-model"] + svc_agentDefaultModel["ctx.agentDefaultModel
Default Agent model selection"] + pkg_headless["headless"] svc_agentLoop["ctx.agentLoop
Concrete loop driver"] pkg_agent_spine_demo["agent-spine-demo"] pkg_goal["goal"] @@ -176,6 +179,7 @@ flowchart LR pkg_tool_workflow["tool-workflow"] pkg_acp --> svc_approval pkg_agent --> svc_agents + pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop pkg_api_gateway --> svc_typertGateway pkg_approval --> svc_approval @@ -265,6 +269,8 @@ flowchart LR pkg_workflow --> svc_workflows pkg_workflow_workerthread --> svc_workflows pkg_workspace --> svc_workspace + svc_agentDefaultModel --> pkg_headless + svc_agentDefaultModel --> pkg_host_apiproxy svc_agentLoop --> pkg_agent_spine_demo svc_agents --> pkg_acp svc_agents --> pkg_agent_loop @@ -397,6 +403,7 @@ flowchart LR | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | +| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`headless`](../packages/bundle/headless), [`host-apiproxy`](../packages/host/apiproxy) | - | Layers the default ModelSelection through settings so direct and Host-backed Agent front doors share one state owner. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.e2b` | `core` | [`e2b`](../packages/e2b/e2b) | - | [`fs-e2b`](../packages/e2b/fs-e2b), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | - | Owns one shared E2B SDK handle, remote working directory, and final sandbox disposition so both fundamental E2B providers inhabit the same Linux runtime. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0a5d0f7519..6219a0080f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -14,7 +14,7 @@ A `Requires:` line lists the service keys the plugin `inject`s: its `cordis.yml` Requires: `agents` ```ts config-catalog -/** Plugin config: the provider/model target used for each ACP-created agent. */ +/** Plugin config: the provider/model selection used for each ACP-created agent. */ export interface AcpConfig { /** Provider route for created agents. */ provider?: string @@ -80,6 +80,20 @@ Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · Source: [`packages/examples/acp-demo/src/index.ts:39`](../packages/examples/acp-demo/src/index.ts) +## `@deepseek-ai/dsh-agent-default-model` + +```ts config-catalog +/** Composition entry for the default model selection. */ +export interface Config { + /** Registered provider route. */ + provider: string + /** Provider-owned model id. */ + model: string +} +``` + +Source: [`packages/core/agent-default-model/src/index.ts:41`](../packages/core/agent-default-model/src/index.ts) + ## `@deepseek-ai/dsh-agent-loop` Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` @@ -475,17 +489,17 @@ Source: [`packages/goal/goal/src/index.ts:116`](../packages/goal/goal/src/index. ## `@deepseek-ai/dsh-headless` -Requires: `apiProxy` · `httpServer` +Requires: `agentDefaultModel` · `agents` · `sessions` ```ts config-catalog /** Plugin config: the task, patched in by the launcher. */ export interface Config { - /** The prompt text for the single turn. */ + /** The prompt text for the single run. */ task: string } ``` -Source: [`packages/bundle/headless/src/index.ts:32`](../packages/bundle/headless/src/index.ts) +Source: [`packages/bundle/headless/src/index.ts:29`](../packages/bundle/headless/src/index.ts) ## `@deepseek-ai/dsh-hooks-claude` @@ -550,32 +564,17 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-c ## `@deepseek-ai/dsh-host-apiproxy` -Requires: `agents` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace` +Requires: `agentDefaultModel` · `agents` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace` ```ts config-catalog -/** - * Gateway plugin config: host-level agent routing and Workspace creation root. - * - * `reasoningEffort` is deliberately absent, so the section carries one field - * the composition cannot. The seam resolves a section by MERGING the user - * layer over the composition entry per field, and an absent key cannot - * override a present one — so a composition-set effort would survive every - * later switch to a model that has none, and strand it for the next session - * to fail on. Effort is a per-model fact anyway: a deployment default belongs - * on the adapter profile (`llm-pi-ai`'s `reasoning`, `llm-deepseek`'s own), - * which resolves per model rather than per gateway. - */ +/** Gateway plugin config: the Host-only Workspace creation root. */ export interface Config { - /** Default provider route for created agents. */ - provider: string - /** Default model id. */ - model: string /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ workspaceRoot?: string } ``` -Source: [`packages/host/apiproxy/src/index.ts:67`](../packages/host/apiproxy/src/index.ts) +Source: [`packages/host/apiproxy/src/index.ts:38`](../packages/host/apiproxy/src/index.ts) ## `@deepseek-ai/dsh-host-directory-picker-browse` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f5ea3eba36..628750959e 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -18,7 +18,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:243`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:216`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:177`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`headless`](../packages/bundle/headless), `server` | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:177`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), `server` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:277`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:172`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 846ade98b9..a089e2a1f0 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -137,6 +137,7 @@ | message | 消息 | | | | | mod | 模组 | | | | | model provider | 模型提供方 | | | | +| model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 | | module | 模块 | | | | | non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 | | npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 | diff --git a/docs/module-graph.md b/docs/module-graph.md index 6f47c36e97..e7d6b47e70 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -25,6 +25,7 @@ flowchart TD end subgraph group_core["packages/core"] pkg_agent["agent"] + pkg_agent_default_model["agent-default-model"] pkg_agent_loop["agent-loop"] pkg_scope["scope"] pkg_session["session"] @@ -510,6 +511,10 @@ flowchart TD pkg_token_meter --> pkg_llm pkg_token_meter --> pkg_session pkg_token_meter --> pkg_session_projection + pkg_agent_default_model --> pkg_agent + pkg_agent_default_model --> pkg_invariants + pkg_agent_default_model --> pkg_llm + pkg_agent_default_model --> pkg_settings pkg_goal --> pkg_agent pkg_goal --> pkg_brand pkg_goal --> pkg_invariants @@ -553,11 +558,6 @@ flowchart TD pkg_loader_smoke --> pkg_invariants pkg_loader_smoke --> pkg_llm pkg_loader_smoke --> pkg_session - pkg_headless --> pkg_agent - pkg_headless --> pkg_host_apiproxy - pkg_headless --> pkg_host_webserver - pkg_headless --> pkg_invariants - pkg_headless --> pkg_session pkg_client_ui_layout --> pkg_client_runtime pkg_client_ui_layout --> pkg_client_ui_slots pkg_client_ui_layout --> pkg_client_ui_theme @@ -698,6 +698,11 @@ flowchart TD pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence pkg_api_remotes --> pkg_typert_registry + pkg_headless --> pkg_agent + pkg_headless --> pkg_agent_default_model + pkg_headless --> pkg_invariants + pkg_headless --> pkg_llm + pkg_headless --> pkg_session pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives @@ -1252,6 +1257,7 @@ flowchart TD | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`type-meta`](../packages/typert/type-meta) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1263,7 +1269,6 @@ flowchart TD | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | @@ -1295,6 +1300,7 @@ flowchart TD | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | +| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 42f00a896e..6a117435f5 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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/core.md -core.md: cc9cc30677b0b657492d8a5118489e8b801f3fd3 -core.zh.md: 96854801d684509d4d77b6408a4bb45b93438cdb +core.md: b09e5b4f0a076a7f95a751a6204836a81a4c31e9 +core.zh.md: 01ac656fb657296fb26217108b8a4a15a5a88280 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index cc9cc30677..b09e5b4f0a 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -314,6 +314,30 @@ The two core IDs are `CallId` (correlates a tool call with its result; dsh-llm) Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` surface lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.agentDefaultModel` — `AgentDefaultModelService` + +Owns the default model selection independently of any Host or transport. The composition entry remains usable without a settings provider; when one is mounted, its user layer is read live. + +```ts cordis-catalog +/** + * Read the current default model selection. + * @returns a detached provider, model, and optional reasoning selection. + */ +currentSelection(): ModelSelection + +/** + * Save the complete default model selection. A deployment without a settings + * provider keeps its composition entry. + * @param next - resolved selection accepted by a front door. + * @returns fulfillment after the optional settings write settles. + */ +async saveSelection(next: ModelSelection): Promise +``` + +Source: [`packages/core/agent-default-model/src/index.ts:64`](../../packages/core/agent-default-model/src/index.ts) + ### `ctx.agentLoop` — `AgentLoop` diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 96854801d6..01ac656fb6 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -322,6 +322,30 @@ type Branded = string & { readonly [BRAND]: B } Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` surface lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.agentDefaultModel` — `AgentDefaultModelService` + +Owns the default model selection independently of any Host or transport. The composition entry remains usable without a settings provider; when one is mounted, its user layer is read live. + +```ts cordis-catalog +/** + * Read the current default model selection. + * @returns a detached provider, model, and optional reasoning selection. + */ +currentSelection(): ModelSelection + +/** + * Save the complete default model selection. A deployment without a settings + * provider keeps its composition entry. + * @param next - resolved selection accepted by a front door. + * @returns fulfillment after the optional settings write settles. + */ +async saveSelection(next: ModelSelection): Promise +``` + +Source: [`packages/core/agent-default-model/src/index.ts:64`](../../packages/core/agent-default-model/src/index.ts) + ### `ctx.agentLoop` — `AgentLoop` diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 38df7fb986..cf4fba292f 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.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/providers.md -providers.md: 8b52044e64411e3081d56c1ee1849d0b24cd1cda -providers.zh.md: f4a42a4093d253b4b230e4a838ba275a0ce58ac9 +providers.md: ad0bd3696dbbdc4a89334932196d72a0afd3d209 +providers.zh.md: 719039cf7af8b59663642ebad81dbf315a023154 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 8b52044e64..ad0bd3696d 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -124,16 +124,16 @@ Under `dsh`, references resolve from the inherited environment, the Models page' A configured route appears in the web model picker and can be switched at any time, which is how most people use it. -Switching there also sets the default: the model you pick becomes the one the next new session starts on, recorded in `settings.yaml` under `api-gateway`. There is no separate gesture. +Switching there also sets the default: the model you pick becomes the one the next new session starts on, recorded in `settings.yaml` under `agent-default-model`. There is no separate gesture. ```yaml -api-gateway: +agent-default-model: provider: acme-gateway model: acme-large reasoningEffort: high # optional ``` -A session that has already run a turn is never retargeted by it — that session derives its route from its own log, so changing the default only reaches sessions that have not started. The shipped fallback under this section is the `api-gateway` composition entry (`deepseek-official` / `deepseek-v4-flash`), which a self-assembled `cordis.yml` may override; a composition you assemble yourself — headless, for instance — sets `agent-loop`'s `agents` instead. +After a session has run a turn, its own log remains authoritative for its model selection; the default applies only to sessions without a recorded request. The shipped fallback under this section is the base bundle's `agent-default-model` composition entry (`deepseek-official` / `deepseek-v4-flash`). A self-assembled `cordis.yml` mounts and configures `@deepseek-ai/dsh-agent-default-model`; both direct front doors and Host-backed front doors read that same service. If the provider a saved default names is later removed, the composer says **Select model** and refuses input until you pick one, rather than sending to a route nothing serves. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index f4a42a4093..719039cf7a 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -124,16 +124,16 @@ settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上 配好的路由会出现在 Web 的模型选择器里,随时可切,这也是最常用的方式。 -在那里切换同时也就选定了默认值:你选的模型会成为下一个新会话的起点,记录在 `settings.yaml` 的 `api-gateway` 段里。没有另一个单独的手势。 +在那里切换同时也就选定了默认值:你选的模型会成为下一个新会话的起点,记录在 `settings.yaml` 的 `agent-default-model` 段里。没有另一个单独的手势。 ```yaml -api-gateway: +agent-default-model: provider: acme-gateway model: acme-large reasoningEffort: high # optional ``` -已经跑过一轮的会话不会被它重定向——那个会话从自己的日志推导路由,因此改默认值只影响还没开始的会话。这个段落之下的出厂兜底是 `api-gateway` 组合条目(`deepseek-official` / `deepseek-v4-flash`),自行组装的 `cordis.yml` 可以覆盖它;自行组装的组合(例如 headless)改的则是 `agent-loop` 的 `agents`。 +会话跑过一轮后,其自身日志仍是模型选择的权威;默认值只适用于尚无请求记录的会话。这个段落之下的出厂兜底是 base 组合包的 `agent-default-model` 组合条目(`deepseek-official` / `deepseek-v4-flash`)。自行组装的 `cordis.yml` 会挂载并配置 `@deepseek-ai/dsh-agent-default-model`;直接前门与 Host 支撑的前门都读取同一服务。 如果某个已存默认值指向的提供方后来被删掉了,输入框会显示**选择模型**并拒绝输入,而不是把消息发给一个没人服务的路由。 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index dfdba88bec..9bb9b99bd8 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: 9d1a04f0a094919bb008a7e94564a6612e10732d -quickstart.zh.md: 7182f63586f4f08524c643a7c6c1ad599b2de098 +quickstart.md: ce196641f205324334533c025b4ac1dc791f857d +quickstart.zh.md: ce12f43fa660f0a99be73749e0345570575fbbee diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 9d1a04f0a0..ce196641f2 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -39,7 +39,7 @@ Run a non-interactive task and print its final answer: pnpm run dsh run "summarize the architecture of this workspace" ``` -`dsh run` creates and persists a fresh session, prints the final assistant answer, and exits. While it runs, stderr prints the local browser URL where the session can be observed. +`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. ## Step 3: use the Web UI @@ -53,7 +53,7 @@ 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), [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml), and [`dsh-headless`](../../../packages/bundle/headless/cordis.patch.yml) compose over an empty root. `dsh web` uses the first two layers without the one-shot runner. Both select the DeepSeek model and capability plugins appropriate to their entry mode. +`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`. ## Next steps diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index 7182f63586..ce12f43fa6 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -39,7 +39,7 @@ DEEPSEEK_API_KEY=sk-your-key-here pnpm run dsh run "summarize the architecture of this workspace" ``` -`dsh run` 创建并持久化一个新会话,打印最终 assistant 回答,然后退出。运行期间,stderr 会打印可用于观察该会话的本地浏览器 URL。 +`dsh run` 创建并持久化一个新会话,打印最终 assistant 回答,然后退出。它不会启动 Web 服务器或监听端口;成功运行时 stderr 为空。 ## 第三步:使用 Web UI @@ -53,7 +53,7 @@ pnpm run dsh web ## 回头看 -`dsh run` 启动 `headless` profile:[`dsh-base`](../../../packages/bundle/base/cordis.patch.yml)、[`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml) 和 [`dsh-headless`](../../../packages/bundle/headless/cordis.patch.yml) 在空根之上组合。`dsh web` 使用前两层,不包含一次性 runner。二者都会根据各自入口模式选择 DeepSeek 模型和能力插件。 +`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 模型路由。 ## 下一步 diff --git a/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml b/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml index 7d411f7a0f..4199bfb9ca 100644 --- a/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml +++ b/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml @@ -1,4 +1,4 @@ -- id: api-gateway +- id: agent-default-model config: provider: cli-mock model: cli-mock diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 87261439da..593b05b4e8 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -234,7 +234,7 @@ describe('headless stream-json snapshots', () => { }) expect(result.stdout).toBe('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP\n') - expect(result.stderr).toMatch(/^dsh: observing at http:\/\/127\.0\.0\.1:\d+\n$/u) + expect(result.stderr).toBe('') }, 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/dsh-run/session.expected.jsonl index 1312eb6511..260ae241b0 100644 --- a/examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl @@ -2,11 +2,11 @@ {"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","rpcId":"{{sessionId}}"},"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 dsh run 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","rpcId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"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":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":"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"}} diff --git a/knip.json b/knip.json index 9ca15de212..812f7446bc 100644 --- a/knip.json +++ b/knip.json @@ -751,6 +751,11 @@ "@cordisjs/.+" ] }, + "packages/bundle/headless": { + "ignoreDependencies": [ + "@deepseek-ai/dsh-code-runtime-worker" + ] + }, "packages/bundle/web-app": { "ignoreDependencies": [ "@deepseek-ai/.+" diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index 50549d7ab0..a1d37e2aa9 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -66,7 +66,7 @@ function internalError(detail: string): RequestError { return RequestError.internalError(undefined, detail) } -/** Plugin config: the provider/model target used for each ACP-created agent. */ +/** Plugin config: the provider/model selection used for each ACP-created agent. */ export interface AcpConfig { /** Provider route for created agents. */ provider?: string @@ -100,7 +100,7 @@ interface SessionRecord { /** * Mount the automation-only ACP server. * @param ctx - Cordis context carrying the agent factory and session events. - * @param config - Initial provider/model target and optional test transport. + * @param config - Initial provider/model selection and optional test transport. */ export function apply(ctx: Context, config: AcpConfig): void { // ACP handlers execute outside this plugin's injection scope, so capture the diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index fd61b9bec2..e64706453e 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: c256b89288e3e384c1dd3e64629a06d7cfef31f6 -README.zh.md: 454d504be72c2120ec4aeaa1b22545e7d6ba2fee +README.md: c060e99acb8cc40b8de92806727eea9e3723b7d2 +README.zh.md: f28b89e63e4bd6f07f2f7a55dfba5c2f78862feb diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index c256b89288..c060e99acb 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -33,7 +33,7 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](.. ## Profiles -A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list — and the user's own `cordis.patch.yml`. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps can never drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory — one symlink per package the installation's app and bundles depend on — so bare plugin names in any profile resolve through Node's ordinary parent-walk without pnpm ever managing in-box packages. `PROFILE_TEMPLATES` (`web`, `headless`) auto-initialize on first use; other names fail loud until `initProfile` creates them (the `dsh plugin` path). +A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list — and the user's own `cordis.patch.yml`. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory — one symlink per package the installation's app and bundles depend on — so bare plugin names in any profile resolve through Node's ordinary parent-walk without pnpm managing in-box packages. `PROFILE_TEMPLATES` (`web`, `headless`) auto-initialize on first use; other names fail loud until `initProfile` creates them (the `dsh plugin` path). `loadProfile` normalizes an exact installation-owned bundle tuple to its shipped template while preserving every other manifest field; any extra, missing, or reordered entry makes the list user-owned and leaves it unchanged. User-level machine-local preferences also live in the Harness home: diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 454d504be7..f28b89e63e 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -35,7 +35,7 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 ## Profile -profile 是位于 `$DSH_HOME/profiles/` 下的目录(Harness home 由 [`resolveDshHome`](../../util/paths/README.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和用户自己的 `cordis.patch.yml`。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则大声失败。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录(安装目录的应用与各组合包依赖的每个包对应一个符号链接),使任意 profile 中的裸插件名都能经 Node 常规的逐级向上查找解析,而 pnpm 从不管理随安装内置的包。`PROFILE_TEMPLATES`(`web`、`headless`)在首次使用时自动初始化;其他名称在 `initProfile` 创建之前都会大声失败(即 `dsh plugin` 路径)。 +profile 是位于 `$DSH_HOME/profiles/` 下的目录(Harness home 由 [`resolveDshHome`](../../util/paths/README.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和用户自己的 `cordis.patch.yml`。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则大声失败。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录(安装目录的应用与各组合包依赖的每个包对应一个符号链接),使任意 profile 中的裸插件名都能经 Node 常规的逐级向上查找解析,且无需 pnpm 管理随安装内置的包。`PROFILE_TEMPLATES`(`web`、`headless`)在首次使用时自动初始化;其他名称在 `initProfile` 创建之前都会大声失败(即 `dsh plugin` 路径)。`loadProfile` 会将与安装自有组合包元组完全一致的列表规范化为随发行版交付的模板,同时保留 manifest 中其他所有字段;一旦条目有任何额外、缺失或重排,该列表就归用户所有并保持不变。 用户级的机器本地偏好同样位于 Harness home 中: diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index c353f18bef..1e71f3e8a7 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -114,6 +114,11 @@ export function resolveProfileDir(name: string, home: string = resolveDshHome()) /** The shipped profile templates auto-initialized on first use, by name. */ export const PROFILE_TEMPLATES: Record = { web: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'], + headless: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'], +} + +/** Installation-owned bundle tuples normalized to the shipped template. */ +const INSTALLATION_OWNED_PROFILE_TUPLES: Record = { headless: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless'], } @@ -279,6 +284,32 @@ export function writeProfileManifest(dir: string, manifest: ProfileManifest): vo writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest, undefined, 2) + '\n') } +/** Return whether two bundle lists have the same values in the same order. */ +function sameBundles(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} + +/** + * Normalize an exact installation-owned bundle tuple to its shipped template + * while preserving every other manifest field. Any other list is user-owned. + */ +function normalizeShippedProfile(name: string, dir: string, manifest: ProfileManifest): ProfileManifest { + const installationOwned = INSTALLATION_OWNED_PROFILE_TUPLES[name] + const current = PROFILE_TEMPLATES[name] + const bundles = manifest.dsh?.profile?.bundles + if (installationOwned === undefined || current === undefined || bundles === undefined + || !sameBundles(bundles, installationOwned)) return manifest + const normalized: ProfileManifest = { + ...manifest, + dsh: { + ...manifest.dsh, + profile: { ...manifest.dsh?.profile, bundles: [...current] }, + }, + } + writeProfileManifest(dir, normalized) + return normalized +} + /** * Resolve a package's root directory from one anchor without depending on the * package exporting `./package.json` (`require.resolve` would need that): @@ -350,7 +381,7 @@ export function loadProfile( } initProfile(dir, template) } - const manifest = readProfileManifest(binName, dir) + const manifest = normalizeShippedProfile(name, dir, readProfileManifest(binName, dir)) // A hand-written profile manifest may omit the dsh section entirely. const bundles = manifest.dsh?.profile?.bundles ?? [] const layers = bundles.map((packageName): ProfileLayer => { diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index f0bd6f5da7..bd0294475d 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -161,6 +161,33 @@ describe('loadProfile', () => { .toEqual([...PROFILE_TEMPLATES.web ?? []]) }) + it('normalizes only the exact installation-owned headless bundle tuple', () => { + const anchor = stageInstallation({ + '@deepseek-ai/dsh-base': { patch: '[]\n' }, + '@deepseek-ai/dsh-web-app': { patch: '[]\n' }, + '@deepseek-ai/dsh-headless': { patch: '[]\n' }, + 'custom-bundle': { patch: '[]\n' }, + }) + const home = tmp() + const stock = resolveProfileDir('headless', home) + initProfile(stock, [ + '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless', + ]) + loadProfile('t', 'headless', anchor, home) + expect(readProfileManifest('t', stock).dsh?.profile?.bundles) + .toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless']) + + const customHome = tmp() + const custom = resolveProfileDir('headless', customHome) + initProfile(custom, [ + '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless', 'custom-bundle', + ]) + loadProfile('t', 'headless', anchor, customHome) + expect(readProfileManifest('t', custom).dsh?.profile?.bundles).toEqual([ + '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless', 'custom-bundle', + ]) + }) + it('fails loud when a listed bundle declares no dsh.bundle', () => { const anchor = stageInstallation({ 'not-a-bundle': {} }) const home = tmp() diff --git a/packages/bundle/README.i18n.yaml b/packages/bundle/README.i18n.yaml index 860390d722..eafbe0b0ab 100644 --- a/packages/bundle/README.i18n.yaml +++ b/packages/bundle/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/README.md -README.md: af06fe921a66378894a4ab0174ef403499f5f68c -README.zh.md: b9656649782a638a3b39b4e1e6d330476b0732ba +README.md: 696aa9ef7bbed23774f2b9ab2648ca83edcf0978 +README.zh.md: 2bb22c7949d759f404288548ea0eccfa0aac866b diff --git a/packages/bundle/README.md b/packages/bundle/README.md index af06fe921a..696aa9ef7b 100644 --- a/packages/bundle/README.md +++ b/packages/bundle/README.md @@ -8,6 +8,6 @@ Profile bundles: npm packages whose manifest declares `"dsh": { "bundle": { "pat |---|---|---| | [`base/`](base/README.md) | The shared dsh core every profile applies first | — (patch only) | | [`web-app/`](web-app/README.md) | Browser surface: web patch layer + runtime glue plugin | mounts rows | -| [`headless/`](headless/README.md) | One-shot task mode over base + web-app | mounts `headless-runner` | +| [`headless/`](headless/README.md) | Direct one-shot task mode over base, with no Host or Web layer | mounts `headless-runner` | In-box bundles resolve from the dsh installation; out-of-tree bundles install into a profile through `dsh plugin --profile add `. diff --git a/packages/bundle/README.zh.md b/packages/bundle/README.zh.md index b965664978..2bb22c7949 100644 --- a/packages/bundle/README.zh.md +++ b/packages/bundle/README.zh.md @@ -8,6 +8,6 @@ Profile 组合包:在 manifest(元数据清单)中声明 `"dsh": { "bundle |---|---|---| | [`base/`](base/README.md) | 每个 profile 最先应用的共享 dsh 核心 | —(仅 patch) | | [`web-app/`](web-app/README.md) | 浏览器表层:web patch 层 + 运行时粘合插件 | 挂载多条配置行 | -| [`headless/`](headless/README.md) | 叠加在 base + web-app 之上的一次性任务模式 | 挂载 `headless-runner` | +| [`headless/`](headless/README.md) | 直接运行在 base 之上的一次性任务模式,不含 Host 或 Web 层 | 挂载 `headless-runner` | 内置组合包从 dsh 安装目录解析;树外(out-of-tree)组合包通过 `dsh plugin --profile add ` 安装进 profile。 diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 2ae7df0bdc..973959478e 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: 301d397d4c87687b382665cf63af47ab5e3f85be -README.zh.md: f007bc817b6cbad84725fe8abe72549cf67d8cd7 +README.md: fb003908a262dc21edd3c9d49c972e487534f367 +README.zh.md: 13e64db6d34374fac63bf9bfd60544fc46b86f35 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 301d397d4c..fb003908a2 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, tools, persistence, policy, settings/credentials, repository Plugins, 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, repository Plugins, 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 row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index f007bc817b..13e64db6d3 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 根之上插入全部基础插件行——模型适配器、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 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、repository 插件、遥测——作为每个 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,绝不通过代码。 行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。 diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 799affc16b..c512127199 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -65,6 +65,14 @@ - id: agent name: '@deepseek-ai/dsh-agent' + # The transport-independent default for Agents created by front doors. + # Settings may supply a saved selection; consumers read it at creation time. + - id: agent-default-model + name: '@deepseek-ai/dsh-agent-default-model' + config: + provider: deepseek-official + model: deepseek-v4-flash + - id: tasks name: '@deepseek-ai/dsh-tasks-local' diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 507952b43a..c2cd151b3e 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -35,6 +35,7 @@ "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-timer": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-default-model": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-bash-sandbox": "workspace:^", diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index f1a9d53be8..ea768061df 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: 661b377817482d22f58f22b573075722646729a2 -README.zh.md: a6b91a8e60fdcc06ba23e07dcb2f4208ea1020f7 +README.md: 6faf1dc732345a86ec1d7b60c4f9a606e0491512 +README.zh.md: a189f77f57cf0f641abe7c11288dbc1cdf05355a diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 661b377817..6faf1dc732 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -2,11 +2,13 @@ English | [中文](README.zh.md) -The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md): it moves the webserver to an OS-assigned port (parallel runs never collide), silences the URL line, and inserts this package's `headless-runner` plugin (config `{task}`). The runner drives one task turn through the in-process API carrier (`InProcessApiClient` over `toFetchHandler(ctx.apiProxy)`, so the full wire chain — serialization, zod, SSE framing — really runs), waits at idle until that mux has consumed the session's final event sequence, aggregates the turn's final assistant text, writes it to stdout, and requests exit (completed → 0, else 1) through the launcher-provided `ctx.headlessIo` seam. The Web composition stays mounted, so the running session is observable in a browser at the stderr-announced URL. The launcher patches the task text in (`dsh run "task"`), and fails loud when the selected profile lacks this row. +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. + +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` seam (final `turn/end` completed → 0, otherwise 1). A successful run writes nothing to stderr and opens no listening port. The launcher patches the task text in (`dsh run "task"`) and fails loud when the selected profile lacks this row. ## Model Experience -None, as the runner submits the task as an ordinary user message over the shared composition; prompts and tools belong to the base/web bundles. +None, as the runner submits the task as an ordinary user message; prompts and tools belong to the base and headless bundle rows. #### KV Cache effect @@ -14,5 +16,5 @@ None; the runner adds nothing to the request prefix. ## Known Limitations and Deferred Work -- **One turn only** — the runner anchors on the first message-triggered turn and exits at its end; queued follow-ups and multi-turn tasks are out of scope. +- **One submitted task only** — the runner has no interactive follow-up surface; it waits through any work the Agent completes before returning to idle and prints the last non-empty assistant message in that interval. - **`ctx.headlessIo` is launcher-owned** — booting the headless profile outside the `dsh` launcher fails loud at activation until the host provides the seam. diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index a6b91a8e60..a189f77f57 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -2,11 +2,13 @@ [English](README.md) | 中文 -dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md) 之上:把 webserver 移到 OS 分配的端口(并行运行绝不冲突),关闭 URL 行输出,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。runner 通过进程内 API 载体(架在 `toFetchHandler(ctx.apiProxy)` 之上的 `InProcessApiClient`,因此序列化、zod、SSE(Server-Sent Events)帧封装这整条 wire 链路都会真实运行)驱动一个任务轮次,在 idle 时等待该 mux 消费完会话的最终事件序号,再聚合该轮次最终的 assistant 文本,写到 stdout,并经启动器提供的 `ctx.headlessIo` seam 请求退出(完成 → 0,否则 1)。Web 组合保持挂载,因此运行中的会话可在浏览器中通过 stderr 公告的 URL 观察。启动器把任务文本 patch 进来(`dsh run "task"`);若所选 profile 缺少该行,则显式报错。 +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 或浏览器插件。 + +Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.headlessIo` seam 请求退出(最终 `turn/end` 完成 → 0,否则为 1)。成功运行时不会向 stderr 写入任何内容,也不会打开监听端口。启动器把任务文本 patch 进来(`dsh run "task"`);若所选 profile 缺少该行,则显式报错。 ## 模型体验 -无。runner 把任务作为普通用户消息经共享组合提交;提示词与工具归 base/web 组合包所有。 +无影响,因为 runner 把任务作为普通用户消息提交;提示词与工具归 base 和 headless 组合包中的相应行所有。 #### KV Cache 影响 @@ -14,5 +16,5 @@ dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 ## 已知限制与延期工作 -- **只运行一个轮次**:runner 锚定第一个由消息触发的轮次,并在其结束时退出;排队的后续消息与多轮任务不在范围内。 +- **只提交一个任务**:runner 没有用于交互式后续输入的 surface;它会等待 Agent 在返回 idle 前完成的所有工作,并打印该区间内最后一条非空 assistant 消息。 - **`ctx.headlessIo` 由启动器持有**:在 `dsh` 启动器之外启动 headless profile 会在激活时大声失败,直到宿主提供该 seam。 diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index 5801a20863..8b147714be 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -1,21 +1,26 @@ -# The dsh-headless bundle patch: one-shot task mode over dsh-base + -# dsh-web-app. The web composition stays mounted (the session is observable -# in a browser while it runs); this layer silences the URL line and the -# GUI-orientation surface context (this user is not in the GUI), moves the -# webserver to an OS-assigned port so parallel headless runs never collide, -# and mounts the one-shot runner. The launcher patches the runner's `task`. +# 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. -- id: webserver +- id: system-prompt config: - host: 127.0.0.1 - port: 0 + persona: >- + You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. -- id: web-runtime +# One-shot runs never watch or reload their composition. +- id: hmr + disabled: true + +- id: tools config: - mode: production - printUrl: false - surfaceContext: false + # Keep the same temporary process-wide Code Mode opt-in as the Web surface. + mode: !!js process.env.DSH_TOOLS_MODE - insert: + # Code Mode is a core execution capability, not a Web component. + - id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' + - id: headless-runner name: '@deepseek-ai/dsh-headless' diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 2b28423168..bc461e4c72 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-headless", - "description": "The dsh one-shot bundle: a patch layer over dsh-base + dsh-web-app plus the runner plugin driving one task turn through the in-process API carrier", + "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer", "version": "0.0.1", "private": true, "type": "module", @@ -32,22 +32,23 @@ } }, "dependencies": { + "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", "schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-host-apiproxy": "^0.0.1", - "@deepseek-ai/dsh-host-webserver": "^0.0.1", + "@deepseek-ai/dsh-agent-default-model": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", - "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-agent-default-model": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 8db505c3ac..670838661a 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -1,36 +1,33 @@ /** - * @deepseek-ai/dsh-headless — the one-shot headless bundle: the bundle patch - * (`cordis.patch.yml`) rides over dsh-base + dsh-web-app (the headless - * session is web-observable while it runs — same composition), and this - * runner plugin drives one task through the in-process API carrier - * (InProcessApiClient over toFetchHandler(ctx.apiProxy), so the full wire - * chain — serialization, zod, SSE framing — really runs), prints the final - * assistant text at agent quiescence, and exits (completed → 0, else 1). The - * task text arrives as launcher-patched config (`dsh run "task"`). + * @deepseek-ai/dsh-headless — one-shot direct Agent driver. The bundle patch + * rides over dsh-base without Host, HTTP, or browser plugins; this runner + * creates one Agent through the core registry, drives the task to quiescence, + * flushes its Session, prints the final assistant text, and exits. + * * @module @deepseek-ai/dsh-headless */ +import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' import z from 'schemastery' -import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' -// Empty type imports carry the httpServer and agent/status Context merges used below. -import type {} from '@deepseek-ai/dsh-host-webserver' -import type {} from '@deepseek-ai/dsh-agent' +import { installModelSelection } from '@deepseek-ai/dsh-agent' +import type { ModelSelectionRef } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-agent-default-model' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' // Empty type import carries the loader Context merge for the settlement await. import type {} from '@cordisjs/plugin-loader' -import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api' -import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import type { SessionId } from '@deepseek-ai/dsh-session' /** Stable Cordis plugin name. */ export const name = 'headless-runner' -/** Services required before the one-shot turn can start. */ -export const inject = ['apiProxy', 'httpServer'] +/** 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. */ export interface Config { - /** The prompt text for the single turn. */ + /** The prompt text for the single run. */ task: string } @@ -38,16 +35,15 @@ export const Config: z = z.object({ task: z.string().required(), }) -/** Outcome of one headless run: aggregated final text plus the last turn-end reason kind. */ -interface TurnOutcome { +/** Outcome of one owned run interval. */ +interface RunOutcome { text: string reason: string } /** - * The process-facing effects of one run, injectable for tests: output - * streams and the exit request (the launcher wires it to its bounded - * shutdown controller). + * Process-facing effects of one run, injectable for tests. The launcher owns + * bounded tree shutdown and wires `exit()` to it. */ export interface HeadlessIo { stdout: { write(chunk: string): unknown } @@ -56,127 +52,91 @@ export interface HeadlessIo { exit(code: number): void } -/** Host seam: the launcher provides the exit wiring before the tree mounts. */ declare module 'cordis' { interface Context { - /** Process-facing effects for the one-shot headless runner. */ + /** Process-facing effects provided before the headless tree mounts. */ headlessIo?: HeadlessIo } } -/** Unwrap an RpcResponse or fail loud: business errors print and exit 1. */ -async function unwrap(response: RpcResponse, io: HeadlessIo): Promise { - if (response.result.ok) return response.result.value - const { code, message } = response.result.error - io.stderr.write(`dsh: ${code}: ${message}\n`) - io.exit(1) - // Exit is asynchronous (bounded tree disposal); park this turn forever so - // no further request rides a session that is already being torn down. - return new Promise(() => {}) -} - -/** - * Consume mux frames until the agent reaches idle, per the one-shot CLI - * idle-to-idle contract: the stream opens immediately before the prompt, and - * its first observed turn/start begins the task. Text is the last committed - * assistant message of the whole interval (steering or injected work may run - * further turns before quiescence), and the outcome reason is the final - * turn/end's kind. Idleness is signalled out of band by the caller's - * `agent/status` subscription; the stream itself carries no status frame. - * @param frames - the mux stream opened before the prompt. - * @param sessionId - the headless session. - * @param idle - resolves to the final session-event sequence when the agent reaches quiescence. - * @param io - process-facing effects for stream diagnostics. - * @returns the aggregated outcome. - */ -async function consumeUntilIdle( - frames: AsyncIterable>, - sessionId: SessionId, - idle: Promise, - io: HeadlessIo, -): Promise { +/** Aggregate the last assistant text and turn outcome in one owned interval. */ +function summarize(events: readonly SessionEvent[], firstSeq: number): RunOutcome { let started = false let text = '' - let reason: string = 'error' - let observedSeq = -1 - let resolveProgress: (() => void) | undefined - const streamDone = (async () => { - try { - for await (const frame of frames) { - const payload = frame.payload - if (payload.type === 'stream/error') return - if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue - const event = payload.event - observedSeq = event.seq - resolveProgress?.() - resolveProgress = undefined - if (event.type === 'turn/start') { - started = true - continue - } - if (!started) continue - if (event.type === 'assistant/message') { - const joined = event.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('') - if (joined !== '') text = joined - } - if (event.type === 'turn/end') reason = event.data.reason.kind - } - } catch (error: unknown) { - io.stderr.write(`dsh: event stream failed: ${String(error)}\n`) + let reason = 'error' + for (const event of events) { + if (event.seq < firstSeq) continue + if (event.type === 'turn/start') { + started = true + continue } - })() - const streamEnded = streamDone.then(() => 'ended' as const) - const idleSeq = await idle - while (observedSeq < idleSeq) { - const progress = new Promise<'progress'>((resolve) => { resolveProgress = () => { resolve('progress') } }) - if (await Promise.race([progress, streamEnded]) === 'ended') break + if (!started) continue + if (event.type === 'assistant/message') { + const joined = event.data.message.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') + if (joined !== '') text = joined + } + if (event.type === 'turn/end') reason = event.data.reason.kind } return { text, reason } } +/** Report an unexpected direct-driver failure and request a failing exit. */ +function fail(io: HeadlessIo, error: unknown): void { + io.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`) + io.exit(1) +} + /** - * Run one headless task to quiescence and request exit (completed → 0, else 1). - * @param ctx - plugin context carrying apiProxy, httpServer, and the launcher's headlessIo. - * @param config - validated {@link Config}. + * Run one task through a freshly created Agent and request process exit. + * @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services. + * @param task - one-shot task text. + * @param io - process-facing effects. + */ +async function run(ctx: Context, task: string, io: HeadlessIo): Promise { + // Loader siblings mount concurrently. Await the complete application before + // creating an Agent so its scoped tools and adapters are not half-composed. + await ctx.get('loader')?.await() + const agents = ctx.get('agents') + const defaultModel = ctx.get('agentDefaultModel') + const sessions = ctx.get('sessions') + // Early process shutdown can dispose the tree while settlement is pending. + if (agents === undefined || defaultModel === undefined || sessions === undefined) return + + const selection = defaultModel.currentSelection() + const { agent } = await agents.create({ + sessionId: SessionId(`session-${randomUUID()}`), + meta: { cwd: process.cwd() }, + agentOptions: { provider: selection.provider, model: selection.model }, + setup: (agentCtx) => { + const selected: ModelSelectionRef = { current: selection, assembled: undefined } + installModelSelection(agentCtx, selected) + }, + }) + await agent.whenIdle() + const firstSeq = agent.session.seq + agent.followup(createUserMessage({ + content: [{ type: 'text', text: task }], + source: { kind: 'user' }, + })) + await agent.whenIdle() + await sessions.flush(agent.session) + const outcome = summarize(agent.session.events, firstSeq) + io.stdout.write(outcome.text + '\n') + io.exit(outcome.reason === 'completed' ? 0 : 1) +} + +/** + * Mount the one-shot direct driver. + * @param ctx - plugin context carrying core services and the launcher-owned IO seam. + * @param config - validated task config. */ export function apply(ctx: Context, config: Config): void { const io = ctx.headlessIo if (io === undefined) { throw new Error('headless-runner: the launcher must provide ctx.headlessIo before the tree mounts') } - // Fire-and-forget by design: the run outlives plugin activation, and every - // failure path inside ends in io.exit, not a rejection. - void (async () => { - // The Loader mounts sibling rows concurrently and this plugin's inject - // gate covers only apiProxy/httpServer; prompting before the agent loop, - // adapters, and tools settle would fail the turn on a half-mounted tree. - // The old launcher ran strictly after settled boot — preserve that. - // A tree disposed mid-settlement (early SIGTERM) has nothing to run. - await ctx.get('loader')?.await() - if (ctx.get('httpServer') === undefined) return - // The headless session is web-observable while it runs (same composition). - io.stderr.write(`dsh: observing at http://127.0.0.1:${String(ctx.httpServer.port)}\n`) - const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy)) - const created = await unwrap(await api.sessions.create({}), io) - // Open the stream before prompting so no frame is lost. The quiescence - // anchor below is an in-process ctx subscription, so a remote-carrier - // port of this runner must replace it with a wire-visible idle signal. - const abort = new AbortController() - const frames = api.events.mux({}, abort.signal) - const idle = new Promise((resolve) => { - ctx.on('agent/status', ({ agent, status }) => { - if (agent.id === created.sessionId && status === 'idle') resolve(agent.session.seq - 1) - }) - }) - const done = consumeUntilIdle(frames, created.sessionId, idle, io) - await unwrap(await api.sessions.prompt({ - sessionId: created.sessionId, - mode: 'queue', - content: [{ type: 'text', text: config.task }], - }), io) - const outcome = await done - io.stdout.write(outcome.text + '\n') - abort.abort() - io.exit(outcome.reason === 'completed' ? 0 : 1) - })() + void run(ctx, config.task, io).catch((error: unknown) => { fail(io, error) }) } diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 9408ee62bf..294c224354 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -1,170 +1,163 @@ -/** - * One-shot runner behavior over a scripted in-process API: idle-to-idle - * aggregation (last text of the whole interval), exit-code mapping by the - * final turn-end reason, stream-error and RPC-error paths, and the - * launcher-owned `ctx.headlessIo` requirement. - */ +/** Direct one-shot Agent driving, durable aggregation, flushing, and exit mapping. */ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' +import AgentDefaultModelService from '@deepseek-ai/dsh-agent-default-model' +import { createAssistantMessage } from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import type { Session, UserMessage } from '@deepseek-ai/dsh-session' import { apply, Config, type HeadlessIo } from '../src/index.ts' -interface ScriptedEvent { type: string; seq?: number; time?: number; sessionId?: string; data: Record } - -let nextSeq = 0 -/** Stamp the envelope fields the wire schema requires. */ -function stamped(event: ScriptedEvent): ScriptedEvent { - nextSeq += 1 - return { seq: nextSeq, time: nextSeq, ...event } +interface Script { + before?(session: Session): void + afterPrompt(session: Session, message: UserMessage): Promise | void } -interface RpcShapedRequest { rpcId: string } - -interface ScriptedApiOptions { - promptFails?: boolean - framesAfterPrompt?: boolean - onPrompt?: () => void +function appendTurn( + session: Session, + turn: number, + message: UserMessage, + text: string | undefined, + completed: boolean, +): void { + session.append('turn/start', { turn }) + session.append('step/start', { turn, step: 1 }) + session.append('user/message', message, { surfaceOp: 'append' }) + if (text !== undefined) { + session.append('assistant/message', { + turn, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'text', text }], + source: { provider: 'test-provider', model: 'test-model' }, + }), + }, { surfaceOp: 'append' }) + } + session.append('step/end', { turn, step: 1 }) + session.append('turn/end', { + turn, + reason: completed + ? { kind: 'completed' } + : { kind: 'aborted', reason: { kind: 'user' } }, + }) } -/** Build a fake apiProxy (echoing rpcIds like the real gateway) whose mux stream replays `events` for the created session. */ -function scriptedApi(events: ScriptedEvent[], options: ScriptedApiOptions = {}): unknown { - let releaseFrames = (): void => {} - const framesReady = options.framesAfterPrompt === true - ? new Promise((resolve) => { releaseFrames = resolve }) - : Promise.resolve() - const prepared = events.map((event) => { - if (event.type === 'stream/error') return { streamError: true } as const - const { sessionId = 'S1', ...rest } = event - return { streamError: false, sessionId, event: stamped(rest) } as const +/** Mount the real registries around a small scripted Agent factory. */ +async function bench(script: Script): Promise<{ + ctx: Context + run(): Promise<{ code: number; out: string; err: string; order: string[] }> +}> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentDefaultModelService, { provider: 'test-provider', model: 'test-model' }) + ctx.agents.setFactory({ + async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise { + const session = ctx.sessions.create(options.sessionId, { + ...options.meta === undefined ? {} : { meta: options.meta }, + }) + let idle = Promise.resolve() + const agent = {} as Agent + const agentCtx = ownerCtx.extend({ agent }) + Object.assign(agent, { + id: session.id, + options: options.agentOptions ?? {}, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx: agentCtx, + cancel: () => {}, + runMaintenance: () => Promise.reject(new Error('not used')), + send: () => {}, + followup: (message: UserMessage) => { + agent.inbox.append('next-turn', message) + idle = Promise.resolve().then(() => script.afterPrompt(session, message)) + }, + steer: () => {}, + inject: () => {}, + whenIdle: () => idle, + } satisfies Partial) + await options.setup?.(agentCtx) + script.before?.(session) + ctx.agents.register(agent) + return { agent, dispose: () => Promise.resolve() } + }, + resume: () => Promise.reject(new Error('not used')), }) return { - sessions: { - create: (request: RpcShapedRequest) => - Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { sessionId: 'S1' } } }), - prompt: (request: RpcShapedRequest) => { - releaseFrames() - options.onPrompt?.() - return Promise.resolve(options.promptFails === true - // A code from the closed wire union: the carrier schema rejects invented codes. - ? { rpcId: request.rpcId, result: { ok: false, error: { code: 'agent-busy', message: 'agent is busy', details: { reason: 'test' } } } } - : { rpcId: request.rpcId, result: { ok: true, value: { accepted: true } } }) - }, - }, - events: { - mux: async function* () { - await framesReady - for (const item of prepared) { - if (item.streamError) { - yield { rpcId: 'e', payload: { type: 'stream/error', error: { code: 'cancelled', message: 'stream broke', details: {} } } } - continue - } - yield { rpcId: 'e', payload: { type: 'session/event', sessionId: item.sessionId, event: item.event } } + ctx, + run: async () => { + let out = '' + let err = '' + const order: string[] = [] + ctx.on('session/flush', () => { order.push('flush') }) + const exited = new Promise((resolve) => { + const io: HeadlessIo = { + stdout: { write: (chunk: string) => { out += chunk; return true } }, + stderr: { write: (chunk: string) => { err += chunk; return true } }, + exit: (code) => { order.push('exit'); resolve(code) }, } - }, + ctx.provide('headlessIo', io) + }) + apply(ctx, { task: 'do the thing' }) + return { code: await exited, out, err, order } }, } } -/** - * Mount the runner against a scripted API, emit the idle transition after the - * scripted frames drain, and wait for its exit request. - */ -async function run( - events: ScriptedEvent[], - options: { promptFails?: boolean; framesAfterPrompt?: boolean; idleInPrompt?: boolean } = {}, -): Promise<{ code: number; out: string; err: string }> { - const ctx = new Context() - let out = '' - let err = '' - const exited = new Promise((resolve) => { - const io: HeadlessIo = { - stdout: { write: (chunk: string) => { out += chunk; return true } }, - stderr: { write: (chunk: string) => { err += chunk; return true } }, - exit: resolve, - } - ctx.provide('headlessIo', io) - }) - const emitIdle = (): void => { - ctx.emit('agent/status', { agent: { id: 'S1', session: { seq: nextSeq + 1 } } as Agent, status: 'idle' }) - } - ctx.provide('apiProxy', scriptedApi(events, { - ...options.promptFails === undefined ? {} : { promptFails: options.promptFails }, - ...options.framesAfterPrompt === undefined ? {} : { framesAfterPrompt: options.framesAfterPrompt }, - ...options.idleInPrompt === true ? { onPrompt: emitIdle } : {}, - }) as never) - ctx.provide('httpServer', { port: 12345 } as never) - apply(ctx, { task: 'do the thing' }) - // Quiescence is out of band: give the scripted stream a beat to drain, then - // flip the agent idle exactly as the loop would. Foreign agents and - // non-idle transitions must not settle the run. - if (options.idleInPrompt !== true) { - await new Promise(resolve => setTimeout(resolve, 10)) - ctx.emit('agent/status', { agent: { id: 'OTHER' } as Agent, status: 'idle' }) - ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'running' }) - emitIdle() - } - const code = await exited - await ctx.fiber.dispose() - return { code, out, err } -} - -const startupTurn: ScriptedEvent = { type: 'turn/start', data: { turn: 0, trigger: { kind: 'startup' } } } -const messageTurn: ScriptedEvent = { type: 'turn/start', data: { turn: 1, trigger: { kind: 'message' } } } -const text = (turn: number, value: string): ScriptedEvent => ({ - type: 'assistant/message', - data: { turn, message: { content: [{ type: 'text', text: value }] } }, -}) -const end = (turn: number, reason: string): ScriptedEvent => ({ type: 'turn/end', data: { turn, reason: { kind: reason } } }) - describe('headless runner', () => { - it('aggregates to quiescence: last text wins across turns, final turn-end reason maps to exit 0', async () => { - const { code, out, err } = await run([ - // Frames before the first turn/start are outside the task interval. - { type: 'assistant/message', data: { turn: 0, message: { content: [{ type: 'text', text: 'pre-task noise' }] } } }, - startupTurn, - // Off-session, non-text, and text-empty frames never affect the aggregate. - { type: 'assistant/message', sessionId: 'OTHER', data: { turn: 1, message: { content: [{ type: 'text', text: 'other session' }] } } }, - { type: 'assistant/message', data: { turn: 1, message: { content: [{ type: 'tool_call', text: 'ignored' }] } } }, - text(0, 'draft'), - end(0, 'completed'), - messageTurn, - text(1, 'final answer'), - end(1, 'completed'), - ]) - expect(code).toBe(0) - expect(out).toBe('final answer\n') - expect(err).toContain('observing at http://127.0.0.1:12345') + it('aggregates the final text across the complete idle-to-idle interval and flushes before exit', async () => { + const test = await bench({ + before(session) { + const setupMessage = { + role: 'user', content: [{ type: 'text', text: 'setup' }], source: { kind: 'user' }, id: 'setup', + } as UserMessage + appendTurn(session, 0, setupMessage, 'pre-task noise', true) + }, + async afterPrompt(session, message) { + await Promise.resolve() + appendTurn(session, 1, message, '', true) + appendTurn(session, 2, message, 'final answer', true) + }, + }) + const result = await test.run() + expect(result).toEqual({ + code: 0, + out: 'final answer\n', + err: '', + order: ['flush', 'exit'], + }) + await test.ctx.fiber.dispose() }) - it('consumes through the idle sequence when queued frames arrive after the status transition', async () => { - const { code, out } = await run( - [messageTurn, text(1, 'race-free answer'), end(1, 'completed')], - { framesAfterPrompt: true, idleInPrompt: true }, - ) - expect(code).toBe(0) - expect(out).toBe('race-free answer\n') + it('waits for asynchronously appended events instead of racing Agent idleness', async () => { + const test = await bench({ + afterPrompt: async (session, message) => { + await new Promise(resolve => setTimeout(resolve, 5)) + appendTurn(session, 1, message, 'race-free answer', true) + }, + }) + expect(await test.run()).toMatchObject({ code: 0, out: 'race-free answer\n', err: '' }) + await test.ctx.fiber.dispose() }) - it('exits 1 when the final turn ends for any other reason', async () => { - const { code } = await run([messageTurn, end(1, 'aborted')]) - expect(code).toBe(1) + it('exits 1 when the final turn does not complete', async () => { + const test = await bench({ + afterPrompt(session, message) { appendTurn(session, 1, message, undefined, false) }, + }) + expect(await test.run()).toMatchObject({ code: 1, out: '\n', err: '' }) + await test.ctx.fiber.dispose() }) - it('exits 1 when no turn ever starts (idle without work)', async () => { - const { code, out } = await run([]) - expect(code).toBe(1) - expect(out).toBe('\n') + it('exits 1 when the owned interval contains no turn', async () => { + const test = await bench({ afterPrompt: () => {} }) + expect(await test.run()).toMatchObject({ code: 1, out: '\n', err: '' }) + await test.ctx.fiber.dispose() }) - it('keeps the error outcome after a stream error ends the frame consumer early', async () => { - const { code } = await run([messageTurn, { type: 'stream/error', data: {} }, end(1, 'completed')]) - // The consumer stopped at the stream error; the completed turn-end after - // it is never observed, so the reason stays 'error'. - expect(code).toBe(1) - }) - - it('prints an RPC business error and exits 1 without waiting for idle', async () => { + it('reports a direct Agent creation failure', async () => { const ctx = new Context() let err = '' const exited = new Promise((resolve) => { @@ -174,15 +167,16 @@ describe('headless runner', () => { exit: resolve, } satisfies HeadlessIo) }) - ctx.provide('apiProxy', scriptedApi([messageTurn, end(1, 'completed')], { promptFails: true }) as never) - ctx.provide('httpServer', { port: 1 } as never) + ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never) + ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never) + ctx.provide('agents', { create: () => Promise.reject(new Error('factory exploded')) } as never) apply(ctx, { task: 't' }) expect(await exited).toBe(1) - expect(err).toContain('agent-busy') + expect(err).toBe('dsh: factory exploded\n') await ctx.fiber.dispose() }) - it('reports the stream-failed diagnostic when the event channel dies, still settling at idle', async () => { + it('stringifies a non-Error Agent creation failure', async () => { const ctx = new Context() let err = '' const exited = new Promise((resolve) => { @@ -192,67 +186,52 @@ describe('headless runner', () => { exit: resolve, } satisfies HeadlessIo) }) - ctx.provide('apiProxy', { - sessions: { - create: (request: RpcShapedRequest) => - Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { sessionId: 'S1' } } }), - prompt: (request: RpcShapedRequest) => - Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { accepted: true } } }), + ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never) + ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never) + const rejected = { + then(_resolve: (value: never) => void, reject: (reason: unknown) => void): void { + reject('factory exploded') }, - events: { - // Synchronous throw: the SSE response never forms, so the client-side - // iterable rejects — the runner's own catch path, not a carrier frame. - mux: () => { throw new Error('channel exploded') }, - }, - } as never) - ctx.provide('httpServer', { port: 1 } as never) + } + ctx.provide('agents', { create: () => rejected } as never) apply(ctx, { task: 't' }) - await new Promise(resolve => setTimeout(resolve, 10)) - ctx.emit('agent/status', { agent: { id: 'S1', session: { seq: nextSeq + 1 } } as Agent, status: 'idle' }) expect(await exited).toBe(1) - expect(err).toContain('event stream failed') + expect(err).toBe('dsh: factory exploded\n') await ctx.fiber.dispose() }) - it('waits for Loader settlement and abandons the run when the tree died during it', async () => { + it('abandons a run when the tree is disposed during Loader settlement', async () => { const ctx = new Context() - let err = '' let exited = false ctx.provide('headlessIo', { stdout: { write: () => true }, - stderr: { write: (chunk: string) => { err += chunk; return true } }, + stderr: { write: () => true }, exit: () => { exited = true }, } satisfies HeadlessIo) - ctx.provide('apiProxy', scriptedApi([]) as never) - // The webserver is provided by a child fiber whose disposal (early - // SIGTERM during the boot window) removes the service; settlement - // resolves only afterwards, and the runner must abandon rather than - // crash on the torn-down port read. - const webserverFiber = ctx.plugin((childCtx: Context) => { - childCtx.provide('httpServer', { port: 1 } as never) + const services = ctx.plugin((child: Context) => { + child.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never) + child.provide('sessions', {} as never) + child.provide('agents', {} as never) }) - await webserverFiber + await services let release: () => void const settlement = new Promise((resolve) => { release = resolve }) ctx.provide('loader', { await: () => settlement } as never) apply(ctx, { task: 't' }) - await webserverFiber.dispose() + await services.dispose() release!() await new Promise(resolve => setTimeout(resolve, 10)) - expect(err).toBe('') expect(exited).toBe(false) await ctx.fiber.dispose() }) it('fails loud without the launcher-owned headlessIo seam', () => { const ctx = new Context() - ctx.provide('apiProxy', scriptedApi([]) as never) - ctx.provide('httpServer', { port: 1 } as never) expect(() => { apply(ctx, { task: 't' }) }).toThrow('must provide ctx.headlessIo') }) it('validates config: the task is required', () => { - expect(() => new Config({ } as never)).toThrow() + expect(() => new Config({} as never)).toThrow() expect(new Config({ task: 'x' })).toEqual({ task: 'x' }) }) }) diff --git a/packages/bundle/headless/tsconfig.json b/packages/bundle/headless/tsconfig.json index 7894985500..9ae3212f6b 100644 --- a/packages/bundle/headless/tsconfig.json +++ b/packages/bundle/headless/tsconfig.json @@ -17,15 +17,15 @@ { "path": "../../../vendor/schemastery" }, - { - "path": "../../host/apiproxy" - }, - { - "path": "../../host/webserver" - }, { "path": "../../core/agent" }, + { + "path": "../../core/agent-default-model" + }, + { + "path": "../../llm/llm" + }, { "path": "../../core/session" }, diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 25bf18dc45..35f9bb4286 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: dc35cb4b596b265b70cea81aa5d6784fc1eff65b -README.zh.md: bd14fe03d222f788563df0b562f86446df70b827 +README.md: d89ae4a7e28506166498caf0032f864bbb109cc5 +README.zh.md: 3c02ed81b0d34d0f8ec597e9dbea2690151ea5e3 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index dc35cb4b59..d89ae4a7e2 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -2,7 +2,7 @@ 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 own `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin owns what used to be launcher code: it resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports (workspace knowledge of this bundle, never user config), 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) layers on top, silences the URL line, and disables the surface context. +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. ## Model Experience diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index bd14fe03d2..3c02ed81b0 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -2,7 +2,7 @@ [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(这是本组合包的 workspace 知识,绝不是用户配置),在其上挂载 [`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) 再叠加一层,关闭 URL 行并禁用表层上下文。 +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 之上的同级表层,不挂载本组合包。 ## 模型体验 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 62ed35bd49..d4fb2e8511 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -72,13 +72,9 @@ name: '@deepseek-ai/dsh-host-directory-picker-auto' # The API gateway: the transport-agnostic dispatch face every client shape - # shares. provider/model are the host default routing — the profile json's - # mapping target (user config overrides these engineering defaults). + # shares. The base layer's agent-default-model service owns the default model. - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' - config: - provider: deepseek-official - model: deepseek-v4-flash # ── layer 2: transport/service ────────────────────────────────────────────── diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 6f29b2dda0..c373376710 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -12,7 +12,7 @@ export type { WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels, + ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels, GoalsApi, GoalRef, SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView, CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 2a081284b2..24f5668d42 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -30,7 +30,7 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface' import type { ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, - ModelProviderGroup, ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary, + ModelProviderGroup, ModelSelection, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary, ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView, } from './api.ts' import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -1347,7 +1347,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { { sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, blank: false, cwd: '/tmp/fixture' }, ] const logs = new Map([[sid('fx-alpha'), buildAlphaLog()]]) - const modelTargets = new Map(sessions.map(session => [ + const modelSelections = new Map(sessions.map(session => [ session.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, ])) @@ -1989,7 +1989,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd, } sessions.push(created) - modelTargets.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + modelSelections.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) attachedSessions += 1 const emitSession = (): void => { // Mirrors the host: the frame fires at creation, so blank is constantly true. @@ -2098,7 +2098,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return ok(request, { ...page, ...projections === undefined ? {} : { projections } }) }, models: request => ok(request, { - current: modelTargets.get(request.payload.sessionId) + current: modelSelections.get(request.payload.sessionId) ?? { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, // The fixture's routes all serve; a surface exercising the blocked // posture drives it through its own stub. @@ -2107,14 +2107,14 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { failures: [], }), selectModel: (request) => { - const selected: ModelTarget = { + const selected: ModelSelection = { provider: request.payload.provider, model: request.payload.model, ...request.payload.reasoningEffort === undefined ? {} : { reasoningEffort: request.payload.reasoningEffort }, } - modelTargets.set(request.payload.sessionId, selected) + modelSelections.set(request.payload.sessionId, selected) return ok(request, { selected }) }, prompt: (request) => { @@ -2153,11 +2153,11 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // Capacity parallel of the host token-meter's request/context record: // log-only, appended inside the open turn, and deduplicated against the // route already recorded (the fixture never varies contextWindow). - const target = modelTargets.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' } - if (lastRequestContext(logOf(id))?.model !== target.model) { + const selection = modelSelections.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' } + if (lastRequestContext(logOf(id))?.model !== selection.model) { append(id, { type: 'request/context', - data: { provider: target.provider, model: target.model, contextWindow: 128_000 }, + data: { provider: selection.provider, model: selection.model, contextWindow: 128_000 }, }) } startReply( @@ -2167,9 +2167,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { ? MARKDOWN_FIXTURE : userText === 'report model' ? (() => { - const target = modelTargets.get(id) - return `当前模型:${target?.provider ?? 'unknown'}/${target?.model ?? 'unknown'}` - + (target?.reasoningEffort === undefined ? '' : ` · 推理等级:${target.reasoningEffort}`) + const selection = modelSelections.get(id) + return `当前模型:${selection?.provider ?? 'unknown'}/${selection?.model ?? 'unknown'}` + + (selection?.reasoningEffort === undefined ? '' : ` · 推理等级:${selection.reasoningEffort}`) })() : `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`, ) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index c2a6668d46..e14a0764a8 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -20,7 +20,7 @@ export type { ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - MessageId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels, + MessageId, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels, SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index ef5071316e..c61b97833b 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -3,7 +3,7 @@ // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { - CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame, + CommandDescriptor, HostFrame, IApiClient, ModelSelection, MuxFrame, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, } from '../src/client/api.ts' import { RpcId } from '../src/client/api.ts' @@ -50,11 +50,11 @@ export class FakeApiClient implements IApiClient { onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) onFork: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) - => Promise> = + => Promise> = () => Promise.resolve(ok({ events: [], hasMore: false, - modelTarget: { provider: 'deepseek-official', model: 'deepseek-chat' }, + modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' }, })) onModels: (payload: unknown) => Promise> = () => Promise.resolve(ok({ @@ -63,8 +63,8 @@ export class FakeApiClient implements IApiClient { groups: [], failures: [], })) - onSelectModel: (payload: ModelTarget & { sessionId: SessionId }) - => Promise> = + onSelectModel: (payload: ModelSelection & { sessionId: SessionId }) + => Promise> = payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) @@ -105,7 +105,7 @@ export class FakeApiClient implements IApiClient { history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => this.record('session.history', payload, this.onHistory(payload)), models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)), - selectModel: (payload: ModelTarget & { sessionId: SessionId }) => + selectModel: (payload: ModelSelection & { sessionId: SessionId }) => this.record('session.selectModel', payload, this.onSelectModel(payload)), rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)), fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)), diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index f608190936..53b68ca786 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -168,7 +168,7 @@ describe('createFixtureApi', () => { }) }) - it('serves grouped models and keeps a selected target for later history and fixture requests', async () => { + it('serves grouped models and keeps a selection for later history and fixture requests', async () => { const api = createFixtureApi() const sessionId = sid('fx-alpha') const catalog = await api.sessions.models(req({ sessionId })) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index f9af3fb8fe..930a12dea3 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/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 packages/client/runtime/README.md -README.md: a9b604974595b1b7856f74b72d36093491ec1bd1 -README.zh.md: 41c81532667f445ce1c52e1b84185ef503e82141 +README.md: c2ce461439a89aa4fe2d59ebf92d14539479dac8 +README.zh.md: bca93b80989152eccccaf13c0f31435c91e7393c diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index a9b6049745..c2ce461439 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -62,7 +62,7 @@ The Session object validates plugin-owned, provider-routed `llm/retry` payloads ## Session model selection -Each resident `Session` owns a `modelSelection` snapshot containing the current provider/model target, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current target, opening a selector refreshes the directory, and selection failures preserve the last target and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the target reported by the Host without replacing unchanged selection substructure. +Each resident `Session` owns a `modelSelection` snapshot containing the current `ModelSelection`, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current selection, opening a selector refreshes the directory, and selection failures preserve the last selection and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the selection reported by the Host without replacing unchanged selection substructure. ## Model Experience @@ -70,7 +70,7 @@ None, as the session object layer selects the provider/model route used by a lat #### KV Cache effect -Changing the target can change or invalidate provider-side cache reuse; this package does not alter the prompt prefix itself. +Changing the model selection can change or invalidate provider-side cache reuse; this package does not alter the prompt prefix itself. ## Known Limitations and Deferred Work diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 41c8153266..bca93b8098 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -62,7 +62,7 @@ Session 对象会在事件 wire 边界依据生产方的完整字段约定,验 ## 会话模型选择 -每个常驻 `Session` 都拥有一个 `modelSelection` 快照,其中包含当前提供方/模型目标、按提供方分组的目录、逐提供方失败记录,以及 `idle`/`loading`/`ready`/`selecting`/`error` 状态。历史记录会建立或刷新当前目标,打开选择器会刷新目录;选择失败会保留上一个目标和可用分组。目录与选择操作共用单调递增的代次,因此较旧响应无法覆盖较新的选择。重连重建会恢复 Host 报告的目标,同时不替换未变化的选择子结构。 +每个常驻 `Session` 都拥有一个 `modelSelection` 快照,其中包含当前 `ModelSelection`、按提供方分组的目录、逐提供方失败记录,以及 `idle`/`loading`/`ready`/`selecting`/`error` 状态。历史记录会建立或刷新当前选择,打开选择器会刷新目录;选择失败会保留上一个选择和可用分组。目录与选择操作共用单调递增的代次,因此较旧响应无法覆盖较新的选择。重连重建会恢复 Host 报告的选择,同时不替换未变化的选择子结构。 ## 模型体验 @@ -70,7 +70,7 @@ Session 对象会在事件 wire 边界依据生产方的完整字段约定,验 #### KV Cache 影响 -更改目标可能改变提供方侧的缓存复用,或使其失效;该包本身不会改变提示词前缀。 +更改模型选择可能改变提供方侧的缓存复用,或使其失效;该包本身不会改变提示词前缀。 ## 已知限制与暂缓事项 diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 5e510c0012..80f45db067 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -3,7 +3,7 @@ // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { - ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame, + ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelSelection, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' @@ -64,7 +64,7 @@ export class FakeApiClient implements IApiClient { onSearch: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [], hasMore: false })) onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) - readonly defaultModel: ModelTarget = { provider: 'deepseek-official', model: 'deepseek-v4-flash' } + readonly defaultModel: ModelSelection = { provider: 'deepseek-official', model: 'deepseek-v4-flash' } onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) onFork: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) @@ -82,7 +82,7 @@ export class FakeApiClient implements IApiClient { failures: [], })) onSelectModel: (payload: { provider: string; model: string }) => - Promise> = + Promise> = payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index e203e49dd9..c69465df45 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -842,7 +842,7 @@ describe('connected generation', () => { api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false, - modelTarget: { provider: 'deepseek-official', model: 'deepseek-chat' }, + modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' }, })) const manager = new SessionManager(api) const openedSession = manager.get(S1) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 5d5f292f92..834f37d434 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -89,7 +89,7 @@ describe('open', () => { gate.resolve(ok({ events: entries(page) as never[], hasMore: false, - modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, })) await opening const seqs = session.getSnapshot().nodes.map(n => n.seq) @@ -631,7 +631,7 @@ describe('paging', () => { gate.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false, - modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, })) await Promise.all([first, second]) expect(api.callsOf('session.history')).toHaveLength(2) // open + one page, not two @@ -1018,7 +1018,7 @@ describe('remaining branches', () => { stale.resolve(ok({ events: entries(plainTurn(0, 0, '旧', '代')) as never[], hasMore: false, - modelTarget: { provider: 'deepseek-official', model: 'stale' }, + modelSelection: { provider: 'deepseek-official', model: 'stale' }, })) // success, but its generation is gone await Promise.all([opening, resynced]) expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9]) // only the fresh generation's window @@ -1041,7 +1041,7 @@ describe('remaining branches', () => { secondPull.resolve(ok({ events: entries([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) as never[], hasMore: false, - modelTarget: { provider: 'deepseek-official', model: 'stale' }, + modelSelection: { provider: 'deepseek-official', model: 'stale' }, })) await Promise.all([opening, resynced]) expect(session.getSnapshot().openState).toBe('open') @@ -1059,7 +1059,7 @@ describe('remaining branches', () => { repairPull.resolve(ok({ events: entries(plainTurn(0, 0, '旧', '页')) as never[], hasMore: false, - modelTarget: { provider: 'deepseek-official', model: 'stale' }, + modelSelection: { provider: 'deepseek-official', model: 'stale' }, })) // repair result: stale, dropped await resynced expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9]) @@ -1104,7 +1104,7 @@ describe('remaining branches', () => { { event: ev.toolResult(7, 1, 'h1', 'done'), view: { for: 'result', view: { card: 'generic', title: '历史果' } } }, ] as never[], hasMore: false, - modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, })) await session.open() expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ diff --git a/packages/client/ui-model/README.i18n.yaml b/packages/client/ui-model/README.i18n.yaml index a909395148..e630daf0fa 100644 --- a/packages/client/ui-model/README.i18n.yaml +++ b/packages/client/ui-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/client/ui-model/README.md -README.md: 5a6f998476629566d35af32efa5d8bc5072a872b -README.zh.md: 55ea296370ffa986c9b11b41f83ef83b12236e88 +README.md: b37f807944850fb34331cd8d78b4a2ccf157f21c +README.zh.md: 906096bed06af26517e215b2d1e1da4b62e58095 diff --git a/packages/client/ui-model/README.md b/packages/client/ui-model/README.md index 5a6f998476..b37f807944 100644 --- a/packages/client/ui-model/README.md +++ b/packages/client/ui-model/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). For ordinary sessions, the `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. `/model` applies the selected model's default effort, and the composer can then choose any advertised effort. -The Host-reported provider/model/reasoning target is the single selection fact, but it is echoed only when the exact route remains in the advertised groups; removing that catalog row leaves the routable target intact while the trigger prompts `Select model`, no stale row is synthesized, and no Effort row is shown until the user picks an advertised model. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory. +The Host-reported provider/model/reasoning `ModelSelection` is the single selection fact, but it is echoed only when the exact provider/model pair remains in the advertised groups; an absent catalog row leaves the routable selection intact while the trigger prompts `Select model`, no stale row is synthesized, and no Effort row is shown until the user picks an advertised model. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored selection before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior selection and directory. When the Host reports that no adapter serves the session's route (`session.models.routable`), this plugin raises a composer block through `ctx.conversation.blocks` and the input goes inert with this plugin's own copy; recovering clears it without a reload. It follows `routable` and nothing else: a `null` — before the first load, or after one failed — never blocks, or a slow Host would lock a working composer, and catalog membership never blocks either, because a route serving a model it stopped advertising is missing from the groups yet perfectly usable. The trigger's own `Select model` fallback still covers that case, which is display, not a gate. @@ -14,7 +14,7 @@ The `/client` export surface is the plugin body (`apply`/`inject`), `ModelServic ## Model Experience -Indirectly, through the `session.selectModel` RPC available to ordinary sessions, both entries submit the provider/model/reasoning target that the Host snapshots at the next prompt-assembly boundary, so the following request uses the chosen route and effort while a running step keeps its assembled target; the selection becomes durable only when the existing request header records a request that consumes it, and menu interaction adds no prompt content. +Indirectly, through the `session.selectModel` RPC available to ordinary sessions, both entries submit the complete `ModelSelection` that the Host snapshots at the next prompt-assembly boundary, so the following request uses the selected provider, model, and effort while a running step keeps its assembled selection; the selection becomes durable only when the existing request header records a request that consumes it, and menu interaction adds no prompt content. #### KV Cache effect @@ -22,6 +22,6 @@ Switching the route can reduce or invalidate provider-side cache reuse for subse ## Known Limitations and Deferred Work -- **No create-time or addressed-subagent selection** — both entries require an existing ordinary session's Agent; there is no draft-phase model choice to fold into session creation, and subagent continuation deliberately exposes no independent model-retargeting contract. +- **No create-time or addressed-subagent selection** — both entries require an existing ordinary session's Agent; there is no draft-phase model choice to fold into session creation, and subagent continuation deliberately exposes no independent model-selection contract. - **Directory names are presentation-only** — selection and persistence use provider/model/effort ids; a provider whose catalog or exact-model metadata lookup fails lists as an unselectable failure row until reload. - **No arbitrary effort input** — the composer offers only the exact model's adapter-advertised levels; an adapter without reasoning metadata leaves the Effort row absent. diff --git a/packages/client/ui-model/README.zh.md b/packages/client/ui-model/README.zh.md index 55ea296370..906096bed0 100644 --- a/packages/client/ui-model/README.zh.md +++ b/packages/client/ui-model/README.zh.md @@ -4,7 +4,7 @@ 模型选择插件(浏览器侧):**两个入口共用一份会话级目录**,由 `ModelService`(`ctx.models`)持有。对于普通会话,`/model` popupSelect 贡献项(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` slot 都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选具体模型则提供由其适配器持有的推理强度名称、说明和默认值。`/model` 应用所选模型的默认推理强度,composer 随后可以选择任一已公布的推理强度。 -Host 报告的提供方/模型/推理(reasoning)目标是唯一的选择事实,但只有当该精确路由仍在已公布分组中时才会回显;删除该目录行会保留仍可路由的目标,但触发器会提示 `Select model`,系统不会合成陈旧行,且在用户选择已公布的模型之前不会显示 Effort 行。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。 +Host 报告的 `ModelSelection` 是唯一的选择事实,其中包含提供方、模型与推理(reasoning)强度;但只有当该提供方/模型对仍在已公布分组中时才会回显。目录行缺席时,可路由的选择保持不变,但触发器会提示 `Select model`;系统不会合成陈旧行,且在用户选择已公布的模型之前不会显示 Effort 行。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的选择。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的选择和目录。 当宿主报告没有适配器服务该会话的路由(`session.models.routable`)时,本插件经 `ctx.conversation.blocks` 注册一个 composer 阻塞块,输入框随之停用并显示本插件自己的文案;恢复后无需重新加载即自动清除。它只跟随 `routable`:`null`(首次加载之前,或加载失败之后)绝不阻断,否则一个慢的宿主就会锁死一个本来可用的 composer;目录成员关系同样不阻断,因为一条仍在服务、只是不再公布该模型的路由不在分组里,却完全可用。触发器自己的 `Select model` 回退仍然覆盖那种情形——那是显示,不是闸门。 @@ -14,7 +14,7 @@ Host 报告的提供方/模型/推理(reasoning)目标是唯一的选择 ## 模型体验 -间接影响。两个入口都通过仅供普通会话使用的 `session.selectModel` RPC 提交提供方/模型/推理强度目标;Host 会在下一次提示词组装边界对该目标进行快照,因此后续请求采用所选路由和推理强度,而运行中的步骤保留已组装目标。只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化;菜单交互不会添加提示词内容。 +间接影响。两个入口都通过仅供普通会话使用的 `session.selectModel` RPC 提交完整的 `ModelSelection`;Host 会在下一次提示词组装边界对其进行快照,因此后续请求采用所选提供方、模型与推理强度,而运行中的步骤保留已组装选择。只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化;菜单交互不会添加提示词内容。 #### KV Cache 影响 @@ -22,6 +22,6 @@ Host 报告的提供方/模型/推理(reasoning)目标是唯一的选择 ## 已知限制与暂缓事项 -- **无创建期或已寻址 subagent 选择**——两个入口都要求既有普通会话的 agent;没有可纳入会话创建的草稿阶段模型选择,subagent 继续执行也有意不公开独立更改模型目标的约定。 +- **无创建期或已寻址 subagent 选择**——两个入口都要求既有普通会话的 agent;没有可纳入会话创建的草稿阶段模型选择,subagent 继续执行也有意不公开独立的模型选择约定。 - **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id;目录查询或确切模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。 - **不能任意输入推理强度**——composer 仅提供确切模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。 diff --git a/packages/client/ui-model/src/client/ModelSelect.tsx b/packages/client/ui-model/src/client/ModelSelect.tsx index 343243a28c..6b7bfd19b9 100644 --- a/packages/client/ui-model/src/client/ModelSelect.tsx +++ b/packages/client/ui-model/src/client/ModelSelect.tsx @@ -14,7 +14,7 @@ import { type KeyboardEvent, type FocusEvent, } from 'react' import clsx from 'clsx' -import type { ModelReasoningEffort, ModelTarget } from '@deepseek-ai/dsh-client-connection/client' +import type { ModelReasoningEffort, ModelSelection } from '@deepseek-ai/dsh-client-connection/client' import { IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -58,17 +58,17 @@ export function ModelSelect( group.models.map(model => ({ group, model, - target: { + selection: { provider: group.id, model: model.id, ...model.reasoning?.defaultEffort === undefined ? {} : { reasoningEffort: model.reasoning.defaultEffort }, - } satisfies ModelTarget, + } satisfies ModelSelection, }))), [state.groups]) const selectedIndex = state.current === null ? -1 - : choices.findIndex(c => c.target.provider === state.current?.provider && c.target.model === state.current.model) + : choices.findIndex(c => c.selection.provider === state.current?.provider && c.selection.model === state.current.model) const currentChoice = choices[selectedIndex] const reasoning = currentChoice?.model.reasoning const effectiveEffort = state.current?.reasoningEffort ?? reasoning?.defaultEffort @@ -148,12 +148,12 @@ export function ModelSelect( close() } - const choose = (target: ModelTarget): void => { - if (state.current?.provider === target.provider && state.current.model === target.model) { + const choose = (selection: ModelSelection): void => { + if (state.current?.provider === selection.provider && state.current.model === selection.model) { close(true) return } - void select(target).then((accepted) => { + void select(selection).then((accepted) => { if (accepted && rootRef.current !== null) close(true) }) } @@ -164,12 +164,12 @@ export function ModelSelect( close(true) return } - const target: ModelTarget = { + const selection: ModelSelection = { provider: state.current.provider, model: state.current.model, ...effort === undefined ? {} : { reasoningEffort: effort }, } - void select(target).then((accepted) => { + void select(selection).then((accepted) => { if (accepted && rootRef.current !== null) close(true) }) } diff --git a/packages/client/ui-model/src/client/directory.ts b/packages/client/ui-model/src/client/directory.ts index 8432cf1b77..1555a2df43 100644 --- a/packages/client/ui-model/src/client/directory.ts +++ b/packages/client/ui-model/src/client/directory.ts @@ -6,17 +6,17 @@ * either entry is what the other shows next. */ import type { - IApiClient, ModelCatalogFailure, ModelProviderGroup, ModelTarget, SessionId, SessionModels, + IApiClient, ModelCatalogFailure, ModelProviderGroup, ModelSelection, SessionId, SessionModels, } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' /** Directory snapshot both entries render from. */ export interface ModelDirectoryState { - /** Target the host reports for the next assembled step; null before the first load. */ - current: ModelTarget | null + /** Model selection the host reports for the next assembled step; null before the first load. */ + current: ModelSelection | null /** - * Whether an adapter serves the current target's route, as the host reports + * Whether an adapter serves the current selection's provider, as the host reports * it — null before the first load, which is NOT the same as blocked. Read * this rather than "current matches no group": catalog membership is * advisory, so a route serving a model it stopped advertising is missing @@ -57,7 +57,7 @@ export class ModelDirectory { /** * Refresh the advisory directory (both entries call this on open). - * Failure preserves the last good groups and current target. + * Failure preserves the last good groups and current selection. * @returns the fresh directory value. */ async load(): Promise { @@ -86,22 +86,22 @@ export class ModelDirectory { } /** - * Select the complete provider/model/reasoning target (both entries submit through here). Success + * Select the complete provider/model/reasoning selection (both entries submit through here). Success * updates the shared current; failure surfaces on the store and throws so * each entry's own retry surface engages. - * @param target - provider, provider-owned model id, and optional adapter-owned effort. - */ - async select(target: ModelTarget): Promise { + * @param selection - provider, provider-owned model id, and optional adapter-owned effort. + */ + async select(selection: ModelSelection): Promise { this.assertAvailable() const generation = ++this.generation this.store.update((s) => { s.status = 'selecting'; s.error = null }) const { result } = await this.sessions.selectModel({ sessionId: this.sessionId, - provider: target.provider, - model: target.model, - ...target.reasoningEffort === undefined + provider: selection.provider, + model: selection.model, + ...selection.reasoningEffort === undefined ? {} - : { reasoningEffort: target.reasoningEffort }, + : { reasoningEffort: selection.reasoningEffort }, }) if (this.disposed || generation !== this.generation) { if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`) @@ -124,7 +124,7 @@ export class ModelDirectory { /** * Drop the previous Host generation's projection and repull it. Clearing * first prevents an unconsumed process-local selection from being displayed - * while the restarted Host has restored the last logged request target. + * while the restarted Host has restored the last logged model selection. */ resetConnected(): void { if (this.disposed) return diff --git a/packages/client/ui-model/src/client/index.ts b/packages/client/ui-model/src/client/index.ts index 68a82bd20a..f55cb83c1c 100644 --- a/packages/client/ui-model/src/client/index.ts +++ b/packages/client/ui-model/src/client/index.ts @@ -4,14 +4,14 @@ * contribution and the composer's named `conversation.input.model` seat both * load the session's provider-grouped advisory directory (`session.models`) * and submit through `session.selectModel` via the same directory instance, - * so the host-reported current target is the single fact both surfaces echo + * so the host-reported current selection is the single fact both surfaces echo * — a switch made in either entry is what the other shows next. Failures * ride each entry's own retry surface (popup shell error/retry; seat menu * inline error) without forking the state. Addressed subagent sessions expose * neither entry because those Agent-bound RPCs would activate persisted * history outside the direct-parent continuation seam. */ -import type { ModelTarget, SessionModels } from '@deepseek-ai/dsh-client-connection/client' +import type { ModelSelection, SessionModels } from '@deepseek-ai/dsh-client-connection/client' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client' // Type-only: pulls the ui-conversation SlotMap merge (the input.model seat). @@ -68,13 +68,13 @@ function optionsOf(directory: SessionModels, t: TranslateNS<'model'>): SelectOpt } /** - * Resolve a picked row back to its target by matching against the loaded + * Resolve a picked row back to its model selection by matching against the loaded * groups (the same data the rows were built from — ids stay opaque). * @param state - the session's directory snapshot. * @param id - the picked row id. - * @returns the row's target, or undefined for failure rows / stale ids. + * @returns the row's model selection, or undefined for failure rows / stale ids. */ -function targetOf(state: ModelDirectoryState, id: string): ModelTarget | undefined { +function selectionOf(state: ModelDirectoryState, id: string): ModelSelection | undefined { for (const group of state.groups) { for (const model of group.models) { if (rowId(group.id, model.id) !== id) continue @@ -139,11 +139,11 @@ export function apply(ctx: ClientContext): void { throw new Error('model selection is unavailable for addressed subagent sessions') } const directory = models.directoryFor(session.sessionId) - const target = targetOf(directory.store.getSnapshot(), option.id) - if (target === undefined) { + const selection = selectionOf(directory.store.getSnapshot(), option.id) + if (selection === undefined) { throw new Error('this provider\'s catalog failed to load — pick a model from a loaded group') } - await directory.select(target) + await directory.select(selection) }, }, }), 'ui-model: /model contribution') @@ -165,8 +165,8 @@ export function apply(ctx: ClientContext): void { load: () => { if (available) directory.load().catch(() => { /* surfaced on the store */ }) }, - select: (target: ModelTarget) => available - ? directory.select(target).then(() => true, () => false) + select: (selection: ModelSelection) => available + ? directory.select(selection).then(() => true, () => false) : Promise.resolve(false), } }, diff --git a/packages/client/ui-model/src/client/slots.ts b/packages/client/ui-model/src/client/slots.ts index 3d408de232..e3e3957726 100644 --- a/packages/client/ui-model/src/client/slots.ts +++ b/packages/client/ui-model/src/client/slots.ts @@ -4,7 +4,7 @@ * entry; this package only contributes the single occupant, so no SlotMap * merge lives here. */ -import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client' +import type { ModelSelection } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { ModelDirectoryState } from './directory.ts' @@ -17,9 +17,9 @@ export interface ModelSelectInjected { /** Refresh the advisory directory (fire-and-forget; errors land on the store). */ load: () => void /** - * Select a complete provider/model/reasoning target through the shared route. - * @param target - model target and optional adapter-owned effort. + * Select a complete provider/model/reasoning selection. + * @param selection - model selection and optional adapter-owned effort. * @returns whether the host accepted the selection. */ - select: (target: ModelTarget) => Promise + select: (selection: ModelSelection) => Promise } diff --git a/packages/client/ui-model/tests/browser-plugin.spec.ts b/packages/client/ui-model/tests/browser-plugin.spec.ts index 4fbeca5600..422ada56b3 100644 --- a/packages/client/ui-model/tests/browser-plugin.spec.ts +++ b/packages/client/ui-model/tests/browser-plugin.spec.ts @@ -13,7 +13,7 @@ import { describe, expect, it } from 'vitest' import { createScope } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' -import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client' +import type { ModelSelection } from '@deepseek-ai/dsh-client-connection/client' import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client' import type { ModelSelectInjected } from '../src/client/slots.ts' import { apply, inject } from '../src/client/index.ts' @@ -55,7 +55,7 @@ const GROUPS = [{ /** Boot the plugin over fake faces + a stateful fake host (current moves on selectModel). */ async function bench() { const ctx = new Context() - let current: ModelTarget = { provider: 'deepseek-official', model: 'deepseek-v4-flash' } + let current: ModelSelection = { provider: 'deepseek-official', model: 'deepseek-v4-flash' } const calls = { models: 0, select: 0 } ctx.provide('connection', { api: { sessions: { models: () => { @@ -125,7 +125,7 @@ async function bench() { contribution: () => contribution!, seat: () => seats.get('conversation.input.model')!, hostCurrent: () => current, - setHostCurrent: (target: ModelTarget) => { current = target }, + setHostCurrent: (selection: ModelSelection) => { current = selection }, address: (id: SessionId) => { addressed.add(id) }, setRoutable: (next: boolean) => { routable = next }, blockOf: (key: string) => blocks.get(sid(key)), diff --git a/packages/client/ui-model/tests/model-select.spec.tsx b/packages/client/ui-model/tests/model-select.spec.tsx index 75a0622ed6..1b1e1ef17c 100644 --- a/packages/client/ui-model/tests/model-select.spec.tsx +++ b/packages/client/ui-model/tests/model-select.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client' +import type { ModelSelection } from '@deepseek-ai/dsh-client-connection/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { ComponentProps } from 'react' import type { ModelDirectoryState } from '../src/client/directory.ts' @@ -48,10 +48,10 @@ function state(overrides: Partial = {}): ModelDirectoryStat afterEach(cleanup) describe('ModelSelect reasoning effort', () => { - it('renders adapter metadata and submits the effort as part of the session target', async () => { + it('renders adapter metadata and submits the effort as part of the session selection', async () => { const directory = createSnapshotStore(state()) - const select = vi.fn(async (target: ModelTarget) => { - directory.set(state({ current: target })) + const select = vi.fn(async (selection: ModelSelection) => { + directory.set(state({ current: selection })) return true }) render( { .toEqual(['Default', 'Standard']) }) - it('prompts for a new selection when the current target is no longer advertised', () => { + it('prompts for a selection when the current model is no longer advertised', () => { const directory = createSnapshotStore(state({ current: { provider: 'deepseek-official', model: 'removed-model' }, })) diff --git a/packages/core/README.i18n.yaml b/packages/core/README.i18n.yaml index ff41da1511..f3ab9dcc56 100644 --- a/packages/core/README.i18n.yaml +++ b/packages/core/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/README.md -README.md: 40a888183a9338805d83c86bab9ae16f1c7bc04f -README.zh.md: c0a24d78cb8ff2a2b2ccc2d2a2f4294424be55b0 +README.md: 19aed9abafa8e4531d2ee31ff0442003328a9ef6 +README.zh.md: 73ed1f500799cb235a8835ffb0317370f9a0a7e6 diff --git a/packages/core/README.md b/packages/core/README.md index 40a888183a..19aed9abaf 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The session log, system-prompt assembly, tool registry, agent vocabulary, and concrete loop that form the harness's default control spine. These are **product** packages — the stable surface plugins and consumers build against. +The session log, system-prompt assembly, tool registry, agent vocabulary, deployment-default model selection, and concrete loop that form the harness's default control spine. These are **product** packages — the stable surface plugins and consumers build against. | Package | Role | ctx key | |---|---|---| @@ -11,9 +11,10 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co | [`system-prompt/`](system-prompt/README.md) | Prompt and tool-schema assembly registry | `ctx.systemPrompt` | | [`tools/`](tools/README.md) | Scoped tool registry and execution pipeline | `ctx.tools` | | [`agent/`](agent/README.md) | Agent interface, registry, and event vocabulary | `ctx.agents` | +| [`agent-default-model/`](agent-default-model/README.md) | Default model selection shared by Agent front doors | `ctx.agentDefaultModel` | | [`agent-loop/`](agent-loop/README.md) | Default concrete agent driver | `ctx.agentLoop` | -`scope` supplies the shared scoping primitive. `agent` owns the public seam, while `agent-loop` is its default implementation; extension plugins depend on the seam so the driver remains swappable. +`scope` supplies the shared scoping primitive. `agent` owns the public seam, while `agent-loop` is its default implementation; extension plugins depend on the seam so the driver remains swappable. `agent-default-model` owns the deployment selection an Agent front door uses only when a session has no selection of its own. Runnable compositions belong to [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md); this group owns only the swappable spine pieces. diff --git a/packages/core/README.zh.md b/packages/core/README.zh.md index c0a24d78cb..73ed1f5007 100644 --- a/packages/core/README.zh.md +++ b/packages/core/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -构成 harness 默认控制主干的会话日志、系统提示词组装、工具注册表、agent(智能体)词汇和具体循环。这些是**产品**包,即插件和消费方构建所依赖的稳定 surface。 +构成 harness 默认控制主干的会话日志、系统提示词组装、工具注册表、agent(智能体)词汇、部署默认模型选择和具体循环。这些是**产品**包,即插件和消费方构建所依赖的稳定 surface。 | 包 | 职责 | ctx key | |---|---|---| @@ -11,9 +11,10 @@ | [`system-prompt/`](system-prompt/README.md) | 提示词和工具 schema 组装注册表 | `ctx.systemPrompt` | | [`tools/`](tools/README.md) | 作用域工具注册表和执行流水线 | `ctx.tools` | | [`agent/`](agent/README.md) | Agent 接口、注册表和事件词汇 | `ctx.agents` | +| [`agent-default-model/`](agent-default-model/README.md) | 各 Agent 入口共享的默认模型选择 | `ctx.agentDefaultModel` | | [`agent-loop/`](agent-loop/README.md) | 默认具体 agent 驱动器 | `ctx.agentLoop` | -`scope` 提供共享作用域原语。`agent` 负责公开 seam,`agent-loop` 是其默认实现;扩展插件依赖该 seam,从而保持驱动器可替换。 +`scope` 提供共享作用域原语。`agent` 负责公开 seam,`agent-loop` 是其默认实现;扩展插件依赖该 seam,从而保持驱动器可替换。`agent-default-model` 负责部署选择,Agent 入口仅在会话自身没有选择时使用它。 可运行组合属于 [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md);该分组只负责可替换的主干组件。 diff --git a/packages/core/agent-default-model/README.i18n.yaml b/packages/core/agent-default-model/README.i18n.yaml new file mode 100644 index 0000000000..92c6095788 --- /dev/null +++ b/packages/core/agent-default-model/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/core/agent-default-model/README.md +README.md: 02bcc9be3adee2293a20b3ae87ddaf4d52e70deb +README.zh.md: 807b612bd25e49aa318c13c8c8dc7595a6459080 diff --git a/packages/core/agent-default-model/README.md b/packages/core/agent-default-model/README.md new file mode 100644 index 0000000000..02bcc9be3a --- /dev/null +++ b/packages/core/agent-default-model/README.md @@ -0,0 +1,25 @@ +# @deepseek-ai/dsh-agent-default-model + +English | [中文](README.zh.md) + +The deployment default used when a front door creates an Agent that has no session-local model selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel`; direct front doors such as `dsh run` and Host-backed front doors 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. + +- `ctx.agentDefaultModel.currentSelection()` returns a detached `{ provider, model, reasoningEffort? }` selection for a newly created Agent. +- `ctx.agentDefaultModel.saveSelection(selection)` saves the complete user selection. Without a settings provider it is a no-op and the composition entry remains current. + +The service does not validate catalog membership. A provider route may serve an unadvertised model, and the consumer that actually opens a model request owns availability diagnostics. + +## Model Experience + +Indirectly, through the provider/model selection supplied to a front door; request assembly and adapters own the model-visible request. + +#### KV Cache effect + +Changing the default affects only Agents that subsequently resolve from it. An existing session whose request log already names a selection keeps that selection, so this service does not invalidate its established prefix. + +## Known Limitations and Deferred Work + +- The service owns one process-wide default; per-session selection remains the front door's responsibility. +- Without a settings provider, `saveSelection()` cannot retain a selection for a later Agent. diff --git a/packages/core/agent-default-model/README.zh.md b/packages/core/agent-default-model/README.zh.md new file mode 100644 index 0000000000..807b612bd2 --- /dev/null +++ b/packages/core/agent-default-model/README.zh.md @@ -0,0 +1,25 @@ +# @deepseek-ai/dsh-agent-default-model + +[English](README.md) | 中文 + +该部署默认值供入口在创建尚无会话级模型选择的 Agent 时使用。`AgentDefaultModelService` 提供 `ctx.agentDefaultModel`;`dsh run` 这类直接入口与 ApiProxy 这类由 Host 支撑的入口读取同一服务,而不是分别持有平行的提供方/模型默认值。 + +插件配置必须提供 `{ provider, model }`。该组合配置项构成 Settings 中 `agent-default-model` 分节的基础层;挂载的设置提供方在其上叠加用户选择,更改会在下一次调用 `currentSelection()` 时可见。`reasoningEffort` 属于该 Settings 分节,但特意不属于插件配置:完整保存的选择必须能在下一个选定模型没有推理(reasoning)强度时清除旧值,而组合配置值会再次被继承。 + +- `ctx.agentDefaultModel.currentSelection()` 返回一份独立的 `{ provider, model, reasoningEffort? }` 选择,供新创建的 Agent 使用。 +- `ctx.agentDefaultModel.saveSelection(selection)` 保存完整的用户选择。未挂载设置提供方时,此调用不执行任何操作,组合配置项仍为当前值。 + +该服务不校验目录成员关系。提供方路由可以服务未在目录中公布的模型;实际发起模型请求的消费方负责可用性诊断。 + +## 模型体验 + +通过提供给入口的提供方/模型选择间接影响;模型可见请求由请求组装与适配器负责。 + +#### KV Cache 影响 + +更改默认值只影响之后从该默认值解析选择的 Agent。请求日志已经指明选择的现有会话仍沿用该选择,因此本服务不会使其已建立的前缀失效。 + +## 已知限制与暂缓事项 + +- 该服务只拥有一项进程级默认值;每个会话的选择仍由入口负责。 +- 未挂载设置提供方时,`saveSelection()` 无法保留选择供后续 Agent 使用。 diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json new file mode 100644 index 0000000000..d012fbea93 --- /dev/null +++ b/packages/core/agent-default-model/package.json @@ -0,0 +1,44 @@ +{ + "name": "@deepseek-ai/dsh-agent-default-model", + "description": "Default model selection shared by Agent front doors", + "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", + "dependencies": { + "schemastery": "^3.18.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-settings": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/core/agent-default-model/src/index.ts b/packages/core/agent-default-model/src/index.ts new file mode 100644 index 0000000000..36b3b9ba44 --- /dev/null +++ b/packages/core/agent-default-model/src/index.ts @@ -0,0 +1,107 @@ +/** + * Default model selection for an Agent without a session-specific selection. + * + * @module @deepseek-ai/dsh-agent-default-model + */ + +import { Context, Service } from 'cordis' +import z from 'schemastery' +import type { ModelSelection } from '@deepseek-ai/dsh-agent' +import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' + +declare module 'cordis' { + interface Context { + /** Default model selection for Agents created without an explicit model. */ + agentDefaultModel: AgentDefaultModelService + } +} + +/** Settings namespace carrying the default model selection for future Agents. */ +export const AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE = settingsNamespace('agent-default-model') + +/** Stored and composed default model selection. */ +export interface AgentDefaultModelSettings { + /** Registered provider route. */ + provider: string + /** Provider-owned model id. */ + model: string + /** Adapter-owned reasoning effort, or provider/default behavior when absent. */ + reasoningEffort?: string +} + +/** Schema of the default Agent model settings section. */ +export const AGENT_DEFAULT_MODEL_SETTINGS_SCHEMA: z = z.object({ + provider: z.string().required(), + model: z.string().required(), + reasoningEffort: z.string(), +}) + +/** Composition entry for the default model selection. */ +export interface Config { + /** Registered provider route. */ + provider: string + /** Provider-owned model id. */ + model: string +} + +/** Project stored settings onto the Agent-facing selection type. */ +function selection(settings: AgentDefaultModelSettings): ModelSelection { + return { + provider: settings.provider, + model: settings.model, + ...settings.reasoningEffort === undefined + ? {} + : { reasoningEffort: ReasoningEffortId(settings.reasoningEffort) }, + } +} + +/** + * Owns the default model selection independently of any Host or transport. + * The composition entry remains usable without a settings provider; when one + * is mounted, its user layer is read live. + */ +export class AgentDefaultModelService extends Service { + static Config: z = z.object({ + provider: z.string().required(), + model: z.string().required(), + }) + + private source: () => AgentDefaultModelSettings + + constructor(ctx: Context, config: Config) { + super(ctx, 'agentDefaultModel') + const entry: AgentDefaultModelSettings = { provider: config.provider, model: config.model } + this.source = () => entry + installSettingsSection(ctx, AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, AGENT_DEFAULT_MODEL_SETTINGS_SCHEMA, entry, { + setSource: (current) => { this.source = current }, + // Every consumer reads through currentSelection(), so no registration-level fact + // needs rebuilding when the settings document changes. + onChange: () => {}, + }) + } + + /** + * Read the current default model selection. + * @returns a detached provider, model, and optional reasoning selection. + */ + currentSelection(): ModelSelection { + return selection(this.source()) + } + + /** + * Save the complete default model selection. A deployment without a settings + * provider keeps its composition entry. + * @param next - resolved selection accepted by a front door. + * @returns fulfillment after the optional settings write settles. + */ + async saveSelection(next: ModelSelection): Promise { + await this.ctx.get('settings')?.replace(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, { + provider: next.provider, + model: next.model, + ...next.reasoningEffort === undefined ? {} : { reasoningEffort: String(next.reasoningEffort) }, + }) + } +} + +export default AgentDefaultModelService diff --git a/packages/core/agent-default-model/src/invariant.ts b/packages/core/agent-default-model/src/invariant.ts new file mode 100644 index 0000000000..8366018661 --- /dev/null +++ b/packages/core/agent-default-model/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for the default Agent model selection. + * + * The service owns no independent event relationship: settings registration + * already validates every mutable value before `currentSelection()` can observe it. + * The empty installer keeps that absence explicit in composed invariant sets. + * + * @module @deepseek-ai/dsh-agent-default-model/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-agent-default-model' + +/** Cordis companion plugin name. */ +export const name = 'agent-default-model-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** No runtime invariant: settings validation owns the only mutable-value relationship. */ +const install: InvariantInstaller = () => {} + +/** + * Register the intentionally empty invariant contribution. + * @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/core/agent-default-model/tests/agent-default-model.spec.ts b/packages/core/agent-default-model/tests/agent-default-model.spec.ts new file mode 100644 index 0000000000..ef479b4a96 --- /dev/null +++ b/packages/core/agent-default-model/tests/agent-default-model.spec.ts @@ -0,0 +1,98 @@ +/** Default Agent model settings layered over a real settings provider. */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import AgentDefaultModelService, { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '../src/index.ts' +import { Settings } from '@deepseek-ai/dsh-settings' +import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' + +/** The smallest real provider: one in-memory document, always writable. */ +class MemorySettings extends Settings { + doc: Record = {} + + get writable(): boolean { + return true + } + + protected load(): Promise> { + return Promise.resolve(structuredClone(this.doc)) + } + + protected persist(ns: SettingsNamespace, section: Record): Promise { + this.doc = { ...this.doc, [ns]: structuredClone(section) } + return Promise.resolve() + } +} + +async function boot(): Promise<{ + ctx: Context + settingsFiber: Context['fiber'] + defaultModel: AgentDefaultModelService +}> { + const ctx = new Context() + const settingsFiber = ctx.plugin(MemorySettings) + await settingsFiber.await() + await ctx.plugin(AgentDefaultModelService, { + provider: 'deepseek-official', + model: 'deepseek-v4-flash', + }) + return { ctx, settingsFiber, defaultModel: ctx.agentDefaultModel } +} + +describe('AgentDefaultModelService', () => { + it('resolves the user layer over the composition entry', async () => { + const bench = await boot() + expect(bench.defaultModel.currentSelection()).toEqual({ + provider: 'deepseek-official', model: 'deepseek-v4-flash', + }) + + await bench.defaultModel.saveSelection({ + provider: 'acme-gateway', model: 'acme-large', reasoningEffort: ReasoningEffortId('high'), + }) + expect(bench.defaultModel.currentSelection()).toEqual({ + provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high', + }) + await bench.ctx.fiber.dispose() + }) + + it('clears a stored effort when the saved selection has none', async () => { + const bench = await boot() + await bench.defaultModel.saveSelection({ + provider: 'acme-gateway', model: 'acme-large', reasoningEffort: ReasoningEffortId('high'), + }) + await bench.defaultModel.saveSelection({ provider: 'acme-gateway', model: 'acme-plain' }) + expect(bench.defaultModel.currentSelection()).toEqual({ provider: 'acme-gateway', model: 'acme-plain' }) + await bench.ctx.fiber.dispose() + }) + + it('layers a hand-written partial section over the entry', async () => { + const bench = await boot() + await bench.settingsFiber.ctx.settings.replace(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, { + model: 'deepseek-reasoner', + }) + expect(bench.defaultModel.currentSelection()).toEqual({ + provider: 'deepseek-official', model: 'deepseek-reasoner', + }) + await bench.ctx.fiber.dispose() + }) + + it('falls back to the composition entry when the settings provider detaches', async () => { + const bench = await boot() + await bench.defaultModel.saveSelection({ provider: 'acme-gateway', model: 'acme-large' }) + expect(bench.defaultModel.currentSelection().provider).toBe('acme-gateway') + await bench.settingsFiber.dispose() + expect(bench.defaultModel.currentSelection()).toEqual({ + provider: 'deepseek-official', model: 'deepseek-v4-flash', + }) + await bench.ctx.fiber.dispose() + }) + + it('keeps the composition entry when no settings provider is mounted', async () => { + const ctx = new Context() + await ctx.plugin(AgentDefaultModelService, { provider: 'p', model: 'm' }) + await ctx.agentDefaultModel.saveSelection({ provider: 'other', model: 'other' }) + expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'p', model: 'm' }) + await ctx.fiber.dispose() + }) +}) diff --git a/packages/core/agent-default-model/tsconfig.json b/packages/core/agent-default-model/tsconfig.json new file mode 100644 index 0000000000..95b89f17b5 --- /dev/null +++ b/packages/core/agent-default-model/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../settings/settings" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/core/agent-default-model/tsdown.config.ts b/packages/core/agent-default-model/tsdown.config.ts new file mode 100644 index 0000000000..ab8dc26ee8 --- /dev/null +++ b/packages/core/agent-default-model/tsdown.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsdown' + +/** Build the package root and invariant companion as independent bundles. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, + { + entry: ['lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 53a1a1f650..825c1714a2 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/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 packages/core/agent/README.md -README.md: 3a3bdf6a4b3bdc5bfef250495e84b7d90b003822 -README.zh.md: e421070a6eec2e6e7e1fc7b45f0a5e29040bf712 +README.md: ba0fa456593e598b6532c10ef2390bbd55f3a486 +README.zh.md: 581da5c8621e5653f57edce38ac9816da7a40d40 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 3a3bdf6a4b..ba0fa45659 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -12,7 +12,7 @@ Tracks live agents and carries the initiating Agent through asynchronous driver ### Public API -The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. +The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installModelSelection(agentCtx, selection)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies its provider and model to prompt variables, and applies the complete selection to request routing for one step; an absent selected effort clears an inherited effort so adapter/provider defaults apply. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. `AgentOptions` supplies the initial provider/model route and an optional positive `maxTokens` output cap. The concrete loop resolves any exact-model adapter default, records the effective cap in the request header, and applies it to each conversation-model request; an explicit Agent option wins, while omission leaves the adapter or provider route default in control. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index e421070a6e..581da5c862 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -12,7 +12,7 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事 ### 公开 API -带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在 dispose(资源释放)时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型/推理(reasoning)强度选择,将路由应用到提示词变量,并将完整目标应用到一个步骤的请求路由;如果没有选定推理强度,则会清除继承的推理强度,使该目标使用适配器/提供方默认值。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。 +带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在 dispose(资源释放)时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installModelSelection(agentCtx, selection)` 在提示词组装期间快照可变的提供方/模型/推理(reasoning)强度选择,将其中的提供方和模型应用到提示词变量,并将完整选择应用到一个步骤的请求路由;如果没有选定推理强度,则会清除继承的推理强度,使适配器/提供方默认值生效。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。 `AgentOptions` 提供初始的提供方/模型路由,以及可选的正数 `maxTokens` 输出上限。具体循环会解析确切模型的适配器默认值,把生效上限记录到请求 header,并应用到每次对话模型请求;显式 Agent 选项优先,省略时由适配器或提供方路由默认值控制。 diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 8f316f75dc..44c55d80a2 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -17,7 +17,7 @@ import type { Agent, AgentOptions } from './types.ts' export * from './types.ts' export * from './inbox.ts' -export * from './llm-target.ts' +export * from './model-selection.ts' export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts' export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts' diff --git a/packages/core/agent/src/llm-target.ts b/packages/core/agent/src/model-selection.ts similarity index 55% rename from packages/core/agent/src/llm-target.ts rename to packages/core/agent/src/model-selection.ts index e23ea9d750..4d36ca34fb 100644 --- a/packages/core/agent/src/llm-target.ts +++ b/packages/core/agent/src/model-selection.ts @@ -1,13 +1,13 @@ /** - * Agent-scoped LLM target snapshot shared by interactive front doors. - * @module @deepseek-ai/dsh-agent/llm-target + * Agent-scoped model selection shared by interactive front doors. + * @module @deepseek-ai/dsh-agent/model-selection */ import type { Context } from 'cordis' import type { LlmCallConfig, ReasoningEffortId } from '@deepseek-ai/dsh-llm' -/** Complete provider/model route and optional reasoning effort selected for one live agent. */ -export interface AgentLlmTarget { +/** Complete provider, model, and optional reasoning effort selected for one live Agent. */ +export interface ModelSelection { /** Registered provider route. */ provider: string /** Provider-owned model id. */ @@ -16,31 +16,31 @@ export interface AgentLlmTarget { reasoningEffort?: ReasoningEffortId } -/** Mutable selection plus the target captured for the current step. */ -export interface AgentLlmTargetRef { - /** Target selected for the next step that enters prompt assembly. */ - current: AgentLlmTarget | undefined - /** Target captured when the current step entered prompt assembly. */ - assembled: AgentLlmTarget | undefined +/** Mutable model selection plus the value captured for the current step. */ +export interface ModelSelectionRef { + /** Model selected for the next step that enters prompt assembly. */ + current: ModelSelection | undefined + /** Selection captured when the current step entered prompt assembly. */ + assembled: ModelSelection | undefined } /** - * Couple one mutable target to agent-scoped prompt assembly and request routing. - * Prompt assembly snapshots the selected target before delegating, then applies - * its route to prompt variables and its route/effort to request config so a + * Couple one mutable selection to Agent-scoped prompt assembly and request routing. + * Prompt assembly snapshots the selected model before delegating, then applies + * its provider/model pair and effort to request config so a * concurrent switch takes effect on a later step instead of splitting the two - * surfaces. An absent selected effort clears any inherited effort so a model - * switch can restore that target's provider/default behavior. + * surfaces. An absent selected effort clears any inherited effort, restoring + * the selected model's provider/default behavior. * - * @param agentCtx - The target agent's scoped context. - * @param target - Mutable selection owned by the calling front door. + * @param agentCtx - The selected Agent's scoped context. + * @param selection - Mutable selection owned by the calling front door. * @returns Disposer for both scoped waterfall listeners. */ -export function installAgentLlmTarget(agentCtx: Context, target: AgentLlmTargetRef): () => void { +export function installModelSelection(agentCtx: Context, selection: ModelSelectionRef): () => void { const disposeAssembly = agentCtx.on('system-prompt/assemble', async (_assembly, _context, next) => { - const selected = target.current + const selected = selection.current const assembled = await next() - target.assembled = selected + selection.assembled = selected if (selected === undefined) return assembled return { ...assembled, @@ -55,7 +55,7 @@ export function installAgentLlmTarget(agentCtx: Context, target: AgentLlmTargetR 'agent/request', async (_payload, next): Promise => { const resolved = await next() - const selected = target.assembled + const selected = selection.assembled if (selected === undefined) return resolved const { reasoningEffort: _inheritedEffort, ...withoutInheritedEffort } = resolved return { diff --git a/packages/core/agent/tests/llm-target.spec.ts b/packages/core/agent/tests/model-selection.spec.ts similarity index 86% rename from packages/core/agent/tests/llm-target.spec.ts rename to packages/core/agent/tests/model-selection.spec.ts index 991a69ea32..2d7a1a7336 100644 --- a/packages/core/agent/tests/llm-target.spec.ts +++ b/packages/core/agent/tests/model-selection.spec.ts @@ -3,18 +3,18 @@ import { Context } from 'cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import { agentEvents, - installAgentLlmTarget, + installModelSelection, type Agent, - type AgentLlmTargetRef, + type ModelSelectionRef, } from '../src/index.ts' import { ReasoningEffortId, type LlmCallConfig } from '@deepseek-ai/dsh-llm' -describe('installAgentLlmTarget()', () => { +describe('installModelSelection()', () => { it('snapshots prompt variables and request routing together, then disposes both listeners', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) - const target: AgentLlmTargetRef = { current: undefined, assembled: undefined } - const dispose = installAgentLlmTarget(ctx, target) + const selection: ModelSelectionRef = { current: undefined, assembled: undefined } + const dispose = installModelSelection(ctx, selection) const agent = {} as Agent const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } const signal = new AbortController().signal @@ -24,13 +24,13 @@ describe('installAgentLlmTarget()', () => { 'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed), )).resolves.toBe(seed) - target.current = { + selection.current = { provider: 'alpha', model: 'a1', reasoningEffort: ReasoningEffortId('high'), } expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' }) - target.current = { provider: 'beta', model: 'b1' } + selection.current = { provider: 'beta', model: 'b1' } await expect(agentEvents(ctx, agent).waterfall( 'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed), )).resolves.toEqual({ diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index bc7869b361..b994e46b03 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: a3c9f214690144ec0f39a8690e4fd346f5e315e2 -README.zh.md: 65475351e279f258f4417f2082673382e76a5be3 +README.md: 5e3a6cf57ce3fe11b3f31d7f68055f34708d74eb +README.zh.md: b96487d6d67deb8a0a6ddf4a92784f31dafb3e89 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index a3c9f21469..5e3a6cf57c 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -2,19 +2,19 @@ English | [中文](README.zh.md) -The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, reasoningEffort?, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml). +The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle. -## The default route (`api-gateway` settings section) +## The shared Agent default (`agent-default-model` Settings section) -`{provider, model, reasoningEffort?}` is also the gateway's user-settings section, registered under `api-gateway`: the composition entry is the `base` layer and `settings.yaml` layers the user's own choice over it. `workspaceRoot` is deliberately outside the section — a launcher fact, not a preference. +`ApiProxyService` consumes `ctx.agentDefaultModel`; it does not own a provider/model config or settings section. The shared service registers `{provider, model, reasoningEffort?}` under `agent-default-model`: the base bundle's composition entry is the lower layer and `settings.yaml` layers the user's choice over it. `workspaceRoot` remains ApiProxy config because it is a Host launcher fact, not a model preference. -A session resolves its route from three tiers, re-read on every access rather than seeded once: a selection made in this process, else the session's own latest logged `request/header`, else this default. Re-reading is what makes both directions hold — a session that has run a turn derives its route from its log forever after, so changing the default never retargets it, while a session still blank (New Session reuses one rather than minting another) starts from a default saved after it was created. +A session resolves its model selection from three tiers on every access: a selection made in this process, otherwise the session's latest logged `request/header`, otherwise this default. A session that has run a turn derives its selection from its log, while a blank session observes a default saved after it was created. -`session.selectModel` records an accepted switch as the new default, which is how the default is chosen in practice: there is no separate gesture. What it stores is the RESOLVED target, so an adapter-materialized default effort is pinned as the user saw it and a later adapter-default change does not silently move stored defaults. The write replaces the section wholesale rather than merging, because switching to a model with no reasoning effort has to clear a stored one; a storage failure is logged without undoing the switch, which already applies to its own session. A deployment with no settings provider keeps the composition entry and a switch stays process-local. +`session.selectModel` saves an accepted switch as the deployment default; there is no separate gesture. It stores the resolved `ModelSelection`, including an adapter-materialized default effort. The complete-section write clears a stored effort when the selected model has none. A storage failure is logged without undoing the session selection. A deployment with no settings provider keeps the composition entry and the switch remains session-local. -The section's `reasoningEffort` has no counterpart in the plugin config, deliberately: the seam merges the user layer over the composition entry per field, so an absent key cannot override a present one and a composition-set effort would survive every later switch to a model without one. A deployment default for effort belongs on the adapter profile, which resolves per model. +The section's `reasoningEffort` has no counterpart in the agent-default-model plugin config, deliberately: the seam merges the user layer over the composition entry per field, so an absent key cannot override a present one and a composition-set effort would survive every later switch to a model without one. A deployment default for effort belongs on the adapter profile, which resolves per model. -The stored route is not validated against the registry, in either direction. A default naming a route the Models page has since removed still reaches `session.models` as the session's `current` — matching no advertised group, which is precisely what makes a selector prompt for a replacement instead of naming a model the deployment cannot reach. Repairing it silently would also break the deliberate converse: an adapter may serve a model its catalog does not advertise. +The stored selection is independent of catalog membership. A default naming an unavailable provider still reaches `session.models` as the session's `current`, allowing the selector to request a replacement instead of silently choosing another model. Conversely, an adapter may serve a model that its catalog does not advertise. ## Contract layer (`/api`) @@ -30,9 +30,9 @@ Question responses are validated against their pending request before the first Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`. -`session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged provider/model/reasoning target, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale. +`session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged `ModelSelection`, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale. -Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target separately from provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. The current target may be absent from the groups and is never injected as a synthetic row; clients can prompt for a replacement without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. `session.models` additionally reports `routable`: whether an adapter currently serves the current target's route, which is deliberately NOT derivable from the groups — a route serving a model it stopped advertising is absent from them yet perfectly usable, while a route whose adapter is gone can serve nothing. `session.prompt` refuses on that same fact with `model-unavailable` rather than spending the pre-step path to fail inside an adapter; a client that disables its composer is an affordance, and this method stays callable regardless. +Session model selection is a session-domain contract. `session.models` returns the current `ModelSelection` separately from provider-grouped advisory models, exact-model reasoning metadata, and provider-local lookup failures. The selection may be absent from the groups and is never injected as a synthetic row; clients can prompt for another selection without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and assigns the complete selection for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable provider or unsupported effort returns `model-unavailable`. `session.models` additionally reports `routable`: whether an adapter currently serves the selected provider. This is deliberately not derivable from the groups because an adapter may serve an unadvertised model. `session.prompt` refuses on the same fact with `model-unavailable` before opening a turn; a disabled composer is a client affordance, and the method remains callable. Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events. @@ -52,7 +52,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)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh run` headless. +`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 front door 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 65475351e2..b96487d6d6 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -2,19 +2,19 @@ [English](README.md) | 中文 -所有客户端形态共用的 API 网关:TS 约定(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, reasoningEffort?, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml)。 +所有客户端形态共用的 API 网关:TS 约定(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{workspaceRoot?}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent(智能体)模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。 -## 默认路由(`api-gateway` 设置段) +## 共享 Agent 默认值(`agent-default-model` Settings 分节) -`{provider, model, reasoningEffort?}` 同时是网关的用户设置段,注册在 `api-gateway` 之下:组合条目是 `base` 层,`settings.yaml` 把用户自己的选择叠加其上。`workspaceRoot` 刻意不在段内——它是启动器事实,不是偏好。 +`ApiProxyService` 消费 `ctx.agentDefaultModel`;它不持有提供方/模型配置或 Settings 分节。共享服务在 `agent-default-model` 下注册 `{provider, model, reasoningEffort?}`:base 组合包的组合条目是底层,`settings.yaml` 把用户选择叠加其上。`workspaceRoot` 仍属于 ApiProxy 配置,因为它是 Host 启动器事实,而不是模型偏好。 -会话按三级解析自己的路由,且每次读取都重新解析,而不是只在创建时种一次:本进程内的显式选择,其次是该会话自己最新记录的 `request/header`,最后才是这个默认值。重新解析正是让两个方向都成立的原因——已经跑过一轮的会话此后永远从自己的日志推导路由,改默认值不会重定向它;而仍然空白的会话(新建会话会复用一个,而不是再开一个)则会用上它创建之后才保存的默认值。 +会话每次访问时都按三级解析模型选择:本进程内作出的选择,其次是该会话日志中最新的 `request/header`,最后是这个默认值。已经跑过一轮的会话从自己的日志推导选择,空白会话则能观察到创建之后保存的默认值。 -`session.selectModel` 会把被接受的切换记录为新的默认值,实践中默认值就是这样选定的,没有另一个单独的手势。它存下来的是**解析后**的目标,因此适配器实体化出来的默认推理等级会按用户当时看到的样子钉住,日后适配器改了自己的默认值也不会悄悄移动已存的默认路由。写入是整段替换而非合并,因为切到一个不带推理等级的模型必须清掉已存的等级;存储失败只记日志,不会撤销这次切换——它对自己所在的会话已经生效。没有设置提供方的部署保留组合条目,切换只停留在进程内。 +`session.selectModel` 会把接受的切换保存为部署默认值;没有单独的选择动作。它存储已解析的 `ModelSelection`,包括适配器实体化的默认推理强度。完整分节写入会在所选模型没有推理强度时清除已存值。存储失败只记日志,不会撤销会话选择。没有设置提供方的部署保留组合条目,切换只对当前会话生效。 -设置段里的 `reasoningEffort` 在插件配置中刻意没有对应字段:seam 是按字段把用户层合并到组合条目之上的,缺席的键覆盖不了存在的键,因此组合层设的推理等级会在此后每一次切到不带推理等级的模型时继续存活。推理等级的部署级默认值属于适配器 profile,那里是按模型解析的。 +Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中刻意没有对应字段:seam 按字段把用户层合并到组合条目之上,因此缺席的键无法覆盖已有键,组合层中的推理强度会在以后选择没有推理强度的模型时继续存在。推理强度的部署默认值属于按模型解析的适配器 profile。 -存下来的路由不做注册表校验,两个方向都不做。默认值指向一个已在模型页删除的路由时,它照样作为会话的 `current` 送到 `session.models`——匹配不到任何已公布的分组,而这恰恰是让选择器提示重新选择、而不是显示一个部署根本够不着的模型的原因。静默修复它还会破坏刻意保留的反面情形:适配器可以服务一个自己目录未公布的模型。 +存储的选择独立于目录成员关系。默认值指向不可用的提供方时,它仍会作为会话的 `current` 送到 `session.models`,让选择器请求用户重新选择,而不是静默选用其他模型。反过来,适配器也可以服务其目录中未公布的模型。 ## 约定层(`/api`) @@ -24,15 +24,15 @@ 首个回答认领待处理请求之前,系统会对照该请求校验问题响应。多选题的回答项可以同时携带 `selected` 中的请求选项标签与非空 `custom` 文本;单选题的回答项必须二选一。标签重复、标签未知、id 不匹配、批次不完整以及自定义文本为空都会以 `bad-response` 拒绝。 -`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent(智能体),然后按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。 +`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent,然后按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。 `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 -`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的提供方/模型/推理(reasoning)目标及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由。 +`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的 `ModelSelection` 及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由。 -会话模型路由属于会话领域约定。`session.models` 将选中的提供方/模型/推理目标,与按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录分开返回。当前目标可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户选择替代目标,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。`session.models` 还会报告 `routable`:当前目标的路由是否有适配器在服务。这一点刻意不由分组推导——一条仍在服务、只是不再公布该模型的路由不在分组里,却完全可用;而适配器已经消失的路由什么都服务不了。`session.prompt` 依据同一个事实以 `model-unavailable` 拒绝,而不是把整条 pre-step 路径走完再在适配器内部失败;客户端禁用输入框只是提示性设计,这个方法始终可被调用。 +会话模型选择属于会话领域约定。`session.models` 将当前 `ModelSelection` 与按提供方分组的建议性模型、精确模型的推理(reasoning)元数据和逐提供方查询失败记录分开返回。该选择可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户作出另一项选择,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并指定将在下一提示词组装边界使用的完整选择。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用的提供方或不受支持的推理强度会返回 `model-unavailable`。`session.models` 还会报告 `routable`,即当前是否有适配器为所选提供方提供服务。该值刻意不从分组推导,因为适配器可以服务未公布的模型。`session.prompt` 会依据同一事实,在开启轮次之前以 `model-unavailable` 拒绝;客户端禁用 composer 只是提示性设计,这个方法始终可被调用。 待处理的 queued 输入属于实时控制平面约定,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement,而注入上下文(审批通知、任务完成、附加快照)携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。 @@ -52,7 +52,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr ## 载体层(`/client` + 根路径) -`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh run` headless 模式使用。 +`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,仍是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供需要该路径的调用方和载体测试使用。产品的 `dsh run` 是直连 core 的入口,不挂载本包。 ## 模型体验 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 91d03b4448..511fc25dba 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -39,6 +39,7 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-default-model": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 52236b0f29..01b792353d 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -7,8 +7,9 @@ import { randomUUID } from 'node:crypto' import { mkdir, stat } from 'node:fs/promises' import { join } from 'node:path' import type { Context } from 'cordis' -import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent' +import { installModelSelection } from '@deepseek-ai/dsh-agent' +import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent' +import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model' import { createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { errorChain } from '@deepseek-ai/dsh-llm' import type { MessageSource } from '@deepseek-ai/dsh-llm' @@ -83,14 +84,6 @@ import { openNativePath, openNativeTextFile } from './native-path-opener.ts' /** Page size when history is called without maxMessages. */ const DEFAULT_MAX_MESSAGES = 50 -/** - * The settings namespace carrying the user's default route. Named for the - * gateway rather than for the package, because this key is what a person reads - * and writes in `settings.yaml`; the row id in a composition happens to match - * but does not determine it. - */ -export const API_GATEWAY_SETTINGS_NAMESPACE = settingsNamespace('api-gateway') - /** Non-model settings namespaces intentionally served to the Web client. */ const WEB_SETTINGS_NAMESPACES = ['permission'] as const @@ -152,7 +145,7 @@ function ok(request: RpcRequest, value: T): RpcResponse { /** * Build the provider/model catalog over every registered route. Shared by the * session-scoped `session.models` and host-scoped `llm.models`. Catalog - * membership stays advisory: an unlisted session target remains valid for + * membership stays advisory: an unlisted session selection remains valid for * provider dispatch, but is not injected back into the selector after its * owning catalog stops advertising it. Per-provider failures ride `failures` * without failing the sound groups; groups that advertise nothing are dropped. @@ -345,14 +338,14 @@ function directoryError(error: unknown): RpcError { return { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} } } -/** Resolved Host routing and project-directory defaults consumed by the API implementation. */ +/** Resolved Agent model and project-directory defaults consumed by the API implementation. */ export interface ApiProxyDefaults { /** - * The route a session starts from when its own log names none. Read on + * The model selection a session starts from when its own log names none. Read on * every access rather than captured, so a default saved during this process * reaches the sessions that have not run a turn yet. */ - defaultTarget: () => AgentLlmTarget + defaultModelSelection: () => ModelSelection /** * Record a selection as the new default. Either absent, or a closure that * may itself decline — the gateway plugin always passes one, and it no-ops @@ -361,7 +354,7 @@ export interface ApiProxyDefaults { * reported and swallowed: the switch already applies to its own session, * and undoing it because storage failed would be the worse outcome. */ - persistDefaultTarget?: (target: AgentLlmTarget) => Promise + saveDefaultModelSelection?: (selection: ModelSelection) => Promise /** Default project directory for new sessions whose create request carries no cwd. */ cwd: string /** Parent directory for name-created workspaces. */ @@ -734,17 +727,17 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie /** * Implement ApiProxy over a composed host context. * @param ctx - a context with the Host spine and Workspace registry mounted. - * @param defaults - host routing and project-directory defaults. + * @param defaults - Agent model and project-directory defaults. * @returns the ApiProxy implementation. */ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy { - /** The seed route each create/resume declares; re-read so it never goes stale. */ + /** The seed model each create/resume declares; re-read so it never goes stale. */ const agentOptions = (): AgentOptions => { - const { provider, model } = defaults.defaultTarget() + const { provider, model } = defaults.defaultModelSelection() return { provider, model } } - type WebLlmTargetRef = AgentLlmTargetRef & { current: AgentLlmTarget } - const targets = new WeakMap() + type WebModelSelectionRef = ModelSelectionRef & { current: ModelSelection } + const selections = new WeakMap() /** Client-chosen identity creation/resume, deduplicated across concurrent retries. */ const sessionCreations = new Map>() /** Serializes path ownership and explicit title checks with Workspace mutations. */ @@ -754,29 +747,28 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const muxQueues = new Set>>() /** - * Install or return the session-local target that prompt assembly snapshots. + * Install or return the session-local model selection that prompt assembly snapshots. * * Precedence, resolved on EVERY read rather than seeded once: a selection * made in this process, else the session's own latest logged request/header, - * else the live host default. Re-reading is what keeps the two tiers honest - * in both directions — a session that has run a turn derives its route from - * its log forever after, so changing the default never retargets it; and a - * session still blank (New Session reuses one rather than minting another) - * starts from a default saved after it was created. There is no create-time + * else the live Agent default. Re-reading keeps the two tiers exact in both + * directions: a session with a recorded request derives its selection from + * its log, while a blank session (New Session reuses one rather than minting + * another) reads any default saved after it was created. There is no create-time * per-session override tier on this wire — if one returns (a create-options * contribution), it must fold in between the selection and the log. */ - function targetFor(agent: Agent): WebLlmTargetRef { - const installed = targets.get(agent) + function selectionFor(agent: Agent): WebModelSelectionRef { + const installed = selections.get(agent) if (installed !== undefined) return installed - let picked: AgentLlmTarget | undefined - const target: WebLlmTargetRef = { - get current(): AgentLlmTarget { + let picked: ModelSelection | undefined + const selection: WebModelSelectionRef = { + get current(): ModelSelection { if (picked !== undefined) return picked // Incrementally folded by the session, so a per-step read costs // O(new events) rather than a rescan. const logged = agent.session.requestHeader()?.config - if (logged === undefined) return defaults.defaultTarget() + if (logged === undefined) return defaults.defaultModelSelection() return { provider: logged.provider, model: logged.model, @@ -785,21 +777,21 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro : { reasoningEffort: logged.reasoningEffort }, } }, - set current(next: AgentLlmTarget) { + set current(next: ModelSelection) { picked = next }, assembled: undefined, } - installAgentLlmTarget(agent.ctx, target) - targets.set(agent, target) - return target + installModelSelection(agent.ctx, selection) + selections.set(agent, selection) + return selection } /** Pre-publication setup used by both fresh and resumed Web agents. */ - function installTarget(agentCtx: Context): void { + function installSelection(agentCtx: Context): void { const agent = agentCtx.agent if (agent === undefined) throw new Error('api-proxy: agent setup has no scoped agent') - targetFor(agent) + selectionFor(agent) } const hasSubagentOwner = ( @@ -810,7 +802,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro apiRemoteSubagentOwnershipError(sessionId) const inspectServable = (sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> => inspectApiRemoteSession(ctx, sessionId) - const agentFor = createApiRemoteAgentResolver(ctx, { agentOptions, setup: installTarget }) + const agentFor = createApiRemoteAgentResolver(ctx, { agentOptions, setup: installSelection }) /** Send one transient frame to every connected mux consumer. */ function broadcast(payload: MuxFrame): void { @@ -1076,7 +1068,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return (await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: agentOptions(), - setup: installTarget, + setup: installSelection, })).agent } @@ -1089,7 +1081,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro sessionId, agentOptions: agentOptions(), meta: { cwd }, - setup: installTarget, + setup: installSelection, })).agent })().catch((error: unknown) => { // Another Host entry path may have published the same identity while @@ -1235,10 +1227,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } /** - * Whether an adapter currently serves this route, and therefore whether a - * session pointed at it can start a turn. Catalog membership cannot answer + * Whether an adapter currently serves this provider, and therefore whether + * a session selecting it can start a turn. Catalog membership cannot answer * it: an adapter may serve a model its own catalog stopped advertising, so - * a route missing from the groups is not the same as one nothing serves. + * a provider missing from the groups is not the same as one nothing serves. * A composition with no llm registry at all cannot judge and says yes — * the dispatch it would have refused fails on its own terms. */ @@ -1249,7 +1241,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro /** * Resolve the addressed agent for a turn-starting method and refuse when no - * adapter serves its current route: a route nothing serves cannot start a + * adapter serves its current selection: a provider nothing serves cannot start a * turn, and letting it try spends the whole pre-step path to fail inside * the adapter with a message about registration. Refusing here names the * model the session is pointed at while the draft is still in the composer. @@ -1262,13 +1254,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const found = await agentFor(sessionId) if ('error' in found) return { refused: err(request, found.error) } const agent = found.agent - const target = targetFor(agent).current - if (!routeServed(target.provider)) { + const selection = selectionFor(agent).current + if (!routeServed(selection.provider)) { return { refused: err(request, { code: 'model-unavailable', - message: `no adapter serves provider "${target.provider}"; select a model for this session`, - details: { provider: target.provider, model: target.model }, + message: `no adapter serves provider "${selection.provider}"; select a model for this session`, + details: { provider: selection.provider, model: selection.model }, }), } } @@ -1643,7 +1635,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const { sessionId } = request.payload const found = await agentFor(sessionId) if ('error' in found) return err(request, found.error) - const current = targetFor(found.agent).current + const current = selectionFor(found.agent).current const { groups, failures } = await buildModelCatalog(ctx) const routable = routeServed(current.provider) return ok(request, { current: { ...current }, routable, groups, failures }) @@ -1661,20 +1653,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ? {} : { reasoningEffort: ReasoningEffortId(reasoningEffort) }, }) - const selected: AgentLlmTarget = { + const selected: ModelSelection = { provider: resolved.provider, model: resolved.model, ...resolved.reasoningEffort === undefined ? {} : { reasoningEffort: resolved.reasoningEffort }, } - targetFor(found.agent).current = selected + selectionFor(found.agent).current = selected // A switch is also how this deployment's default is chosen: the next // session created without one of its own starts here. Sessions that - // have already logged a route are unaffected — they derive from - // their own log (see targetFor). + // have already logged a selection are unaffected — they derive from + // their own log (see selectionFor). try { - await defaults.persistDefaultTarget?.(selected) + await defaults.saveDefaultModelSelection?.(selected) } catch (error: unknown) { ctx.logger.warn( `api-proxy: the model switch applies to this session but was not saved as the default: ${String(error)}`, @@ -1783,7 +1775,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro seedLength: cut, }, agentOptions: agentOptions(), - setup: installTarget, + setup: installSelection, }) } catch (error: unknown) { return err(request, { @@ -2192,7 +2184,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro host: { describe(request) { // TODO(step2): version should read apps/cli's package.json; placeholder for now. - const route = defaults.defaultTarget() + const selection = defaults.defaultModelSelection() return Promise.resolve(ok(request, { version: '0.0.1', // Same source as session.create's fallback: the UI's default project @@ -2200,8 +2192,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro cwd: defaults.cwd, // Read live for the same reason: this is what the NEXT session will // start from, so a saved default has to be what it reports. - provider: route.provider, - model: route.model, + provider: selection.provider, + model: selection.model, attachedSessions: ctx.agents.list().length, })) }, @@ -2730,11 +2722,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // A provider's own settings carry its model catalog and endpoint, // so a change there invalidates the model list even when the route // set is untouched — `llm/adapters-updated` alone misses it. The - // gateway's own section is the other such source: it names the - // route every session with no logged one resolves to, so an + // Agent default section is the other such source: it names the + // selection every session with no logged one resolves to, so an // externally edited default (another tab, a hand-edited // settings.yaml) has to reach an open selector too. - if (modelProviderNamespaces().has(name) || name === String(API_GATEWAY_SETTINGS_NAMESPACE)) { + if (modelProviderNamespaces().has(name) || name === String(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE)) { queue.push(frame({ type: 'host/models-changed' })) } }), diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 5537a0e382..8e35c62514 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -37,7 +37,7 @@ export interface ApiProxy { // ---- Domain interfaces and payload entities ---- export type { HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelTarget, QueueAction, SessionModels, SessionProjectionsBlock, SessionSearchItem, + ModelReasoningEffort, ModelSelection, QueueAction, SessionModels, SessionProjectionsBlock, SessionSearchItem, SessionsApi, SessionSummary, } from './sessions.ts' export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts' diff --git a/packages/host/apiproxy/src/api/llm.ts b/packages/host/apiproxy/src/api/llm.ts index 29cda6cf78..01564992d0 100644 --- a/packages/host/apiproxy/src/api/llm.ts +++ b/packages/host/apiproxy/src/api/llm.ts @@ -3,8 +3,8 @@ * surfaces. `llm.providers` merges the configurable-provider directory * (which providers CAN be configured, and where their settings live) with the * live route registry; `llm.models` is the session-independent model catalog - * (the same groups as `session.models`, without the per-session current - * target). Both invalidate on the `host/models-changed` frame. + * (the same groups as `session.models`, without a per-session selection). + * Both invalidate on the `host/models-changed` frame. */ import type { RpcRequest, RpcResponse } from './rpc.ts' diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 80e64fb12a..a1cc88dace 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -12,7 +12,7 @@ import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import type { HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelTarget, SessionProjectionsBlock, SessionSearchItem, SessionSummary, + ModelReasoningEffort, ModelSelection, SessionProjectionsBlock, SessionSearchItem, SessionSummary, } from './sessions.ts' import type { ToolEventView } from './events.ts' import type { WorkspaceId } from './workspace.ts' @@ -140,12 +140,12 @@ export const sessionHistoryRequestSchema = z.object({ maxMessages: z.number().int().positive().optional(), }) satisfies z.ZodType>> -/** Complete provider/model target. */ -export const modelTargetSchema = z.object({ +/** Complete provider/model selection. */ +export const modelSelectionSchema = z.object({ provider: z.string().min(1), model: z.string().min(1), reasoningEffort: z.string().min(1).optional(), -}) satisfies z.ZodType> +}) satisfies z.ZodType> /** One adapter-owned reasoning effort. */ export const modelReasoningEffortSchema = z.object({ @@ -224,7 +224,7 @@ export const sessionModelsRequestSchema = z.object({ /** session.models response value. */ export const sessionModelsValueSchema = z.object({ - current: modelTargetSchema, + current: modelSelectionSchema, routable: z.boolean(), groups: z.array(modelProviderGroupSchema), failures: z.array(modelCatalogFailureSchema), @@ -240,7 +240,7 @@ export const sessionSelectModelRequestSchema = z.object({ /** session.selectModel response value. */ export const sessionSelectModelValueSchema = z.object({ - selected: modelTargetSchema, + selected: modelSelectionSchema, }) satisfies z.ZodType>> /** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */ diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 2e795928ec..a5fc8fffad 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -53,8 +53,8 @@ export interface SessionProjectionsBlock { values: Partial } -/** Complete model target selected for one session. */ -export interface ModelTarget { +/** Complete model selection for one session. */ +export interface ModelSelection { /** Registered provider route. */ provider: string /** Provider-owned model id. */ @@ -115,8 +115,8 @@ export interface ModelCatalogFailure { /** Detached model-directory snapshot for one session. */ export interface SessionModels { - /** Target selected for the session's next assembled step. */ - current: ModelTarget + /** Model selection for the session's next assembled step. */ + current: ModelSelection /** * Whether an adapter currently serves `current.provider`, and therefore * whether this session can start a turn at all. Deliberately NOT derivable @@ -240,7 +240,7 @@ export interface SessionsApi { models(request: RpcRequest<{ sessionId: SessionId }>): Promise> /** - * Selects the complete target for this session. Exact model metadata + * Selects the complete model selection for this session. Exact model metadata * validates an optional reasoning effort, while catalog membership remains * advisory. Session-backed subagents reject with `agent-busy`. */ @@ -250,7 +250,7 @@ export interface SessionsApi { model: string reasoningEffort?: string }>): - Promise> + Promise> /** * Renames a session: appends a `session/title` event with the `user` diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 1a6e0be281..667e85bdac 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -7,28 +7,24 @@ * `ctx.apiProxy`). Transport-agnostic by design: this package registers no * routes — physical carriers wrap `ctx.apiProxy` themselves. * - * The gateway also owns the `api-gateway` settings section: the route a - * session starts from when its own log names none. The composition entry is - * the shipped default and the section layers the user's choice over it, so - * switching models in a conversation is what sets the default for the next - * one. Sessions that have already logged a route are never retargeted by it. + * The gateway consumes `ctx.agentDefaultModel`, the transport-independent default + * shared with direct front doors. Switching models persists through that + * service; sessions that have already logged a selection remain unchanged. */ import { resolve } from 'node:path' import { Context, Service } from 'cordis' import z from 'schemastery' -import type { AgentLlmTarget } from '@deepseek-ai/dsh-agent' -import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' -import { installSettingsSection } from '@deepseek-ai/dsh-settings' +import type {} from '@deepseek-ai/dsh-agent-default-model' import type { ApiProxy } from './api/index.ts' -import { API_GATEWAY_SETTINGS_NAMESPACE, createApiProxy } from './api-proxy.ts' +import { createApiProxy } from './api-proxy.ts' export type * from './api/index.ts' export { RpcId } from './api/rpc.ts' export { toFetchHandler } from './fetch/handler.ts' export { AbstractApiClient, InProcessApiClient } from './fetch/client.ts' export type { IApiClient } from './fetch/client.ts' -export { API_GATEWAY_SETTINGS_NAMESPACE, createApiProxy } from './api-proxy.ts' +export { createApiProxy } from './api-proxy.ts' export type { ApiProxyDefaults } from './api-proxy.ts' declare module 'cordis' { @@ -38,62 +34,12 @@ declare module 'cordis' { } } -/** - * The `api-gateway` settings section: the route a session starts from when its - * own log names none. `workspaceRoot` is deliberately not part of it — that is - * a launcher fact, not a preference. - */ -export interface DefaultRouteSettings { - /** Default provider route for created agents. */ - provider: string - /** Default model id. */ - model: string - /** Default reasoning effort; absence preserves the adapter/provider default. */ - reasoningEffort?: string -} - -/** - * Gateway plugin config: host-level agent routing and Workspace creation root. - * - * `reasoningEffort` is deliberately absent, so the section carries one field - * the composition cannot. The seam resolves a section by MERGING the user - * layer over the composition entry per field, and an absent key cannot - * override a present one — so a composition-set effort would survive every - * later switch to a model that has none, and strand it for the next session - * to fail on. Effort is a per-model fact anyway: a deployment default belongs - * on the adapter profile (`llm-pi-ai`'s `reasoning`, `llm-deepseek`'s own), - * which resolves per model rather than per gateway. - */ +/** Gateway plugin config: the Host-only Workspace creation root. */ export interface Config { - /** Default provider route for created agents. */ - provider: string - /** Default model id. */ - model: string /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ workspaceRoot?: string } -/** - * Schema of the `api-gateway` section, exported because it IS that section's - * contract — the shape anything reading or writing `settings.yaml` addresses. - */ -export const DEFAULT_ROUTE_SCHEMA: z = z.object({ - provider: z.string().required(), - model: z.string().required(), - reasoningEffort: z.string(), -}) - -/** Project the stored/composed section onto the agent-facing target shape. */ -function routeTarget(settings: DefaultRouteSettings): AgentLlmTarget { - return { - provider: settings.provider, - model: settings.model, - ...settings.reasoningEffort === undefined - ? {} - : { reasoningEffort: ReasoningEffortId(settings.reasoningEffort) }, - } -} - /** * The API gateway service: implements the ApiProxy contract over the composed * host context and provides it as `ctx.apiProxy`. The Host cwd is the default @@ -101,13 +47,11 @@ function routeTarget(settings: DefaultRouteSettings): AgentLlmTarget { */ export class ApiProxyService extends Service implements ApiProxy { static inject = [ - 'agents', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery', + 'agentDefaultModel', 'agents', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery', 'tools', 'userInteraction', 'workspace', ] static Config: z = z.object({ - provider: z.string().required(), - model: z.string().required(), workspaceRoot: z.string(), }) @@ -127,30 +71,9 @@ export class ApiProxyService extends Service implements ApiProxy { constructor(ctx: Context, config: Config) { super(ctx, 'apiProxy') const cwd = process.cwd() - // The composition entry is the shipped default; the settings section - // layers the user's own choice over it, and a deployment without a - // settings provider simply keeps the entry. - const entry: DefaultRouteSettings = { provider: config.provider, model: config.model } - let route: () => DefaultRouteSettings = () => entry - installSettingsSection(ctx, API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA, entry, { - setSource: (current) => { - route = current - }, - // Nothing registration-level derives from the default: every consumer - // reads it through the thunk at the moment it needs a route. - onChange: () => {}, - }) const api = createApiProxy(ctx, { - defaultTarget: () => routeTarget(route()), - // Wholesale, never a merge: switching to a model with no reasoning - // effort must clear a stored one, and a merged patch would strand it - // for the next session to fail on. This clears it because the entry - // below the user layer carries no effort to re-inherit — the reason - // `Config` deliberately has no such field. The section holds no - // secrets, so there is nothing a replace can collaterally drop. - persistDefaultTarget: async (target) => { - await ctx.get('settings')?.replace(API_GATEWAY_SETTINGS_NAMESPACE, target) - }, + defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(), + saveDefaultModelSelection: selection => ctx.agentDefaultModel.saveSelection(selection), cwd, workspaceRoot: resolve(config.workspaceRoot ?? cwd), }) diff --git a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts index e6555898cd..2272c7c61d 100644 --- a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts @@ -27,7 +27,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { await ctx.plugin(UserInteractionService) await ctx.plugin(AgentRegistry) await ctx.plugin(ApprovalService) - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) return { ctx, api } } @@ -217,7 +217,7 @@ describe('approval pending registry', () => { await ctx.plugin(ApprovalService) let api!: ApiProxy const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => { - api = createApiProxy(fiberCtx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + api = createApiProxy(fiberCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) }, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] })) await fiber.await() const abort = new AbortController() diff --git a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts index 4943c051bb..ed2eaeefc5 100644 --- a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts @@ -35,7 +35,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy; attach: (sessio await ctx.plugin(AgentRegistry) return { ctx, - api: createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }), + api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }), attach: (session) => { ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) }, diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 8e79c641a8..be76bf4cc6 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -64,7 +64,7 @@ describe('sessions.list cold merge', () => { return undefined }, }) - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const response = await api.sessions.list(request({})) expect(response.result.ok).toBe(true) @@ -92,7 +92,7 @@ describe('attached updatedAt excludes end-seed', () => { await ctx.plugin(SessionStore) await ctx.plugin(UserInteractionService) await ctx.plugin(AgentRegistry) - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) // Old work, resumed just now: the log tail would report the pickup. const worked = 1_000_000 @@ -150,7 +150,7 @@ describe('cold history recovery view', () => { inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal), locate: () => undefined, } as never) - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const history = await api.sessions.history(request({ sessionId, beforeSeq: 2, maxMessages: 10 })) if (!history.result.ok) throw new Error('history failed') @@ -206,7 +206,7 @@ describe('Remote Agent and Session lookup policy', () => { }) const defaultAgentLookup = ctx.typert.lookups.get('agent') const defaultSessionLookup = ctx.typert.lookups.get('session') - createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) await vi.waitFor(() => { expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) @@ -250,7 +250,7 @@ describe('Remote Agent and Session lookup policy', () => { const resume = vi.spyOn(ctx.agents, 'resume') const defaultAgentLookup = ctx.typert.lookups.get('agent') const defaultSessionLookup = ctx.typert.lookups.get('session') - createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) await vi.waitFor(() => { expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) @@ -312,7 +312,7 @@ describe('subagent ownership fence', () => { locate: () => undefined, } as never) const resume = vi.spyOn(ctx.agents, 'resume') - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const history = await api.sessions.history(request({ sessionId })) expect(history.result.ok).toBe(true) @@ -371,7 +371,7 @@ describe('subagent ownership fence', () => { // instead of answering `agent-busy`. const resume = vi.spyOn(ctx.agents, 'resume') .mockRejectedValue(new Error('registry unavailable in this bench')) - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const prompt = await api.sessions.prompt(request({ sessionId, @@ -412,7 +412,7 @@ describe('subagent ownership fence', () => { }) const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent ctx.agents.enter(startingChild, parent) - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const stopped = await api.sessions.cancel(request({ sessionId: originChild.id })) expect(stopped.result.ok).toBe(false) @@ -458,7 +458,7 @@ describe('subagent ownership fence', () => { const followup = vi.fn() const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent ctx.agents.register(agent) - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const response = await api.sessions.prompt(request({ sessionId: agent.id, @@ -476,7 +476,7 @@ describe('degenerate composition (no persistence, no factory)', () => { await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const listed = await api.sessions.list(request({})) expect(listed.result.ok).toBe(true) @@ -501,7 +501,7 @@ describe('degenerate composition (no persistence, no factory)', () => { list: () => Promise.resolve([]), inspect, } as never) - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const response = await api.sessions.history(request({ sessionId: sid('session-missing') })) expect(response.result.ok).toBe(false) @@ -527,7 +527,7 @@ describe('sessions.prompt synchronous rejection', () => { followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, } as unknown as Agent) - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) for (const mode of ['queue', 'steer'] as const) { const response = await api.sessions.prompt(request({ @@ -571,7 +571,7 @@ describe('sessions.prompt synchronous rejection', () => { ctx.agents.register(child) throw new Error('session id already published') }) - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const models = await api.sessions.models(request({ sessionId })) expect(models.result.ok).toBe(false) diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 09526c5a87..b44701fca0 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -25,7 +25,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' import { createApiProxy } from '../src/api-proxy.ts' -const DEFAULTS = { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' } +const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' } function request

(payload: P): RpcRequest

{ return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload } diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 86a77f4af2..0beb7455cf 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -22,9 +22,10 @@ import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepsee import type { HostFrame } from '../src/api/index.ts' import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' -import { API_GATEWAY_SETTINGS_NAMESPACE, createApiProxy } from '../src/api-proxy.ts' +import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model' +import { createApiProxy } from '../src/api-proxy.ts' -const DEFAULTS = { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' } +const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' } let nextRpc = 1 function request

(payload: P): RpcRequest

{ @@ -398,21 +399,21 @@ describe('settings domain', () => { expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'permission' }]) }) - it('invalidates the model catalog when the gateway default route changes', async () => { + it('invalidates the model catalog when the Agent default selection changes', async () => { const ctx = await harness() - const route = ctx.settings.register(API_GATEWAY_SETTINGS_NAMESPACE, z.object({ + const defaultModel = ctx.settings.register(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, z.object({ provider: z.string().required(), model: z.string().required(), }), { base: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }) const api = createApiProxy(ctx, DEFAULTS) - // The gateway's own section names the route every session with no logged - // one resolves to, so an externally edited default — another tab, a + // The shared section names the selection every blank session resolves to, + // so an externally edited default — another tab, a // hand-edited settings.yaml — has to reach an open selector as well. const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 2, async () => { - await route.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) + await defaultModel.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) }) expect(frames).toEqual([ - { type: 'host/settings-changed', ns: 'api-gateway' }, + { type: 'host/settings-changed', ns: 'agent-default-model' }, { type: 'host/models-changed' }, ]) }) diff --git a/packages/host/apiproxy/tests/api-proxy-default-route.spec.ts b/packages/host/apiproxy/tests/api-proxy-default-route.spec.ts deleted file mode 100644 index 996cea5da2..0000000000 --- a/packages/host/apiproxy/tests/api-proxy-default-route.spec.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * The `api-gateway` settings section over a REAL settings provider: the - * composition entry as the base layer, the wholesale replace the gateway - * persists with, and the fallback when the provider detaches. The other model - * specs drive hand-rolled `defaultTarget`/`persistDefaultTarget` closures, so - * this is the only place the layering itself is exercised. - */ - -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import { Settings, installSettingsSection } from '@deepseek-ai/dsh-settings' -import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' -import { API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA } from '../src/index.ts' -import type { DefaultRouteSettings } from '../src/index.ts' - -/** The smallest real provider: one in-memory document, always writable. */ -class MemorySettings extends Settings { - doc: Record = {} - - get writable(): boolean { - return true - } - - protected load(): Promise> { - return Promise.resolve(structuredClone(this.doc)) - } - - protected persist(ns: SettingsNamespace, section: Record): Promise { - this.doc = { ...this.doc, [ns]: structuredClone(section) } - return Promise.resolve() - } -} - -/** Mount the gateway's own section wiring over a live provider. */ -async function boot(entry: DefaultRouteSettings) { - const ctx = new Context() - const fiber = ctx.plugin(MemorySettings) - await fiber.await() - let route: () => DefaultRouteSettings = () => entry - const consumer = ctx.plugin(function section(child: Context) { - installSettingsSection(child, API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA, entry, { - setSource: (current) => { route = current }, - onChange: () => {}, - }) - }) - await consumer.await() - const settings = ctx.get('settings') - if (settings === undefined) throw new Error('settings provider did not mount') - return { ctx, fiber, consumer, settings, read: () => route() } -} - -describe('the api-gateway default-route section', () => { - it('resolves the composition entry until the user layer overrides it', async () => { - const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) - expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) - - await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, { - provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high', - }) - expect(bench.read()).toEqual({ - provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high', - }) - await bench.ctx.fiber.dispose() - }) - - it('clears a stored effort when the next switch has none', async () => { - const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) - await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, { - provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high', - }) - expect(bench.read().reasoningEffort).toBe('high') - - // The whole reason the gateway persists with `replace` rather than a merge - // patch — and the reason `Config` carries no effort for the base layer to - // re-inherit here. A stranded effort would fail the next session's first - // request against a model that does not support it. - await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, { - provider: 'acme-gateway', model: 'acme-plain', - }) - expect(bench.read()).toEqual({ provider: 'acme-gateway', model: 'acme-plain' }) - await bench.ctx.fiber.dispose() - }) - - it('layers a hand-written partial section over the entry', async () => { - const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) - // Someone editing settings.yaml by hand may name only the model. The - // entry supplies the provider, which is what makes this legal — and is - // exactly why an effort in the entry could never be cleared, so there - // is none to inherit. - await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, { model: 'deepseek-reasoner' }) - expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) - await bench.ctx.fiber.dispose() - }) - - it('falls back to the composition entry when the provider detaches', async () => { - const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) - await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, { - provider: 'acme-gateway', model: 'acme-large', - }) - expect(bench.read().provider).toBe('acme-gateway') - - // A deployment that loses its settings provider keeps serving the route it - // was composed with rather than the one it can no longer read. - await bench.fiber.dispose() - expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) - await bench.ctx.fiber.dispose() - }) -}) diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts index fb6f8cdfed..bc1f0a14df 100644 --- a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts @@ -82,7 +82,7 @@ function liveAgent( } const api = (ctx: Context) => createApiProxy(ctx, { - defaultTarget: () => ({ provider: 'default-provider', model: 'default-model' }), + defaultModelSelection: () => ({ provider: 'default-provider', model: 'default-model' }), cwd: '/tmp', workspaceRoot: '/tmp', }) @@ -254,7 +254,7 @@ describe('sessions.fork', () => { await ctx.fiber.dispose() }) - it('installs the latest logged model target before the child can run', async () => { + it('installs the latest logged model selection before the child can run', async () => { const ctx = await composed() const source = liveAgent(ctx, 'session-routed', 1) source.append('request/header', { diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 818260504c..83d0fa3916 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -1,6 +1,6 @@ /** * Web session model-directory and selection behavior: dynamic provider grouping, - * provider-local catalog failures, logged-target restoration without stale + * provider-local catalog failures, logged-selection restoration without stale * catalog injection, advisory pass-through models, and the prompt-assembly * boundary for a running selection change. */ @@ -119,13 +119,13 @@ function expectValue(response: { result: { ok: true; value: T } | { ok: false } describe('Web session model selection', () => { - it('groups successful providers and leaves an unlisted current target out of the catalog', async () => { + it('groups successful providers and leaves an unlisted current selection out of the catalog', async () => { const { ctx, sessionId } = await harness({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: ReasoningEffortId('max'), }) - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const catalog = expectValue(await api.sessions.models(request({ sessionId }))) expect(catalog.current).toEqual({ @@ -160,7 +160,7 @@ describe('Web session model selection', () => { it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => { const { ctx, agent, sessionId } = await harness() - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } const signal = new AbortController().signal @@ -226,11 +226,11 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) - it('reads the host default live for a session whose log names no route', async () => { + it('reads the Agent default live for a session whose log names no selection', async () => { const { ctx, sessionId } = await harness() let stored = { provider: 'deepseek-official', model: 'deepseek-chat' } const api = createApiProxy(ctx, { - defaultTarget: () => stored, + defaultModelSelection: () => stored, cwd: '/tmp', workspaceRoot: '/tmp', }) @@ -248,14 +248,14 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) - it('keeps a session that logged a route on it when the host default moves', async () => { + it('keeps a session on its logged selection when the Agent default differs', async () => { const { ctx, sessionId } = await harness({ provider: 'deepseek-official', model: 'deepseek-chat', }) let stored = { provider: 'deepseek-official', model: 'deepseek-chat' } const api = createApiProxy(ctx, { - defaultTarget: () => stored, + defaultModelSelection: () => stored, cwd: '/tmp', workspaceRoot: '/tmp', }) @@ -271,9 +271,9 @@ describe('Web session model selection', () => { const saved: unknown[] = [] let reject = false const api = createApiProxy(ctx, { - defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), - persistDefaultTarget: (target) => { - saved.push(target) + defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), + saveDefaultModelSelection: (selection) => { + saved.push(selection) return reject ? Promise.reject(new Error('read-only document')) : Promise.resolve() }, cwd: '/tmp', @@ -306,7 +306,7 @@ describe('Web session model selection', () => { it('refuses a prompt no adapter can route, and reports it on the directory', async () => { const { ctx, sessionId } = await harness() const api = createApiProxy(ctx, { - defaultTarget: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }), + defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }), cwd: '/tmp', workspaceRoot: '/tmp', }) @@ -339,7 +339,7 @@ describe('Web session model selection', () => { const api = createApiProxy(ctx, { // What a Models-page removal leaves behind: the settings document still // names the route the user last picked, and nothing serves it. - defaultTarget: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }), + defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }), cwd: '/tmp', workspaceRoot: '/tmp', }) diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index c9cb212004..ddc558fefd 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts @@ -68,7 +68,7 @@ function seedMessages(session: Session, count: number): void { } } -const api = (ctx: Context) => createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) +const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) describe('session.history projections block', () => { it('serves the unit value on the tail page with asOfSeq = last event seq', async () => { diff --git a/packages/host/apiproxy/tests/api-proxy-question.spec.ts b/packages/host/apiproxy/tests/api-proxy-question.spec.ts index 32835f5054..00f7fdfffe 100644 --- a/packages/host/apiproxy/tests/api-proxy-question.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-question.spec.ts @@ -14,7 +14,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { await ctx.plugin(UserInteractionService) return { ctx, - api: createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }), + api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }), } } diff --git a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts index 2f93cdd9b3..9d5ddb09fd 100644 --- a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts @@ -68,7 +68,7 @@ function liveAgent(ctx: Context, id: string, turns: number): Session { return session } -const api = (ctx: Context) => createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) +const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) describe('sessions.rename', () => { it('accepts through the composed title service: normalized user-source event, echoed seq', async () => { diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 15a4ae3bf3..0220f4804b 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -27,7 +27,7 @@ vi.mock('node:fs/promises', async (importOriginal) => { }) const sid = (value: string): SessionId => value as SessionId -const defaults = { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' } +const defaults = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' } function request(query: string): RpcRequest<{ query: string }> { return { rpcId: RpcId(`search-${query}`), payload: { query } } diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index 62c7e653e0..7542d328ba 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -95,7 +95,7 @@ function bench(options: { ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} }) ctx.provide('userInteraction', { registerProvider: () => () => {} }) const api = createApiProxy(ctx, { - defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp', + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp', }) return { api, getAgent, listChildren, inspect, snapshot, restore, followup, interrupt, parent } } diff --git a/packages/host/apiproxy/tests/api-proxy-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-view.spec.ts index 4490c71bc2..9810ff5cb8 100644 --- a/packages/host/apiproxy/tests/api-proxy-view.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-view.spec.ts @@ -105,7 +105,7 @@ async function collect(iterable: AsyncIterable>, count: num describe('mux live view computation', () => { it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal) const collected = collect(stream, 9, abort) @@ -170,7 +170,7 @@ describe('mux live view computation', () => { it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const session = ctx.sessions.create() // history resolves the agent first; a live structural stub is enough (only // .session is read on this path). @@ -238,7 +238,7 @@ describe('mux live view computation', () => { it('counts only append-origin messages toward maxMessages and keeps compaction provenance whole', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const session = ctx.sessions.create() ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) session.append('turn/start', { turn: 1 }) @@ -287,7 +287,7 @@ describe('mux live view computation', () => { it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal) @@ -308,7 +308,7 @@ describe('mux live view computation', () => { it('pairs a result after turn/end via the in-memory backscan fallback', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal) const collected = collect(stream, 4, abort) diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index aa560bdf58..637c2fcbe0 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -100,7 +100,7 @@ async function harness( // object per harness mirrors the seam's stability contract. ctx.provide('directoryPicker', { capability: () => picker } as never) const api = createApiProxy(ctx, { - defaultTarget: () => ({ provider: 'test', model: 'test-model' }), + defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), cwd: workspaceRoot, workspaceRoot, ...extras.openPath === undefined ? {} : { openPath: extras.openPath }, diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 476ebf344f..ad95275e38 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -41,7 +41,7 @@ function scriptedApi(overrides: { history: r => ok(r, { events: [], hasMore: false, - modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, }), models: r => ok(r, { current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 75b6dff3f7..6d2ae5b23c 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -192,7 +192,7 @@ describe('sessions domain schemas', () => { expect(sessionHistoryValueSchema.parse({ events: [], hasMore: false, - modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, }).hasMore).toBe(false) expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') expect(sessionModelsValueSchema.parse({ diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 64d7944892..eb22348935 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -35,6 +35,9 @@ { "path": "../../core/agent" }, + { + "path": "../../core/agent-default-model" + }, { "path": "../../core/session" }, diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index e8381a56a6..302fd4c9b3 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -62,6 +62,20 @@ export interface TypeApiEntry { /** Every harness `ctx.` service, sorted by key. */ export const SERVICE_API: readonly ServiceApiEntry[] = [ + { + key: 'agentDefaultModel', + summary: 'Owns the default model selection independently of any Host or transport.', + methods: [ + { + signature: 'currentSelection(): ModelSelection', + jsDoc: '/**\n * Read the current default model selection.\n * @returns a detached provider, model, and optional reasoning selection.\n */', + }, + { + signature: 'async saveSelection(next: ModelSelection): Promise', + jsDoc: '/**\n * Save the complete default model selection. A deployment without a settings\n * provider keeps its composition entry.\n * @param next - resolved selection accepted by a front door.\n * @returns fulfillment after the optional settings write settles.\n */', + }, + ], + }, { key: 'agentLoop', summary: 'Concrete agent factory and driver service.', @@ -2233,6 +2247,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ModelMessageSource', declaration: 'export interface ModelMessageSource extends AssistantProvenance {\n kind: \'model\';\n}', }, + { + name: 'ModelSelection', + declaration: 'export interface ModelSelection {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n}', + }, { name: 'ObjectJsonSchema', declaration: 'export type ObjectJsonSchema = JsonSchemaNode & {\n type: \'object\';\n};', diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts index 08cb7d3216..d6d459a4d7 100644 --- a/packages/todo/tool-todo/tests/projection.spec.ts +++ b/packages/todo/tool-todo/tests/projection.spec.ts @@ -45,7 +45,7 @@ async function harness(withTodoTool: boolean): Promise { if (withTodoTool) await ctx.plugin(ToolTodo, { allowParallelInProgress: true }) const session = ctx.sessions.create() ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) return { ctx, session, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9726e9bee..bd2eab469c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1013,6 +1013,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-default-model': + specifier: workspace:^ + version: link:../../core/agent-default-model '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop @@ -1233,6 +1236,9 @@ importers: packages/bundle/headless: dependencies: + '@deepseek-ai/dsh-code-runtime-worker': + specifier: workspace:^ + version: link:../../code-runtime/code-runtime-worker schemastery: specifier: ^3.18.0 version: link:../../../vendor/schemastery @@ -1243,15 +1249,15 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-host-apiproxy': + '@deepseek-ai/dsh-agent-default-model': specifier: workspace:^ - version: link:../../host/apiproxy - '@deepseek-ai/dsh-host-webserver': - specifier: workspace:^ - version: link:../../host/webserver + version: link:../../core/agent-default-model '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2935,6 +2941,28 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/core/agent-default-model: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/core/agent-loop: dependencies: schemastery: @@ -3893,6 +3921,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-default-model': + specifier: workspace:^ + version: link:../../core/agent-default-model '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 66b3cea813..de53d44755 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -44,6 +44,7 @@ export { REGION_BEGIN, REGION_END } */ export const SERVICE_PAGE: Record = { agentLoop: 'core.md', + agentDefaultModel: 'core.md', agents: 'core.md', approval: 'approval.md', bash: 'bash.md', @@ -196,6 +197,7 @@ export const LINK_MAP: Readonly> = { AgentCancelCause: 'core.md', AgentFactory: 'core.md', AgentHandle: 'core.md', + ModelSelection: 'core.md', AgentOptions: 'core.md', AgentStatus: 'core.md', ContentBlock: 'llm-streaming.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index cc26b6b0af..745bc23e4f 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -307,6 +307,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['agent-loop', 'acp', 'subagent-inprocess'], note: 'Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation.', }, + { + key: 'agentDefaultModel', + pkg: 'agent-default-model', + title: 'Default Agent model selection', + mode: 'core', + consumers: ['headless', 'host-apiproxy'], + note: 'Layers the default ModelSelection through settings so direct and Host-backed Agent front doors share one state owner.', + }, { key: 'agentLoop', pkg: 'agent-loop', diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index f25007786c..ded84487bf 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -4,7 +4,7 @@ "messages": [ { "role": "system", - "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Translate link text; do not change link targets.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing ``.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering idiom over literal renderings, and localize metaphors instead of transplanting them.\n- Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences where the target language needs a pause. Avoid run-on sentences.\n- Use active voice when it improves clarity without changing or inventing the actor. Retain passive voice when the actor is unknown, irrelevant, or intentionally omitted.\n- Restructure source-language syntax into clear target-language syntax. Preserve the logical scope of conditions, concessions, negation, coordination, and modifiers.\n- Split or combine clauses when needed for readability, provided every source relationship remains explicit.\n- Translate meaning, not words. Do not invent words or expressions that a native technical author would not use.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Translate ordinary prose when an established target-language expression is clear. Preserve proper nouns, canonical product names, code identifiers, APIs, paths, package names, and terms that the terminology table requires to remain in the source language.\n- Use context to resolve polysemous words. A familiar word does not have one fixed rendering in every technical domain.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate distinct source-language concepts when their distinction matters.\n- Avoid repeating the same ordinary verb in close proximity when a natural equivalent preserves the exact meaning. Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety.\n\n#### When translating into Chinese\n- When a number modifies a noun, include a natural Chinese classifier or measure word when Chinese grammar requires one. For example: \"three-package seam\" → \"由三个包构成的 seam\", not \"三包 seam\". Do not add classifiers to code, identifiers, versions, units, or fixed names.\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in Chinese prose: `,。:;?!()「」`. Keep half-width punctuation inside code spans, numbers, and complete verbatim English text.\n- Prefer colons, periods, commas, or parentheses over em dashes when they make the sentence clearer or more natural. Keep an em dash when it is the clearest natural punctuation.\n- Use enumeration commas (、) between parallel Chinese items, not regular commas.\n- Keep list-item endings consistent with their grammar. Complete sentences may end with periods or other grammatically required punctuation; do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words or numerals. Do not add a space next to full-width punctuation, and do not leave a meaningless half-width space between two Chinese characters.\n- Markdown emphasis markers do not create a word boundary. Determine spacing from the rendered adjacent characters: Chinese next to Chinese takes no space, while Chinese next to a Latin word or numeral takes one half-width space.\n- Use half-width digits and Latin letters, never full-width forms.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以), preserve the SOURCE emphasis span exactly, and do not weaken its normative strength: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text.\n- Convert enumeration commas (、) to English commas and Chinese prose quotation marks to English double quotes.\n- Convert Chinese topic-comment sentences and omitted-subject constructions into clear English subjects when the actor is stated or unambiguously implied. Do not invent an actor.\n- Use concise professional developer prose and established English technical terms. Do not transliterate Chinese engineering idioms literally.\n- Use the terminology table's English column exactly and do not carry Chinese first-occurrence glosses into English prose.\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On the document's first prose occurrence, write the \"首次出现\" value when one is specified; on later occurrences, write only the part before the parenthetical gloss.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- Code spans and other protected tokens remain verbatim even when their text resembles a listed term.\n- For an unlisted technical term, use an established target-language technical term when its meaning is unambiguous in context. For a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source; if you cannot reliably determine such a rendering, preserve the source term and record `[Terminology: pending]` in `` with a tentative rendering for human review. For an English target, use the established English technical term; if the source term has no unambiguous established equivalent, preserve it with the shortest English gloss needed to make it intelligible and record `[Terminology: pending]` in ``. A tentative rendering may appear in `` but must not be silently adopted in `` or ``, and you must not invent or claim a specific external precedent. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | | 智能体注记、智能体笔记 | 仓库定义的文档类型,涵盖提案、已实现决策和被否决提案;中文对侧 H1 保持固定前缀 `# Agent Note: `,标题中不加术语括注 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| KV Cache | KV Cache | | | 专有技术名称,保持大小写与空格 |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 本仓库的命名架构概念,正文保留英文;与 `extension point` 是不同概念 |\n| skill | skill | skill(技能) | | |\n| slot | slot | | 坑位、孔位 | 客户端架构中的具名可注册位置,保留英文 |\n| spill | spill | | | 工具输出超限落盘机制;组合词写 `spill 文件`、`spill 路径` |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器约定 | 适配器约定(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| capability | 能力 | | | 必须与 `feature` → `功能` 区分 |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库接口、实现与消费方分离的命名架构概念;普通 `seam` 仍按其词条处理 |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| configurable-provider directory | 可配置提供方目录 | | | llm seam 中 `registerConfigurableProviders()` 维护的目录;沿用 Service Catalog →「服务目录」先例 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 约定 | | | 如:`pairing contract` →`配对约定` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| fold | 折叠区 | | | 配置界面语境:默认收起的字段分区(collapsed →「收起」)|\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 |\n| sidecar file | 伴随文件 | | | 指与文档同目录的普通伴随文件 |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nReturn exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required shape; do not reproduce the fence.\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of ``, ``, ``, ``, ``, or ``, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n\n(First pass: the complete translation, written as natural target-language technical prose)\n\n\n\n(Second pass: actual corrections only, one correction per line with a category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- [Terminology: pending] source term → tentative rendering\n- 无修正\n\n\n\n(Complete final translation after corrections)\n\n```\n\n## Self-Review Instructions\n\nAfter writing ``, verify it in two directions. First re-read it in the target language only, without looking at the source; awkward phrasing is easier to notice without source-language anchoring. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing ``; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections.\n\n**Structure**\n- Is the heading hierarchy and order, list shape and count, ordered-list start, table shape, and code block content identical to the source?\n- Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source?\n- Are inline code spans and machine-readable tokens verbatim?\n- Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one?\n- Are link targets and emphasis spans preserved?\n- Does spacing across emphasis boundaries follow the same Chinese/Latin/numeral rule as ordinary prose?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Faithfulness**\n- Clause by clause, is anything added, dropped, weakened, strengthened, reversed, merged, or re-bounded? Are list item counts identical on both sides?\n- Do actors, objects, conditions, exceptions, negation, modality, causal relationships, guarantees, contract directions, result channels, ownership changes, and quantities survive exactly?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native technical author?\n- Is there any colloquial, casual, overly informal, promotional, or transplanted metaphorical phrasing?\n- Are actors explicit where the target language needs them, without inventing responsibility?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that can safely become active, or active constructions that invent an actor?\n- Are conditions, concessions, negation, coordination, and modifiers scoped clearly?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Are ordinary prose words left untranslated despite an established target-language expression?\n- Does each polysemous word fit its local context?\n- Is the same target-language word used for distinct source concepts, or is a defined term varied merely to avoid repetition?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied to the true first prose occurrence, neither missing nor repeated? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- Do protected tokens remain untouched even when they resemble terminology entries?\n- For an unlisted term, does a Chinese target use an established Chinese rendering or preserve the source term as pending when no reliable rendering is known, and does an English target use the established English technical term or preserve only an ambiguous source term with the shortest necessary gloss and a pending notice?\n\n**Punctuation** (when target is Chinese)\n- Are punctuation, mixed-script spacing, quotation marks, Latin letters, and digits in their required forms?\n- Are there em dashes that make the sentence less clear and should be replaced, while natural em dashes remain intact?\n- Are list-item endings grammatically consistent, with none ending in commas?\n- Do RFC 2119 keywords preserve the source emphasis span and normative strength exactly?\n\nRecord actual corrections in ``, then output the corrected complete document in ``. If no correction or pending terminology notice is needed, write exactly `- 无修正` in `` and copy `` unchanged into ``. If `` contains only pending terminology notices, copy `` unchanged into ``.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions within the rule each example illustrates; examples do not override source context or higher-priority requirements.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to hear without the source anchoring you`\n- Bad: `没有源文锚着,别扭的表述更容易被听出来`\n- Good: `不对照原文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" + "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Translate link text; do not change link targets.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing ``.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering idiom over literal renderings, and localize metaphors instead of transplanting them.\n- Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences where the target language needs a pause. Avoid run-on sentences.\n- Use active voice when it improves clarity without changing or inventing the actor. Retain passive voice when the actor is unknown, irrelevant, or intentionally omitted.\n- Restructure source-language syntax into clear target-language syntax. Preserve the logical scope of conditions, concessions, negation, coordination, and modifiers.\n- Split or combine clauses when needed for readability, provided every source relationship remains explicit.\n- Translate meaning, not words. Do not invent words or expressions that a native technical author would not use.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Translate ordinary prose when an established target-language expression is clear. Preserve proper nouns, canonical product names, code identifiers, APIs, paths, package names, and terms that the terminology table requires to remain in the source language.\n- Use context to resolve polysemous words. A familiar word does not have one fixed rendering in every technical domain.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate distinct source-language concepts when their distinction matters.\n- Avoid repeating the same ordinary verb in close proximity when a natural equivalent preserves the exact meaning. Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety.\n\n#### When translating into Chinese\n- When a number modifies a noun, include a natural Chinese classifier or measure word when Chinese grammar requires one. For example: \"three-package seam\" → \"由三个包构成的 seam\", not \"三包 seam\". Do not add classifiers to code, identifiers, versions, units, or fixed names.\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in Chinese prose: `,。:;?!()「」`. Keep half-width punctuation inside code spans, numbers, and complete verbatim English text.\n- Prefer colons, periods, commas, or parentheses over em dashes when they make the sentence clearer or more natural. Keep an em dash when it is the clearest natural punctuation.\n- Use enumeration commas (、) between parallel Chinese items, not regular commas.\n- Keep list-item endings consistent with their grammar. Complete sentences may end with periods or other grammatically required punctuation; do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words or numerals. Do not add a space next to full-width punctuation, and do not leave a meaningless half-width space between two Chinese characters.\n- Markdown emphasis markers do not create a word boundary. Determine spacing from the rendered adjacent characters: Chinese next to Chinese takes no space, while Chinese next to a Latin word or numeral takes one half-width space.\n- Use half-width digits and Latin letters, never full-width forms.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以), preserve the SOURCE emphasis span exactly, and do not weaken its normative strength: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text.\n- Convert enumeration commas (、) to English commas and Chinese prose quotation marks to English double quotes.\n- Convert Chinese topic-comment sentences and omitted-subject constructions into clear English subjects when the actor is stated or unambiguously implied. Do not invent an actor.\n- Use concise professional developer prose and established English technical terms. Do not transliterate Chinese engineering idioms literally.\n- Use the terminology table's English column exactly and do not carry Chinese first-occurrence glosses into English prose.\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On the document's first prose occurrence, write the \"首次出现\" value when one is specified; on later occurrences, write only the part before the parenthetical gloss.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- Code spans and other protected tokens remain verbatim even when their text resembles a listed term.\n- For an unlisted technical term, use an established target-language technical term when its meaning is unambiguous in context. For a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source; if you cannot reliably determine such a rendering, preserve the source term and record `[Terminology: pending]` in `` with a tentative rendering for human review. For an English target, use the established English technical term; if the source term has no unambiguous established equivalent, preserve it with the shortest English gloss needed to make it intelligible and record `[Terminology: pending]` in ``. A tentative rendering may appear in `` but must not be silently adopted in `` or ``, and you must not invent or claim a specific external precedent. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | | 智能体注记、智能体笔记 | 仓库定义的文档类型,涵盖提案、已实现决策和被否决提案;中文对侧 H1 保持固定前缀 `# Agent Note: `,标题中不加术语括注 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| KV Cache | KV Cache | | | 专有技术名称,保持大小写与空格 |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 本仓库的命名架构概念,正文保留英文;与 `extension point` 是不同概念 |\n| skill | skill | skill(技能) | | |\n| slot | slot | | 坑位、孔位 | 客户端架构中的具名可注册位置,保留英文 |\n| spill | spill | | | 工具输出超限落盘机制;组合词写 `spill 文件`、`spill 路径` |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器约定 | 适配器约定(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| capability | 能力 | | | 必须与 `feature` → `功能` 区分 |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库接口、实现与消费方分离的命名架构概念;普通 `seam` 仍按其词条处理 |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| configurable-provider directory | 可配置提供方目录 | | | llm seam 中 `registerConfigurableProviders()` 维护的目录;沿用 Service Catalog →「服务目录」先例 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 约定 | | | 如:`pairing contract` →`配对约定` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| fold | 折叠区 | | | 配置界面语境:默认收起的字段分区(collapsed →「收起」)|\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 |\n| sidecar file | 伴随文件 | | | 指与文档同目录的普通伴随文件 |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nReturn exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required shape; do not reproduce the fence.\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of ``, ``, ``, ``, ``, or ``, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n\n(First pass: the complete translation, written as natural target-language technical prose)\n\n\n\n(Second pass: actual corrections only, one correction per line with a category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- [Terminology: pending] source term → tentative rendering\n- 无修正\n\n\n\n(Complete final translation after corrections)\n\n```\n\n## Self-Review Instructions\n\nAfter writing ``, verify it in two directions. First re-read it in the target language only, without looking at the source; awkward phrasing is easier to notice without source-language anchoring. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing ``; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections.\n\n**Structure**\n- Is the heading hierarchy and order, list shape and count, ordered-list start, table shape, and code block content identical to the source?\n- Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source?\n- Are inline code spans and machine-readable tokens verbatim?\n- Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one?\n- Are link targets and emphasis spans preserved?\n- Does spacing across emphasis boundaries follow the same Chinese/Latin/numeral rule as ordinary prose?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Faithfulness**\n- Clause by clause, is anything added, dropped, weakened, strengthened, reversed, merged, or re-bounded? Are list item counts identical on both sides?\n- Do actors, objects, conditions, exceptions, negation, modality, causal relationships, guarantees, contract directions, result channels, ownership changes, and quantities survive exactly?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native technical author?\n- Is there any colloquial, casual, overly informal, promotional, or transplanted metaphorical phrasing?\n- Are actors explicit where the target language needs them, without inventing responsibility?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that can safely become active, or active constructions that invent an actor?\n- Are conditions, concessions, negation, coordination, and modifiers scoped clearly?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Are ordinary prose words left untranslated despite an established target-language expression?\n- Does each polysemous word fit its local context?\n- Is the same target-language word used for distinct source concepts, or is a defined term varied merely to avoid repetition?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied to the true first prose occurrence, neither missing nor repeated? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- Do protected tokens remain untouched even when they resemble terminology entries?\n- For an unlisted term, does a Chinese target use an established Chinese rendering or preserve the source term as pending when no reliable rendering is known, and does an English target use the established English technical term or preserve only an ambiguous source term with the shortest necessary gloss and a pending notice?\n\n**Punctuation** (when target is Chinese)\n- Are punctuation, mixed-script spacing, quotation marks, Latin letters, and digits in their required forms?\n- Are there em dashes that make the sentence less clear and should be replaced, while natural em dashes remain intact?\n- Are list-item endings grammatically consistent, with none ending in commas?\n- Do RFC 2119 keywords preserve the source emphasis span and normative strength exactly?\n\nRecord actual corrections in ``, then output the corrected complete document in ``. If no correction or pending terminology notice is needed, write exactly `- 无修正` in `` and copy `` unchanged into ``. If `` contains only pending terminology notices, copy `` unchanged into ``.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions within the rule each example illustrates; examples do not override source context or higher-priority requirements.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to hear without the source anchoring you`\n- Bad: `没有源文锚着,别扭的表述更容易被听出来`\n- Good: `不对照原文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" }, { "role": "user", diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index a0eec4960f..20e6c4bc14 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -48,6 +48,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/bash/pwsh-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-pwsh.' }, 'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' }, 'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' }, + 'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' }, 'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' }, 'packages/typert/loader': { kind: 'none', reason: 'Loader integration only registers generated artifacts; consumers own any model-visible projection.' }, 'packages/e2b/e2b': { kind: 'none', reason: 'The shared remote-runtime owner registers no model context; provider adapters and consumers own rendered effects.' }, @@ -68,7 +69,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-deliverables': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' }, - 'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the host snapshots the target at the next prompt-assembly boundary and owns the model-visible effect.' }, + 'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the Host snapshots the selection at the next prompt-assembly boundary and owns the model-visible effect.' }, 'packages/client/ui-goal': { kind: 'indirect', reason: 'The strip verbs route goal.* mutations; the host GoalService owns the model-visible goal/change context message.' }, 'packages/client/ui-permission': { kind: 'indirect', reason: 'The picker submits the host /permission command; the knob events it appends own the model-visible effect through the sandbox/approval consumers.' }, 'packages/client/ui-plan': { kind: 'indirect', reason: 'The chip dispatches /plan off; dsh-plan-mode owns the model-visible policy, exit tool, and logged state.' }, @@ -94,7 +95,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' }, 'packages/host/frontend-static': { kind: 'none', reason: 'The SPA dist server answers browser asset requests and registers no model surface.' }, 'packages/bundle/base': { kind: 'indirect', reason: 'The bundle is a patch-list carrier; each inserted row\'s package owns its model surface.' }, - 'packages/bundle/headless': { kind: 'none', reason: 'The one-shot runner submits the task as an ordinary user message; prompts and tools belong to the composed base/web bundles.' }, + 'packages/bundle/headless': { kind: 'none', reason: 'The one-shot runner submits the task as an ordinary user message; prompts and tools belong to the composed base and headless bundles.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, 'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' }, 'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 31631995cf..f890487f3b 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -138,6 +138,7 @@ { "path": "./packages/session/session-telemetry-otel" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, + { "path": "./packages/core/agent-default-model" }, { "path": "./packages/interaction/commands" }, { "path": "./packages/goal/goal" }, { "path": "./packages/goal/tool-goal" }, From 63b6e18f71e9e5f65ce344714cde6083e3ffba02 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:35:00 +0800 Subject: [PATCH 2/2] fix(headless): terminal model failures reach stderr --- ...-headless-direct-core-front-door.i18n.yaml | 4 +- ...6-08-09-headless-direct-core-front-door.md | 4 +- ...8-09-headless-direct-core-front-door.zh.md | 4 +- .../tests/fixtures/cli-mock-llm.ts | 4 ++ .../headless-agent/tests/headless.snapshot.ts | 41 +++++++++++++++---- .../snapshots/dsh-run/stderr.expected.txt | 1 + packages/bundle/headless/README.i18n.yaml | 4 +- packages/bundle/headless/README.md | 2 +- packages/bundle/headless/README.zh.md | 2 +- packages/bundle/headless/src/index.ts | 11 +++-- .../bundle/headless/tests/headless.spec.ts | 21 ++++++++++ 11 files changed, 76 insertions(+), 22 deletions(-) create mode 100644 examples/headless-agent/tests/snapshots/dsh-run/stderr.expected.txt diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.i18n.yaml index cab1c8e338..921bc193e4 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.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-front-door.md -2026-08-09-headless-direct-core-front-door.md: cae17970633d6eda9d4f823118827ee2bfe32abf -2026-08-09-headless-direct-core-front-door.zh.md: 8ff5e870b7f0fbc82065c6c55b4635cdc586036c +2026-08-09-headless-direct-core-front-door.md: 875939b424059907949e350d50d88ded331f3dc9 +2026-08-09-headless-direct-core-front-door.zh.md: 7de5efbe172c0c43c03d55724d27e32d08c11e9f diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.md index cae1797063..875939b424 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.md @@ -14,7 +14,7 @@ The direct front door still needs the same deployment model state as Web-created The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The headless bundle supplies its persona and tool mode, disables HMR, mounts the Code Mode worker explicitly, and inserts `headless-runner`. Its tree contains no `@deepseek-ai/dsh-host-*` package, ApiProxy, HTTP server, Web runtime, or browser client. Code Mode and Session persistence are one-shot Agent capabilities independent of Web presentation. -`headless-runner` is a direct core front door. After Loader settlement, it reads `ctx.agentDefaultModel.currentSelection()`, creates a fresh persisted Agent through `ctx.agents.create`, installs that `ModelSelection` in the Agent scope, waits for startup quiescence, anchors the Session sequence, submits one ordinary user message, and waits for quiescence again. It awaits `ctx.sessions.flush`, folds its durable event interval for the last non-empty assistant text and final `turn/end` reason, writes the text plus one newline to stdout, and requests bounded launcher shutdown with exit 0 exactly when the reason is `completed`. Unexpected driver failures use stderr and exit 1. +`headless-runner` is a direct core front door. After Loader settlement, it reads `ctx.agentDefaultModel.currentSelection()`, creates a fresh persisted Agent through `ctx.agents.create`, installs that `ModelSelection` in the Agent scope, waits for startup quiescence, anchors the Session sequence, submits one ordinary user message, and waits for quiescence again. It awaits `ctx.sessions.flush`, folds its durable event interval for the last non-empty assistant text and final `turn/end` reason, writes the text plus one newline to stdout, and requests bounded launcher shutdown with exit 0 exactly when the reason is `completed`. A terminal `error` reason writes its durable code and message to stderr; unexpected driver failures also use stderr and exit 1. `@deepseek-ai/dsh-agent-default-model` owns the transport-independent default used for an Agent without a session-local selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel` and registers the `agent-default-model` Settings section. Composition config supplies `{provider, model}`; user settings may also supply `reasoningEffort`. `currentSelection()` returns the live complete selection and `saveSelection()` writes it as a complete section, so a selection without an effort clears any stored effort. `dsh-base` supplies the composition entry. Direct and ApiProxy front doors consume this service; ApiProxy alone owns session-local precedence, model validation, and persistence of accepted Web selections. @@ -24,7 +24,7 @@ This note owns the headless transport and completion contracts. [`dsh run` owns ## 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, non-completed exits, direct failures, Loader-time disposal, and flush-before-exit ordering. The keyless assembled snapshot drives `dsh run` through a replayed tool round trip and records direct user-message provenance. 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 run` through a replayed tool round trip, record direct user-message provenance, 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 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.zh.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.zh.md index 8ff5e870b7..7de5efbe17 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.zh.md @@ -14,7 +14,7 @@ Status: implemented 随附的 `headless` profile 包含 `dsh-base` 与 `dsh-headless`。headless 组合包提供自身的 persona 与工具模式、禁用 HMR(热模块替换)、显式挂载 Code Mode worker,并插入 `headless-runner`。其插件树不包含任何 `@deepseek-ai/dsh-host-*` 包、ApiProxy、HTTP server、Web 运行时或浏览器客户端。Code Mode 与会话持久化均为独立于 Web 呈现的一次性 Agent 能力。 -`headless-runner` 是直接使用核心服务的入口。Loader 完全加载后,它读取 `ctx.agentDefaultModel.currentSelection()`,通过 `ctx.agents.create` 创建一个新的持久化 Agent,在 Agent 作用域中安装该 `ModelSelection`,等待启动工作完全停稳,锚定会话事件序号,提交一条普通用户消息,再次等待完全停稳。随后,它等待 `ctx.sessions.flush`,折叠自身持有的持久事件区间,以取得最后一条非空 assistant 文本和最终 `turn/end` 结束原因,将文本连同一个换行写入 stdout,并且仅在结束原因为 `completed` 时请求启动器以退出状态 0 有界关闭。驱动器的意外失败写入 stderr 并以 1 退出。 +`headless-runner` 是直接使用核心服务的入口。Loader 完全加载后,它读取 `ctx.agentDefaultModel.currentSelection()`,通过 `ctx.agents.create` 创建一个新的持久化 Agent,在 Agent 作用域中安装该 `ModelSelection`,等待启动工作完全停稳,锚定会话事件序号,提交一条普通用户消息,再次等待完全停稳。随后,它等待 `ctx.sessions.flush`,折叠自身持有的持久事件区间,以取得最后一条非空 assistant 文本和最终 `turn/end` 结束原因,将文本连同一个换行写入 stdout,并且仅在结束原因为 `completed` 时请求启动器以退出状态 0 有界关闭。结束原因为 `error` 时,其持久化错误码与消息写入 stderr;驱动器的意外失败也写入 stderr 并以 1 退出。 `@deepseek-ai/dsh-agent-default-model` 拥有与传输无关的默认值,供没有会话级选择的 Agent 使用。`AgentDefaultModelService` 提供 `ctx.agentDefaultModel` 并注册 `agent-default-model` Settings 分节。组合配置提供 `{provider, model}`,用户设置还可以提供 `reasoningEffort`。`currentSelection()` 返回当前的完整选择,`saveSelection()` 则写入完整分节,因此不含强度的选择会清除已存强度。`dsh-base` 提供组合条目。直接前门与 ApiProxy 前门均消费该服务;只有 ApiProxy 负责会话级优先级、模型校验与已接受 Web 选择的持久化。 @@ -24,7 +24,7 @@ Status: implemented ## 验证 -包测试围绕脚本化 Agent 工厂使用真实的会话存储与 Agent 注册表,固定空闲态到空闲态的聚合、延迟异步完成、未完成退出、直接失败、Loader 加载期间的 dispose(资源释放),以及退出前 flush 的顺序。组装后的无密钥快照通过回放的工具往返驱动 `dsh run`,并记录直接用户消息的来源。构建后二进制验收通过已发布入口访问 mock 提供方,并要求最终文本出现在 stdout、退出状态为 0 且 stderr 为空。配置转储验收排除随附 headless 树中的所有 Host、Web 与 Client 包;PTY 关闭覆盖要求不出现观察行,并在有界时间内完成 dispose。 +包测试围绕脚本化 Agent 工厂使用真实的会话存储与 Agent 注册表,固定空闲态到空闲态的聚合、延迟异步完成、终止态模型诊断、其他未完成退出、直接失败、Loader 加载期间的 dispose(资源释放),以及退出前 flush 的顺序。组装后的无密钥快照通过回放的工具往返驱动 `dsh run`,记录直接用户消息的来源,并在 stderr 暴露终止态模型失败。构建后二进制验收通过已发布入口访问 mock 提供方,并要求最终文本出现在 stdout、退出状态为 0 且 stderr 为空。配置转储验收排除随附 headless 树中的所有 Host、Web 与 Client 包;PTY 关闭覆盖要求不出现观察行,并在有界时间内完成 dispose。 ## 考虑过的替代方案 diff --git a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts index 80a4f7e240..b0b120ecb2 100644 --- a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts +++ b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts @@ -29,6 +29,10 @@ class CliMockAdapter extends LlmAdapter { } async * stream(options: GenerateOptions): AsyncIterable { + if (process.env.DSH_CLI_MOCK_FAILURE === '1') { + yield { type: 'finish', reason: { kind: 'error', failure: { code: 'SERVER', message: 'CLI mock provider failed' } } } + return + } const toolResult = options.messages.at(-1)?.content.find(block => block.type === 'tool-result') if (toolResult === undefined) { const args = JSON.stringify({ command: 'printf CLI_TOOL_ROUND_TRIP', description: 'Prove the CLI tool round trip.' }) diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 593b05b4e8..ff00d42b6b 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -54,6 +54,7 @@ const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', i 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 cliMockLlmPluginPath = fileURLToPath(new URL('./fixtures/cli-mock-llm.ts', import.meta.url)) const refreshing = process.env.DSH_SNAPSHOT === 'refresh' @@ -196,6 +197,16 @@ async function persistedLogs(cwd: string, root: string = join(cwd, '.sessions')) })) } +/** Install the keyless product-CLI adapter into the temporary headless profile. */ +async function prepareCliMockFixture(cwd: string): Promise { + const fixtureDir = join(cwd, '.dsh', 'profiles', 'headless', 'snapshot-fixtures') + await mkdir(fixtureDir, { recursive: true }) + await Promise.all([ + copyFile(cliMockLlmPluginPath, join(fixtureDir, 'cli-mock-llm.ts')), + writeFile(join(fixtureDir, 'package.json'), '{"type":"module"}\n'), + ]) +} + 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.' @@ -211,14 +222,7 @@ describe('headless stream-json snapshots', () => { DSH_TELEMETRY_DISABLED: '1', NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), }, - prepare: async (cwd) => { - const fixtureDir = join(cwd, '.dsh', 'profiles', 'headless', 'snapshot-fixtures') - await mkdir(fixtureDir, { recursive: true }) - await Promise.all([ - copyFile(cliMockLlmPluginPath, join(fixtureDir, 'cli-mock-llm.ts')), - writeFile(join(fixtureDir, 'package.json'), '{"type":"module"}\n'), - ]) - }, + prepare: prepareCliMockFixture, inspect: async (cwd) => { const logs = await persistedLogs(cwd, join(cwd, '.dsh', 'sessions')) expect(logs).toHaveLength(1) @@ -237,6 +241,27 @@ 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 () => { + const result = await runLoaderSmoke({ + label: 'product dsh run model failure snapshot', + tempDirPrefix: 'headless-snapshot-dsh-run-failure-', + binScript: dshBinScript, + configPath: dshRunOverlayPath, + binArgs: ['run', '--patch', dshRunOverlayPath, 'Trigger the keyless model failure.'], + tsconfigPath, + expectedExitCode: 1, + env: { + DSH_CLI_MOCK_FAILURE: '1', + DSH_TELEMETRY_DISABLED: '1', + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + prepare: prepareCliMockFixture, + }) + + expect(result.stdout).toBe('\n') + await expect(result.stderr).toMatchFileSnapshot(dshRunFailureExpected) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('prints the original Loader activation error through the assembled one-shot app', async () => { const result = await runLoaderSmoke({ label: 'headless startup activation error snapshot', diff --git a/examples/headless-agent/tests/snapshots/dsh-run/stderr.expected.txt b/examples/headless-agent/tests/snapshots/dsh-run/stderr.expected.txt new file mode 100644 index 0000000000..79665a83c3 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/dsh-run/stderr.expected.txt @@ -0,0 +1 @@ +dsh: SERVER: CLI mock provider failed diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index ea768061df..4d43c64eb3 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: 6faf1dc732345a86ec1d7b60c4f9a606e0491512 -README.zh.md: a189f77f57cf0f641abe7c11288dbc1cdf05355a +README.md: fbdf80be3d587b0ef526c1a36952a761035f7312 +README.zh.md: 833737e08643e0ad6b585f93fdc6ae018242b937 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 6faf1dc732..fbdf80be3d 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -4,7 +4,7 @@ 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. -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` seam (final `turn/end` completed → 0, otherwise 1). A successful run writes nothing to stderr and 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` seam (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. ## Model Experience diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index a189f77f57..833737e086 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -4,7 +4,7 @@ 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 或浏览器插件。 -Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.headlessIo` seam 请求退出(最终 `turn/end` 完成 → 0,否则为 1)。成功运行时不会向 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` seam 请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。启动器把任务文本 patch 进来(`dsh run "task"`);若所选 profile 缺少该行,则显式报错。 ## 模型体验 diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 670838661a..2ea68c441b 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -38,7 +38,7 @@ export const Config: z = z.object({ /** Outcome of one owned run interval. */ interface RunOutcome { text: string - reason: string + reason: SessionEvent<'turn/end'>['data']['reason'] | undefined } /** @@ -63,7 +63,7 @@ declare module 'cordis' { function summarize(events: readonly SessionEvent[], firstSeq: number): RunOutcome { let started = false let text = '' - let reason = 'error' + let reason: SessionEvent<'turn/end'>['data']['reason'] | undefined for (const event of events) { if (event.seq < firstSeq) continue if (event.type === 'turn/start') { @@ -78,7 +78,7 @@ function summarize(events: readonly SessionEvent[], firstSeq: number): RunOutcom .join('') if (joined !== '') text = joined } - if (event.type === 'turn/end') reason = event.data.reason.kind + if (event.type === 'turn/end') reason = event.data.reason } return { text, reason } } @@ -125,7 +125,10 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise { await sessions.flush(agent.session) const outcome = summarize(agent.session.events, firstSeq) io.stdout.write(outcome.text + '\n') - io.exit(outcome.reason === 'completed' ? 0 : 1) + if (outcome.reason?.kind === 'error') { + io.stderr.write(`dsh: ${outcome.reason.error.code}: ${outcome.reason.error.message}\n`) + } + io.exit(outcome.reason?.kind === 'completed' ? 0 : 1) } /** diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 294c224354..bd24a9300a 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -151,6 +151,27 @@ describe('headless runner', () => { await test.ctx.fiber.dispose() }) + it('prints the durable model failure when the final turn ends in error', async () => { + const test = await bench({ + afterPrompt(session, message) { + session.append('turn/start', { turn: 1 }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('user/message', message, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { + turn: 1, + reason: { kind: 'error', error: { code: 'SERVER', message: 'provider unavailable' } }, + }) + }, + }) + expect(await test.run()).toMatchObject({ + code: 1, + out: '\n', + err: 'dsh: SERVER: provider unavailable\n', + }) + await test.ctx.fiber.dispose() + }) + it('exits 1 when the owned interval contains no turn', async () => { const test = await bench({ afterPrompt: () => {} }) expect(await test.run()).toMatchObject({ code: 1, out: '\n', err: '' })