feat(subagent): carry model routing through DSH SDK

This commit is contained in:
pku-xht
2026-08-24 21:38:28 +08:00
parent b44a139ab7
commit 1044db218d
54 changed files with 638 additions and 137 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md
2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: 37c341e964b556c7ab5fdd9081416883066b97d1 2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: 0843692af2f1f6e3202897f2928d25cd6d7027c8
2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: e97e951028de3bcda9fe11be0351072481c72dd9 2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: 9288be6f7b58b5d8f92db4c150cfbb04f13ff665
@@ -12,20 +12,20 @@ The stdio JSON-RPC serving surface (`@deepseek-ai/dsh-sdk-jsonrpc-server`, the [
Three packages, layered exactly like the existing Python stack, plus one Service Provider registration: Three packages, layered exactly like the existing Python stack, plus one Service Provider registration:
- **`@deepseek-ai/dsh-sdk-protocol`** (`packages/sdk/protocol/`) — the wire made shared and nominal. `JsonRpcLineTransport` moves here verbatim from `dsh-sdk-jsonrpc-server` (which now imports it), and `types.ts` names every payload the server speaks: `InitializeParams/Result`, `SessionPromptParams/Result`, the four notification payloads, and the `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` indexes. The package root explicitly exports that complete interface and provides no source-module deep imports. The server's `notify()` call sites are typed against these named payloads, so server drift breaks compilation, not clients. One behavioral change: an error response now rejects with `JsonRpcResponseError` carrying the wire `code`/`data` (the Python client already preserved these; the old transport threw a bare `Error` with only the message). - **`@deepseek-ai/dsh-sdk-protocol`** (`packages/sdk/protocol/`) — the wire made shared and nominal. `JsonRpcLineTransport` lives here, and `types.ts` names every payload the server speaks: `InitializeParams/Result`, `SessionPromptParams/Result`, the four notification payloads, and the `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` indexes. `InitializeParams` carries provider, model, optional adapter-owned reasoning effort, and optional output cap. The package root explicitly exports that complete interface and provides no source-module deep imports. The server's `notify()` call sites are typed against these named payloads, so server drift breaks compilation, not clients. Error responses reject with `JsonRpcResponseError` carrying the wire `code`/`data`, matching the Python client.
- **`@deepseek-ai/dsh-sdk-client`** (`packages/sdk/client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, `run()` pairing one `session/prompt` with its `session.finished`). Its package-root consumer interface explicitly exports both client layers, caller-facing types, and the protocol-owned `JsonRpcResponseError`; source modules, normalization helpers, and the notification producer stay internal. `RunResult.events` contains only the root session's typed events, while `notifications` retains session ids across the root and descendants discovered from `subagent.started`; session-tree scoping is client-side, mirroring `client.py`. The launch interface resolves the same-version `@deepseek-ai/dsh` dependency and selects a named profile, with optional `dshBin`, ordered patches, an explicit Harness home, process cwd, environment, and timeouts; arbitrary command/argv launch remains an internal fake-runtime adapter. A clean checkout without `lib/bin.js` uses that package's source entry through an absolute `tsx/esm` loader and an internal patch that omits build-generated Typert contribution loading, which the SDK protocol does not consume. `env` replaces rather than merges and is read when `start()` spawns, so callers own credential policy and can finish preparing it before first use. `RunResult` carries the structured `reason` (Python exposes only `status`); teardown walks a private stdin-EOF → SIGTERM → SIGKILL ladder to actual exit (the client runs outside any harness context, so it cannot ride `ctx.subprocess`). - **`@deepseek-ai/dsh-sdk-client`** (`packages/sdk/client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, `run()` pairing one `session/prompt` with its owned activity). Its package-root consumer interface explicitly exports both client layers, caller-facing types, and the protocol-owned `JsonRpcResponseError`; source modules, normalization helpers, and the notification producer stay internal. `RunResult.events` contains only the root session's typed events, while `notifications` retains session ids across the root and descendants discovered from `subagent.started`; session-tree scoping is client-side, mirroring `client.py`. The launch interface resolves the same-version `@deepseek-ai/dsh` dependency and selects a named profile, with optional `dshBin`, ordered patches, an explicit Harness home, process cwd, environment, and timeouts; arbitrary command/argv launch remains an internal fake-runtime adapter. `initialize` carries provider, model, optional reasoning effort, and optional output cap. A clean checkout without `lib/bin.js` uses that package's source entry through an absolute `tsx/esm` loader and an internal patch that omits build-generated Typert contribution loading, which the SDK protocol does not consume. `env` replaces rather than merges and is read when `start()` spawns, so callers own credential policy and can finish preparing it before first use. Teardown walks a private stdin-EOF → SIGTERM → SIGKILL ladder to actual exit because the client runs outside any harness context.
- **`@deepseek-ai/dsh-subagent-dsh-sdk`** (`packages/subagent/subagent-dsh-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling: same all-false capabilities and `inheritsParentContext: false`, same publish-after-handshake ownership transaction, same result-never-rejects flattening through an `onError` sink, same parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason` (`completed`/`max-tokens`/`aborted` pass through; everything else, including a settled-without-turn child, is `error`). Its `dshBin`/profile/patch/home config selects an isolated SDK application, `provider`/`model` feeds the child's `initialize`, and `env` supplies explicit child-only values such as its API key. - **`@deepseek-ai/dsh-subagent-dsh-sdk`** (`packages/subagent/subagent-dsh-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling but advertising `agentOptions: true`: each run merges provider/model/reasoning/maxTokens over instance defaults and sends only those fields through the child `initialize`. Other start capabilities remain false, and `inheritsParentContext: false`. The provider retains the same publish-after-handshake ownership transaction, result-never-rejects flattening through an `onError` sink, and parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason` (`completed`/`max-tokens`/`aborted` pass through; everything else, including a settled-without-turn child, is `error`). Its `dshBin`/profile/patch/home config selects an isolated SDK application, while `env` supplies explicit child-only values such as its API key.
- **The subagent seam grows `out-of-process.ts`**: the provider-side vocabulary both out-of-process backends share — `NO_START_CAPABILITIES`, timing-bound validation, child cwd resolution (config override, else the delegating parent session's workspace), the never-reject `settleRunResult`, and the `subprocessRunHandle` publication. Process mechanics (spawn, env scrub, tree-scoped teardown) live in the `dsh-subprocess` seam; `subagent-acp` spawns through `ctx.subprocess`, while this backend spawns through the SDK client (the subprocess README's documented exception for SDK-managed transports) and applies the seam's `scrubbedParentEnv()` itself. - **The subagent seam grows `out-of-process.ts`**: the provider-side vocabulary both out-of-process backends share — `NO_START_CAPABILITIES`, timing-bound validation, child cwd resolution (config override, else the delegating parent session's workspace), the never-reject `settleRunResult`, and the `subprocessRunHandle` publication. Process mechanics (spawn, env scrub, tree-scoped teardown) live in the `dsh-subprocess` seam; `subagent-acp` spawns through `ctx.subprocess`, while this backend spawns through the SDK client (the subprocess README's documented exception for SDK-managed transports) and applies the seam's `scrubbedParentEnv()` itself.
`dsh-sdk-jsonrpc-server` keeps serving unchanged (the wire is byte-identical). TypeScript and Python clients both consume the shared protocol through `dsh --profile sdk`; the Python wheel packages that CLI and its closed dependency tree. `dsh-sdk-jsonrpc-server` validates the exact provider/model/effort route during `initialize`, stores only explicitly supplied effort and token values, and creates every SDK root Agent from that fixed process-wide route. TypeScript and Python clients both expose the same initialization fields through `dsh --profile sdk`; the Python wheel packages that CLI and its closed dependency tree.
## Testing ## Testing
Four tiers, per [testing policy](../../../../docs/testing.md): Four tiers, per [testing policy](../../../../docs/testing.md):
- **Keyless unit** — `sdk-client` drives a scripted fake runtime (`tests/fake-runtime.ts`, env-scripted, protocol-only — the Python `test_client.py` pattern) over real stdio; `subagent-dsh-sdk` drives the same fake through the real provider. 100% per-file coverage on all three packages. - **Keyless unit** — `sdk-client` drives a scripted fake runtime (`tests/fake-runtime.ts`, env-scripted, protocol-only — the Python `test_client.py` pattern) over real stdio; `subagent-dsh-sdk` drives the same fake through the real provider. 100% per-file coverage on all three packages.
- **Keyless Loader composition** — `subagent-dsh-sdk/tests/loader-composition.e2e.ts` boots a test-only cordis.yml (`examples/python-sdk-agent/tests/fixtures/subagent/subagent-dsh-sdk/`) where the child is a real second `dsh --profile sdk` runtime with its own isolated home and ordered patch; asserts the parent tool result and the child's own persisted transcript both carry the parent session's cwd. - **Keyless Loader composition** — `subagent-dsh-sdk/tests/loader-composition.e2e.ts` boots a test-only cordis.yml (`examples/python-sdk-agent/tests/fixtures/subagent/subagent-dsh-sdk/`) where the child is a real second `dsh --profile sdk` runtime with its own isolated home and ordered patch; it asserts provider/model/reasoning/maxTokens and parent cwd in both the tool result and the child's persisted request header.
- **Keyless snapshot** — `examples/python-sdk-agent/tests/sdk.snapshot.ts` drives the real `dsh --profile sdk` runtime through the real `dsh-sdk-client`, replaying recorded fixtures through an ordered `llm-replay` patch. Four scenarios — text turn, bash tool, spawn subagent, and the minimal persistent-tool composition — each pin the normalized notification stream, SDK turn result, and persisted parent and child logs. This also closes the protocol-tier gap the single-exe note's Python-side snapshot left on the vitest side. - **Keyless snapshot** — `examples/python-sdk-agent/tests/sdk.snapshot.ts` drives the real `dsh --profile sdk` runtime through the real `dsh-sdk-client`. Text, bash, and in-process subagent scenarios replay recorded fixtures through `llm-replay`; the DSH SDK scenario uses deterministic parent and child adapters to pin a model-selected route through the delegation tool, a second SDK runtime, and the child's persisted request header. The minimal persistent-tool scenario covers the smaller shipped profile. Every scenario pins the normalized notification stream, SDK result, and applicable session logs.
- **With-key e2e** — the snapshot suite's `DSH_SNAPSHOT=record` mode is the live-API path (it produced the committed fixtures); the composition e2e needs no key by design. - **With-key e2e** — the snapshot suite's `DSH_SNAPSHOT=record` mode is the live-API path (it produced the committed fixtures); the composition e2e needs no key by design.
## Alternatives considered ## Alternatives considered
@@ -12,20 +12,20 @@ stdio JSON-RPC 对外服务接口(`@deepseek-ai/dsh-sdk-jsonrpc-server`,见[
三个包,分层与既有 Python 栈完全一致,外加一个 Service Provider 注册: 三个包,分层与既有 Python 栈完全一致,外加一个 Service Provider 注册:
- **`@deepseek-ai/dsh-sdk-protocol`**`packages/sdk/protocol/`)—— 把线协议做成共享且具名。`JsonRpcLineTransport` `dsh-sdk-jsonrpc-server` 原样移入(后者现在导入它)`types.ts` 为服务器所说的每个载荷命名:`InitializeParams/Result``SessionPromptParams/Result`、四个通知载荷,以及 `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` 索引。该包根显式导出这一完整接口,且不提供指向源模块的深层导入。服务器的 `notify()` 调用点以这些具名载荷标注类型,服务器漂移会先破坏编译而不是破坏客户端。一处行为变化:错误响应现在以携带线上 `code`/`data``JsonRpcResponseError` 拒绝Python 客户端本就保留这些;旧传输只抛携带消息的裸 `Error` - **`@deepseek-ai/dsh-sdk-protocol`**`packages/sdk/protocol/`)—— 把协议格式做成共享且具名。`JsonRpcLineTransport` 位于此处`types.ts` 为服务器所说的每个载荷命名:`InitializeParams/Result``SessionPromptParams/Result`、四个通知载荷,以及 `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` 索引。`InitializeParams` 携带提供方、模型、可选且由适配器持有的推理强度,以及可选输出上限。该包根显式导出完整接口,且不提供指向源模块的深层导入。服务器的 `notify()` 调用点以这些具名载荷标注类型,服务器漂移会先破坏编译而不是破坏客户端。错误响应以携带协议 `code`/`data``JsonRpcResponseError` 拒绝,与 Python 客户端一致
- **`@deepseek-ai/dsh-sdk-client`**`packages/sdk/client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`(spawn、分帧、通知扇出、有类型的错误表面、经共享 dispose(资源释放)阶梯关闭至完全停稳)之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize``run()` 把一个 `session/prompt` 与其 `session.finished` 配对)。其包根消费方接口显式导出两层客户端、面向调用方的类型,以及协议包所拥有的 `JsonRpcResponseError`;源模块、规范化辅助函数和通知投递端都保留为内部实现。`RunResult.events` 只包含根会话的类型化事件,而 `notifications` 则保留根会话及从 `subagent.started` 发现的后代各自的会话 id;基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。启动接口解析同版本 `@deepseek-ai/dsh` 依赖并选择具名 profile,可选配置包括 `dshBin`、有序 patch、显式 Harness home、进程 cwd、环境和超时;任意 command/argv 启动只作为内部 fake-runtime 适配器。干净 checkout 中若不存在 `lib/bin.js`client 会通过绝对 `tsx/esm` loader 使用该包的源码入口,并应用一个省略构建期生成 Typert 贡献加载的内部 patch;SDK 协议不消费这些贡献。`env` 整体替换而非合并,并在 `start()` spawn 时读取,因此凭据策略归调用方,且调用方可在首次使用前完成环境准备。`RunResult` 携带结构化 `reason`Python 只暴露 `status`);拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出client 运行在任何 harness 上下文之外,无法搭乘 `ctx.subprocess` - **`@deepseek-ai/dsh-sdk-client`**`packages/sdk/client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`(spawn、分帧、通知扇出、有类型的错误表面、经共享 dispose(资源释放)阶梯关闭至完全停稳)之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize``run()` 持有一次完整活动区间)。其包根消费方接口显式导出两层客户端、面向调用方的类型,以及协议包所拥有的 `JsonRpcResponseError`;源模块、规范化辅助函数和通知投递端都保留为内部实现。`RunResult.events` 只包含根会话的类型化事件,而 `notifications` 则保留根会话及从 `subagent.started` 发现的后代各自的会话 id;基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。启动接口解析同版本 `@deepseek-ai/dsh` 依赖并选择具名 profile,可选配置包括 `dshBin`、有序 patch、显式 Harness home、进程 cwd、环境和超时;任意 command/argv 启动只作为内部 fake-runtime 适配器。`initialize` 携带提供方、模型、可选推理强度与可选输出上限。干净 checkout 中若不存在 `lib/bin.js`client 会通过绝对 `tsx/esm` loader 使用该包的源码入口,并应用一个省略构建期生成 Typert 贡献加载的内部 patch;SDK 协议不消费这些贡献。`env` 整体替换而非合并,并在 `start()` spawn 时读取,因此凭据策略归调用方,且调用方可在首次使用前完成环境准备。拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出,因为 client 运行在任何 harness 上下文之外。
- **`@deepseek-ai/dsh-subagent-dsh-sdk`**`packages/subagent/subagent-dsh-sdk/`)—— 第二个进程外 `SubagentProvider`,采用与 `subagent-acp` 对等的结构:同样的全 false 能力与 `inheritsParentContext: false`,同样的握手后发布所有权事务,同样通过 `onError` sink 将结果归一为绝不拒绝,同样的父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未运行任何轮次便已结束的子进程,都是 `error`)。其 `dshBin`profilepatchhome 配置选择隔离的 SDK 应用,`provider``model` 写入子进程 `initialize``env` 则提供子进程专用的显式值,例如其 API key。 - **`@deepseek-ai/dsh-subagent-dsh-sdk`**`packages/subagent/subagent-dsh-sdk/`)—— 第二个进程外 `SubagentProvider`,采用与 `subagent-acp` 对等的结构,但声明 `agentOptions: true`:每次运行都会把提供方/模型/推理强度/maxTokens 合并到实例默认值之上,并且只把这些字段送入子进程 `initialize`。其他启动能力保持 false`inheritsParentContext: false`。提供方保留握手后发布所有权事务通过 `onError` sink 将结果归一为绝不拒绝,以及父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未运行任何轮次便已结束的子进程,都是 `error`)。其 `dshBin`profilepatchhome 配置选择隔离的 SDK 应用,`env` 则提供子进程专用的显式值,例如其 API key。
- **subagent seam 新增 `out-of-process.ts`**:两个进程外后端共享的 provider 侧词汇——`NO_START_CAPABILITIES`、时限校验、子进程 cwd 解析(配置覆盖、否则发起委托的父会话工作区)、绝不拒绝的 `settleRunResult`、以及 `subprocessRunHandle` 发布。进程机制(spawn、环境清理、进程树清理)属于 `dsh-subprocess` seam`subagent-acp``ctx.subprocess` spawn 子进程,本后端则经 SDK 客户端 spawn 子进程(subprocess README 记载的 SDK 托管传输例外)并自行应用该 seam 的 `scrubbedParentEnv()` - **subagent seam 新增 `out-of-process.ts`**:两个进程外后端共享的 provider 侧词汇——`NO_START_CAPABILITIES`、时限校验、子进程 cwd 解析(配置覆盖、否则发起委托的父会话工作区)、绝不拒绝的 `settleRunResult`、以及 `subprocessRunHandle` 发布。进程机制(spawn、环境清理、进程树清理)属于 `dsh-subprocess` seam`subagent-acp``ctx.subprocess` spawn 子进程,本后端则经 SDK 客户端 spawn 子进程(subprocess README 记载的 SDK 托管传输例外)并自行应用该 seam 的 `scrubbedParentEnv()`
`dsh-sdk-jsonrpc-server` 的服务不变(协议字节完全一致)。TypeScript 与 Python 客户端都通过 `dsh --profile sdk` 消费共享协议Python wheel 会打包该 CLI 及其封闭依赖树。 `dsh-sdk-jsonrpc-server` 会在 `initialize` 期间校验确切的提供方/模型/推理强度路由,只保存显式提供的推理强度与 token 值,并使用这条固定的进程级路由创建每个 SDK 根 Agent。TypeScript 与 Python 客户端都通过 `dsh --profile sdk` 公开同一组初始化字段Python wheel 会打包该 CLI 及其封闭依赖树。
## 测试 ## 测试
四层,依[测试政策](../../../../docs/testing.zh.md) 四层,依[测试政策](../../../../docs/testing.zh.md)
- **免密钥单元**——`sdk-client` 通过真实 stdio 驱动脚本化伪运行时(`tests/fake-runtime.ts`,环境变量脚本化、纯协议——即 Python `test_client.py` 的模式);`subagent-dsh-sdk` 经真实提供方驱动同一伪运行时。三个包全部 100% 逐文件覆盖。 - **免密钥单元**——`sdk-client` 通过真实 stdio 驱动脚本化伪运行时(`tests/fake-runtime.ts`,环境变量脚本化、纯协议——即 Python `test_client.py` 的模式);`subagent-dsh-sdk` 经真实提供方驱动同一伪运行时。三个包全部 100% 逐文件覆盖。
- **免密钥 Loader 组合**——`subagent-dsh-sdk/tests/loader-composition.e2e.ts` 启动仅测试用 cordis.yml`examples/python-sdk-agent/tests/fixtures/subagent/subagent-dsh-sdk/`),其中子进程是真实的第二个 `dsh --profile sdk` 运行时,拥有独立 home 与有序 patch;断言父工具结果与子进程自己持久化的 transcript(文本记录)都携带父会话 cwd。 - **免密钥 Loader 组合**——`subagent-dsh-sdk/tests/loader-composition.e2e.ts` 启动仅测试用 cordis.yml`examples/python-sdk-agent/tests/fixtures/subagent/subagent-dsh-sdk/`),其中子进程是真实的第二个 `dsh --profile sdk` 运行时,拥有独立 home 与有序 patch;工具结果与子进程持久化请求 header 都必须携带提供方/模型/推理强度/maxTokens 及父会话 cwd。
- **免密钥快照**——`examples/python-sdk-agent/tests/sdk.snapshot.ts` 通过真实 `dsh-sdk-client` 驱动真实 `dsh --profile sdk` 运行时,并通过有序 `llm-replay` patch 回放已录制 fixture(测试前置数据)。文本轮次、bash 工具、spawn subagent 与极简持久工具组合四个场景分别钉住规范化通知流、SDK 轮次结果,以及持久化的父日志与子日志。这也补上了单文件可执行 Note 的 Python 侧快照在 vitest 侧留下的协议层缺口 - **免密钥快照**——`examples/python-sdk-agent/tests/sdk.snapshot.ts` 通过真实 `dsh-sdk-client` 驱动真实 `dsh --profile sdk` 运行时。文本、bash 与进程内 subagent 场景通过 `llm-replay` 回放已录制 fixture;DSH SDK 场景使用确定性的父级和子级适配器,把模型选择的路由固定在委派工具、第二个 SDK 运行时及子级持久化请求 header 中;极简持久工具场景覆盖较小的随附 profile。每个场景都会固定规范化通知流、SDK 结果和适用的会话日志
- **带密钥 e2e**——快照套件的 `DSH_SNAPSHOT=record` 模式即真实 API 路径(已提交 fixture 由它产出);组合 e2e 设计上无需密钥。 - **带密钥 e2e**——快照套件的 `DSH_SNAPSHOT=record` 模式即真实 API 路径(已提交 fixture 由它产出);组合 e2e 设计上无需密钥。
## 考虑过的替代方案 ## 考虑过的替代方案
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-sdk-max-output-tokens.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-sdk-max-output-tokens.md
2026-07-28-sdk-max-output-tokens.md: 72a9e87484ca87e0a750f52d7e46db7aee436d21 2026-07-28-sdk-max-output-tokens.md: 1d2915b7f7169b0784c648aad5900a85fac4c977
2026-07-28-sdk-max-output-tokens.zh.md: 820008ec7293cfee20c0a9c26037f746c9e081c2 2026-07-28-sdk-max-output-tokens.zh.md: ba59f745bc921d3cc0d5c01f83808dd495220bc7
@@ -14,7 +14,7 @@ The high-level SDKs expose one optional process-wide output cap: Python names it
Each SDK-created root Agent receives the cap through `AgentOptions.maxTokens`. Agent Loop places that value in the initial `LlmCallConfig`; final call preparation preserves the explicit value or materializes an exact-model adapter default, logs the effective cap in the request header, and reconstructs every dispatched conversation request from that durable header. Omitting the SDK option therefore allows the selected adapter or provider route default to apply. Each SDK-created root Agent receives the cap through `AgentOptions.maxTokens`. Agent Loop places that value in the initial `LlmCallConfig`; final call preparation preserves the explicit value or materializes an exact-model adapter default, logs the effective cap in the request header, and reconstructs every dispatched conversation request from that durable header. Omitting the SDK option therefore allows the selected adapter or provider route default to apply.
In-process subagents inherit the parent's provider, model, and output cap. An explicit `SubagentStartRequest.agentOptions.maxTokens`, including one configured by `dsh-tool-subagent`, overrides the inherited value for that child and its descendants. Out-of-process providers own the configuration of their separate runtime; `subagent-dsh-sdk` therefore exposes its own optional `maxTokens` and forwards it through that child runtime's SDK handshake. In-process subagents inherit the parent's provider, model, and output cap. An explicit `SubagentStartRequest.agentOptions.maxTokens`, including one configured by `dsh-tool-subagent`, overrides the inherited value for that child and its descendants. `subagent-dsh-sdk` owns a separate runtime per run: request `maxTokens` overrides its optional instance default, and the resolved cap crosses that child runtime's SDK handshake.
Compaction, session-title generation, web search, and other auxiliary calls keep their independently owned output limits. `maxTokensAsSuccess` remains outcome mapping only: it does not set or alter the cap. Compaction, session-title generation, web search, and other auxiliary calls keep their independently owned output limits. `maxTokensAsSuccess` remains outcome mapping only: it does not set or alter the cap.
@@ -30,4 +30,4 @@ Compaction, session-title generation, web search, and other auxiliary calls keep
SDK callers can bound model output without editing Cordis composition, and direct Agent creation uses the same validated `AgentOptions` contract. The cap is visible in durable request headers and reaches provider adapters as `GenerateOptions.maxTokens`; DeepSeek serialization maps it to `max_tokens`. SDK callers can bound model output without editing Cordis composition, and direct Agent creation uses the same validated `AgentOptions` contract. The cap is visible in durable request headers and reaches provider adapters as `GenerateOptions.maxTokens`; DeepSeek serialization maps it to `max_tokens`.
One SDK runtime has one default cap. A caller needing different caps runs separate runtime instances or explicitly overrides an in-process child through its agent options. Reaching the cap still produces the existing `max-tokens` stop reason, whose `ok` or `error` mapping remains deployment policy. One SDK runtime has one default cap. A caller needing different caps runs separate runtime instances or uses a subagent provider that advertises `agentOptions`; DSH SDK naturally creates one such runtime per child run. Reaching the cap still produces the existing `max-tokens` stop reason, whose `ok` or `error` mapping remains deployment policy.
@@ -14,7 +14,7 @@ Python 与 TypeScript SDK 可以选择提供方和模型,却无法限制对话
每个由 SDK 创建的根 Agent 都通过 `AgentOptions.maxTokens` 获得该上限。agent loop(智能体循环)将它放入初始 `LlmCallConfig`;最终调用准备会保留显式值,或填入确切模型的适配器默认值,再将生效上限记录到请求 header,并从该持久化 header 重建每次分派的对话请求。因此,省略 SDK 选项时会应用所选适配器或提供方路由的默认值。 每个由 SDK 创建的根 Agent 都通过 `AgentOptions.maxTokens` 获得该上限。agent loop(智能体循环)将它放入初始 `LlmCallConfig`;最终调用准备会保留显式值,或填入确切模型的适配器默认值,再将生效上限记录到请求 header,并从该持久化 header 重建每次分派的对话请求。因此,省略 SDK 选项时会应用所选适配器或提供方路由的默认值。
进程内 subagent 继承父级的提供方、模型和输出上限。显式的 `SubagentStartRequest.agentOptions.maxTokens`(包括通过 `dsh-tool-subagent` 配置的值)会覆盖该子级及其后代的继承值。进程外提供方自行持有其独立运行时的配置;因此 `subagent-dsh-sdk` 公开独立的可选 `maxTokens`,并通过该子运行时自己的 SDK 握手传入 进程内 subagent 继承父级的提供方、模型和输出上限。显式的 `SubagentStartRequest.agentOptions.maxTokens`(包括通过 `dsh-tool-subagent` 配置的值)会覆盖该子级及其后代的继承值。`subagent-dsh-sdk` 为每次运行持有独立运行时:请求 `maxTokens` 会覆盖可选的实例默认值,解析后的上限再经过该子运行时自己的 SDK 握手。
压缩、会话标题生成、网页搜索和其他辅助调用继续使用各自持有的独立输出上限。`maxTokensAsSuccess` 仍然只负责结果映射,不会设置或改变上限。 压缩、会话标题生成、网页搜索和其他辅助调用继续使用各自持有的独立输出上限。`maxTokensAsSuccess` 仍然只负责结果映射,不会设置或改变上限。
@@ -30,4 +30,4 @@ Python 与 TypeScript SDK 可以选择提供方和模型,却无法限制对话
SDK 调用方无需修改 Cordis 组合即可限制模型输出,直接创建 Agent 也使用同一套经过校验的 `AgentOptions` 约定。该上限在持久化请求 header 中可见,并以 `GenerateOptions.maxTokens` 到达提供方适配器;DeepSeek 序列化会将其映射为 `max_tokens` SDK 调用方无需修改 Cordis 组合即可限制模型输出,直接创建 Agent 也使用同一套经过校验的 `AgentOptions` 约定。该上限在持久化请求 header 中可见,并以 `GenerateOptions.maxTokens` 到达提供方适配器;DeepSeek 序列化会将其映射为 `max_tokens`
一个 SDK 运行时只有一个默认上限。需要不同上限的调用方应运行独立的运行时实例,或通过 agent options 显式覆盖某个进程内子级。达到上限时仍产生现有的 `max-tokens` 停止原因;将其映射为 `ok` 还是 `error` 仍由部署策略决定。 一个 SDK 运行时只有一个默认上限。需要不同上限的调用方应运行独立的运行时实例,或使用声明 `agentOptions` 的 subagent 提供方;DSH SDK 会自然地为每次子级运行创建一个这样的运行时。达到上限时仍产生现有的 `max-tokens` 停止原因;将其映射为 `ok` 还是 `error` 仍由部署策略决定。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md
2026-08-18-model-selected-subagent-routes.md: 1602e3ac90870edbd0206cd87fdf97ecc34cad41 2026-08-18-model-selected-subagent-routes.md: 0802230a537d7dc701928c2f5b8f9d8152f967e3
2026-08-18-model-selected-subagent-routes.zh.md: 0dad8b9d030e6de65cb3fa1e0e93ad7c28bcc5c1 2026-08-18-model-selected-subagent-routes.zh.md: 6a9974a05a1c7882e76acf802896b15671fd19ed
@@ -24,7 +24,7 @@ Shipped `subagent_fork` instances leave `enableModelSelection` disabled even tho
The delegation definition is static across adapter registration and catalog changes, so live topology neither expands every parent request nor invalidates its cache prefix. The discovery result enters the transcript only when called. A custom inheritance-capable instance that enables selection warns that changing provider or model can prevent provider-side reuse of the inherited conversation prefix. The delegation definition is static across adapter registration and catalog changes, so live topology neither expands every parent request nor invalidates its cache prefix. The discovery result enters the transcript only when called. A custom inheritance-capable instance that enables selection warns that changing provider or model can prevent provider-side reuse of the inherited conversation prefix.
`SubagentCapabilities.agentOptions` remains the transport truth. The service rejects a request carrying those options before calling a provider that advertises `false`. Both in-process providers advertise `true`; the current ACP, Codex, Claude Code, and DSH SDK transports advertise `false`. Tool configuration that supplies `agentOptions`, statically enables model selection, or makes it settings-controlled also fails when its bound provider lacks the capability. `SubagentCapabilities.agentOptions` remains the transport truth. The service rejects a request carrying those options before calling a provider that advertises `false`. Both in-process providers and the DSH SDK transport advertise `true`; DSH SDK merges the four supported route fields over its instance defaults and validates them during the new child runtime's `initialize`. ACP, Codex, and Claude Code advertise `false`. Tool configuration that supplies `agentOptions`, statically enables model selection, or makes it settings-controlled also fails when its bound provider lacks the capability.
## Alternatives considered ## Alternatives considered
@@ -53,8 +53,8 @@ The delegation definition is static across adapter registration and catalog chan
- Shipped fork tools inherit the parent's provider and model and omit model-facing route fields so the inherited conversation prefix remains eligible for KV Cache reuse. - Shipped fork tools inherit the parent's provider and model and omit model-facing route fields so the inherited conversation prefix remains eligible for KV Cache reuse.
- Omission retains configured defaults and compatible inheritance from the parent's latest logged request; a route change without an explicit effort uses the selected model's default. - Omission retains configured defaults and compatible inheritance from the parent's latest logged request; a route change without an explicit effort uses the selected model's default.
- Adapter catalog and topology changes leave the delegation definition and its prompt-cache prefix unchanged. - Adapter catalog and topology changes leave the delegation definition and its prompt-cache prefix unchanged.
- Out-of-process subagent providers reject configured and model-selected Agent options until they implement and advertise the capability. - DSH SDK children accept configured and model-selected Agent routes; ACP, Codex, and Claude Code reject them until they implement and advertise the capability.
- Unit coverage owns the default-off Host preference, new-Session sampling, child inheritance, resumed decisions, opt-in schema and execution enforcement, merge precedence, route-aware effort inheritance, preflight cancellation, live discovery, diagnostics, definition stability, capability rejection, and optional-service behavior. A shipped headless snapshot pins inheritance from a logged parent selection; the shipped examples also own the assembled keyless model-visible schemas. - Unit coverage owns the default-off Host preference, new-Session sampling, child inheritance, resumed decisions, opt-in schema and execution enforcement, merge precedence, route-aware effort inheritance, preflight cancellation, live discovery, diagnostics, definition stability, capability rejection, and optional-service behavior. A shipped headless snapshot pins inheritance from a logged parent selection; the shipped examples own the assembled keyless model-visible schemas, and the SDK Loader and snapshot evidence pin the complete route through a separate child runtime.
## Related decisions ## Related decisions
@@ -24,7 +24,7 @@ Status: implemented
委派定义不会随 adapter 注册和目录变化而改变,因此实时拓扑既不会扩大每个父级请求,也不会使缓存前缀失效。只有调用发现工具时,目录结果才进入 transcript。自定义的上下文继承实例如果启用选择,其描述会警告,更改提供方或模型可能阻止提供方复用继承的对话前缀。 委派定义不会随 adapter 注册和目录变化而改变,因此实时拓扑既不会扩大每个父级请求,也不会使缓存前缀失效。只有调用发现工具时,目录结果才进入 transcript。自定义的上下文继承实例如果启用选择,其描述会警告,更改提供方或模型可能阻止提供方复用继承的对话前缀。
`SubagentCapabilities.agentOptions` 仍是传输事实。如果请求携带这些选项,而提供方声明为 `false`,服务会在调用提供方前拒绝。两个进程内提供方声明为 `true`当前 ACP、CodexClaude Code 与 DSH SDK 传输声明为 `false`。工具配置提供 `agentOptions`、静态启用模型选择或让它受 settings 控制时,如果绑定的提供方缺少该能力,也会失败。 `SubagentCapabilities.agentOptions` 仍是传输事实。如果请求携带这些选项,而提供方声明为 `false`,服务会在调用提供方前拒绝。两个进程内提供方与 DSH SDK 传输声明为 `true`DSH SDK 会把四个受支持的路由字段合并到实例默认值之上,并在新子运行时的 `initialize` 期间校验。ACP、CodexClaude Code 声明为 `false`。工具配置提供 `agentOptions`、静态启用模型选择或让它受 settings 控制时,如果绑定的提供方缺少该能力,也会失败。
## 考虑过的替代方案 ## 考虑过的替代方案
@@ -53,8 +53,8 @@ Status: implemented
- 随附 fork 工具会继承父级的提供方与模型,并省略面向模型的路由字段,使继承的对话前缀仍可供 KV Cache 复用。 - 随附 fork 工具会继承父级的提供方与模型,并省略面向模型的路由字段,使继承的对话前缀仍可供 KV Cache 复用。
- 省略选择时保留配置默认值,并从父级最新记录的请求中进行兼容继承;改变路由但不显式指定强度时,使用所选模型的默认值。 - 省略选择时保留配置默认值,并从父级最新记录的请求中进行兼容继承;改变路由但不显式指定强度时,使用所选模型的默认值。
- adapter 目录和拓扑变化不会改变委派定义及其 prompt 缓存前缀。 - adapter 目录和拓扑变化不会改变委派定义及其 prompt 缓存前缀。
- 进程外 subagent 提供方在实现并声明该能力前会拒绝配置和模型选择的 Agent 选项 - DSH SDK 子级接受配置和模型选择的 Agent 路由;ACP、Codex 与 Claude Code 在实现并声明该能力前会拒绝。
- 单元测试覆盖默认关闭的 Host 偏好、新 Session 读取、子级继承、恢复决定、选择启用时的 schema 与执行强制、合并优先级、路由相关强度继承、预检取消、实时发现、诊断、定义稳定性、能力拒绝与可选服务行为。随附的 headless 快照固定从父级已记录选择继承的行为;随附示例覆盖组装后无密钥、模型可见的 schema。 - 单元测试覆盖默认关闭的 Host 偏好、新 Session 读取、子级继承、恢复决定、选择启用时的 schema 与执行强制、合并优先级、路由相关强度继承、预检取消、实时发现、诊断、定义稳定性、能力拒绝与可选服务行为。随附的 headless 快照固定从父级已记录选择继承的行为;随附示例覆盖组装后无密钥、模型可见的 schema,SDK Loader 与快照证据固定完整路由经过独立子运行时的链路
## 相关决策 ## 相关决策
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/subagent.md # pnpm run verify-translation-pairing --write docs/subsystems/subagent.md
subagent.md: 63edef0a4b8d5368ea9d6d82f0ea3ef99ea0bbad subagent.md: b9ddca3c7230d4f5adb4bae9e1b258a3b1184075
subagent.zh.md: 21b0dfd21dbee5e1d37558d6c02fe7949126d9a9 subagent.zh.md: 47a3378718c4cc5c43b44cdd5869eec3cf37f93a
+3 -2
View File
@@ -35,7 +35,7 @@ interface SubagentCapabilities {
## The one-shot start request ## The one-shot start request
The tool layer builds this request from the model input and its own config; the service validates it against the named provider before `start`. Required `parent` supplies the session cwd, lineage, and delegation depth. Optional Agent provider, model, reasoning-effort, and token overrides, output schema, depth, tool filter, and persona require matching capability flags. In-process backends merge `agentOptions` over the parent Agent's options, scope filters and personas to child creation, and implement the supported object-rooted schema with a forced capture tool. Current out-of-process providers reject `agentOptions` before starting their transport. The tool layer builds this request from the model input and its own config; the service validates it against the named provider before `start`. Required `parent` supplies the session cwd, lineage, and delegation depth. Optional Agent provider, model, reasoning-effort, and token overrides, output schema, depth, tool filter, and persona require matching capability flags. In-process backends merge `agentOptions` over the parent Agent's options, scope filters and personas to child creation, and implement the supported object-rooted schema with a forced capture tool. The DSH SDK backend merges the four Agent route fields over its instance defaults and validates them in the child runtime's initialization; ACP, Codex, and Claude Code reject `agentOptions` before starting their transports.
```ts type-equiv ```ts type-equiv
/** /**
@@ -68,7 +68,8 @@ interface SubagentStartRequest {
* Optional host-Agent provider, model, reasoning-effort, and output-token * Optional host-Agent provider, model, reasoning-effort, and output-token
* overrides. Requires {@link SubagentCapabilities.agentOptions}; in-process * overrides. Requires {@link SubagentCapabilities.agentOptions}; in-process
* providers merge them over the parent Agent's options when they create the * providers merge them over the parent Agent's options when they create the
* child. * child, while the DSH SDK provider merges them over its instance defaults
* before initializing the separate child runtime.
*/ */
readonly agentOptions?: AgentOptions readonly agentOptions?: AgentOptions
/** /**
+3 -2
View File
@@ -35,7 +35,7 @@ interface SubagentCapabilities {
## 单次启动请求 ## 单次启动请求
工具层根据模型输入和自身配置构建此请求;服务在 `start` 之前针对指定提供方进行校验。必填的 `parent` 提供会话 cwd、谱系与委派深度。可选的 Agent 提供方、模型、推理强度与 token 覆盖、output schema、depth、工具过滤器和 persona 需要对应的能力 flag 匹配。进程内后端会把 `agentOptions` 合并到父 Agent 选项之上,将 filter 和 persona 的作用域限定在子 agent 创建阶段,并通过强制 capture 工具实现所支持的 object-rooted schema。当前进程外提供方会在启动传输前拒绝 `agentOptions`。 工具层根据模型输入和自身配置构建此请求;服务在 `start` 之前针对指定提供方进行校验。必填的 `parent` 提供会话 cwd、谱系与委派深度。可选的 Agent 提供方、模型、推理强度与 token 覆盖、output schema、depth、工具过滤器和 persona 需要对应的能力 flag 匹配。进程内后端会把 `agentOptions` 合并到父 Agent 选项之上,将 filter 和 persona 的作用域限定在子 agent 创建阶段,并通过强制 capture 工具实现所支持的 object-rooted schema。DSH SDK 后端会把四个 Agent 路由字段合并到实例默认值之上,并在子运行时初始化期间校验;ACP、Codex 与 Claude Code 会在启动传输前拒绝 `agentOptions`。
```ts type-equiv ```ts type-equiv
/** /**
@@ -68,7 +68,8 @@ interface SubagentStartRequest {
* Optional host-Agent provider, model, reasoning-effort, and output-token * Optional host-Agent provider, model, reasoning-effort, and output-token
* overrides. Requires {@link SubagentCapabilities.agentOptions}; in-process * overrides. Requires {@link SubagentCapabilities.agentOptions}; in-process
* providers merge them over the parent Agent's options when they create the * providers merge them over the parent Agent's options when they create the
* child. * child, while the DSH SDK provider merges them over its instance defaults
* before initializing the separate child runtime.
*/ */
readonly agentOptions?: AgentOptions readonly agentOptions?: AgentOptions
/** /**
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/user/guide/python-sdk.md # pnpm run verify-translation-pairing --write docs/user/guide/python-sdk.md
python-sdk.md: 388b259f0adbba11b7d359fcf861980cf0a3bec7 python-sdk.md: b1c7cbff744adf727b4b98048905bf81a02d5e22
python-sdk.zh.md: 2cc23e5cd1d7d7df5ad4b27441c54e6c3239c917 python-sdk.zh.md: d3255352159eb4eb709244906c2068c0d56fcfa9
+1
View File
@@ -90,6 +90,7 @@ dsh_home = Path("/absolute/path/to/example-dsh-home").resolve()
with DeepSeekHarness( with DeepSeekHarness(
provider="deepseek-official", provider="deepseek-official",
model="deepseek-v4-flash", model="deepseek-v4-flash",
reasoning_effort="max",
max_tokens=49_152, max_tokens=49_152,
cwd=str(workspace), cwd=str(workspace),
dsh_home=str(dsh_home), dsh_home=str(dsh_home),
+1
View File
@@ -90,6 +90,7 @@ dsh_home = Path("/absolute/path/to/example-dsh-home").resolve()
with DeepSeekHarness( with DeepSeekHarness(
provider="deepseek-official", provider="deepseek-official",
model="deepseek-v4-flash", model="deepseek-v4-flash",
reasoning_effort="max",
max_tokens=49_152, max_tokens=49_152,
cwd=str(workspace), cwd=str(workspace),
dsh_home=str(dsh_home), dsh_home=str(dsh_home),
@@ -1,17 +1,37 @@
import type { Context } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import { LlmAdapter } from '@deepseek-ai/dsh-llm' import { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
/** /**
* Scripted model for the CHILD runtime: answers every request with its own * Scripted model for the CHILD runtime: rejects any route drift, then reports
* process cwd, so the driving e2e can prove the parent session's workspace * its effective route and process cwd so the driving evidence observes both
* reached the child process across the SDK wire. `options` carries the * SDK initialization inputs and the inherited workspace.
* request; the reply depends only on process state.
*/ */
class CwdEchoAdapter extends LlmAdapter { class RouteEchoAdapter extends LlmAdapter {
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.resolve({
provider,
id: model,
name: model,
reasoning: {
efforts: [{ id: ReasoningEffortId('max'), name: 'Maximum' }],
},
})
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> { async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
void options if (options.provider !== 'mock'
const reply = `child cwd: ${process.cwd()}` || options.model !== 'mock-routed'
|| options.reasoningEffort !== 'max'
|| options.maxTokens !== 777) {
throw new Error(`unexpected child route: ${JSON.stringify({
provider: options.provider,
model: options.model,
reasoningEffort: options.reasoningEffort,
maxTokens: options.maxTokens,
})}`)
}
const reply = `child route: mock/mock-routed/max/777; cwd: ${process.cwd()}`
yield { type: 'block-start', index: 0, blockType: 'text' } yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text: reply } yield { type: 'text-delta', index: 0, text: reply }
yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } } yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } }
@@ -28,5 +48,5 @@ export const inject = ['llm']
* @param ctx - the plugin context supplying `ctx.llm`. * @param ctx - the plugin context supplying `ctx.llm`.
*/ */
export function apply(ctx: Context): void { export function apply(ctx: Context): void {
ctx.llm.registerAdapter(['mock'], new CwdEchoAdapter()) ctx.llm.registerAdapter(['mock'], new RouteEchoAdapter())
} }
@@ -1,7 +1,7 @@
# Test-only composition: the SDK subagent backend on the real Loader/app path. # Test-only composition: the SDK subagent backend on the real Loader/app path.
# The scripted model delegates once; the child — a COMPLETE second harness # The scripted model selects a child route; the child — a COMPLETE second
# runtime speaking stdio JSON-RPC — echoes its process cwd, so parent-session # harness runtime speaking stdio JSON-RPC — echoes the effective route and cwd,
# cwd inheritance is asserted keylessly end to end across the SDK wire. # so dynamic routing and parent-session cwd inheritance are asserted keylessly.
# `cwd` is deliberately omitted — the inheritance branch under test. The child # `cwd` is deliberately omitted — the inheritance branch under test. The child
# profile patch and isolated Harness home are machine-absolute, supplied by # profile patch and isolated Harness home are machine-absolute, supplied by
# the driving e2e. # the driving e2e.
@@ -19,8 +19,10 @@
profile: sdk profile: sdk
patches: !!js JSON.parse(process.env.DSH_TEST_CHILD_PATCHES ?? '[]') patches: !!js JSON.parse(process.env.DSH_TEST_CHILD_PATCHES ?? '[]')
dshHome: !!js process.env.DSH_TEST_CHILD_HOME dshHome: !!js process.env.DSH_TEST_CHILD_HOME
provider: mock # These defaults are intentionally unavailable in the child composition;
model: mock-echo # the model-selected route must replace them before initialize.
provider: unavailable-default
model: unavailable-default
env: env:
DSH_TELEMETRY_DISABLED: '1' DSH_TELEMETRY_DISABLED: '1'
@@ -29,6 +31,8 @@
config: config:
provider: dsh-sdk provider: dsh-sdk
toolName: subagent toolName: subagent
agentOptions:
maxTokens: 777
# The SDK backend advertises no depthLimit: the child harness owns its own # The SDK backend advertises no depthLimit: the child harness owns its own
# recursion budget, so the local numeric default cannot apply here. # recursion budget, so the local numeric default cannot apply here.
maxDepth: 'provider-managed' maxDepth: 'provider-managed'
@@ -1,6 +1,6 @@
import type { Context } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
/** /**
* Test adapter for the `mock-delegate` model: the first request calls the * Test adapter for the `mock-delegate` model: the first request calls the
@@ -9,6 +9,17 @@ import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
* cwd echo) reaches the parent session log for the driving e2e to assert. * cwd echo) reaches the parent session log for the driving e2e to assert.
*/ */
class MockDelegatingAdapter extends LlmAdapter { class MockDelegatingAdapter extends LlmAdapter {
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.resolve({
provider,
id: model,
name: model,
reasoning: {
efforts: [{ id: ReasoningEffortId('max'), name: 'Maximum' }],
},
})
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> { async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const toolResultText = options.messages.at(-1)?.content const toolResultText = options.messages.at(-1)?.content
.filter(block => block.type === 'tool-result') .filter(block => block.type === 'tool-result')
@@ -18,7 +29,13 @@ class MockDelegatingAdapter extends LlmAdapter {
.join('') ?? '' .join('') ?? ''
if (toolResultText.length === 0) { if (toolResultText.length === 0) {
const args = JSON.stringify({ description: 'cwd probe', prompt: 'report your workspace' }) const args = JSON.stringify({
description: 'route probe',
prompt: 'report your route and workspace',
provider: 'mock',
model: 'mock-routed',
reasoning_effort: 'max',
})
yield { type: 'block-start', index: 0, blockType: 'tool-call' } yield { type: 'block-start', index: 0, blockType: 'tool-call' }
yield { type: 'tool-call-delta', index: 0, id: CallId('call-delegate'), name: 'subagent', argumentsDelta: args } yield { type: 'tool-call-delta', index: 0, id: CallId('call-delegate'), name: 'subagent', argumentsDelta: args }
yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('call-delegate'), name: 'subagent', arguments: args } } yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('call-delegate'), name: 'subagent', arguments: args } }
@@ -0,0 +1,51 @@
# JSON-RPC snapshot root: a deterministic parent model selects a route for a
# separate SDK child runtime. Both runtimes persist their own request headers.
- id: sdk-jsonrpc-server
name: '@deepseek-ai/dsh-sdk-jsonrpc-server'
- id: mock-llm
name: './mock-delegating-llm.ts'
- id: subagent
name: '@deepseek-ai/dsh-subagent'
- id: subagent-dsh-sdk
name: '@deepseek-ai/dsh-subagent-dsh-sdk'
config:
profile: sdk
patches: !!js JSON.parse(process.env.DSH_TEST_CHILD_PATCHES ?? '[]')
dshHome: !!js process.env.DSH_TEST_CHILD_HOME
provider: unavailable-default
model: unavailable-default
env:
DSH_TELEMETRY_DISABLED: '1'
- id: tool-subagent
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: dsh-sdk
toolName: subagent
enableRunInBackground: false
agentOptions:
maxTokens: 777
maxDepth: 'provider-managed'
- id: agent-spine
name: '@deepseek-ai/dsh-agent-spine-demo'
config:
persona: 'Test SDK subagent dynamic routing.'
workspaceContext: false
skills:
enabled: false
toolBash:
enableRunInBackground: false
toolJobs: false
- id: sessions
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
root: !!js process.env.DSH_SESSION_ROOT
compression: none
- id: session-checkpoints
name: '@deepseek-ai/dsh-session-checkpoint-policy'
@@ -106,7 +106,13 @@ describe('Python SDK dsh profile keyless smoke', () => {
jsonrpc: '2.0', jsonrpc: '2.0',
id: 1, id: 1,
method: 'initialize', method: 'initialize',
params: { cwd: root, provider: 'deepseek-official', model: 'deepseek-v4-pro', maxTokens: 1234 }, params: {
cwd: root,
provider: 'deepseek-official',
model: 'deepseek-v4-pro',
reasoningEffort: 'max',
maxTokens: 1234,
},
})}\n`) })}\n`)
const initialized = await waitForLine(lines, value => value.id === 1, () => stderr) const initialized = await waitForLine(lines, value => value.id === 1, () => stderr)
expect(initialized).toMatchObject({ expect(initialized).toMatchObject({
@@ -145,6 +151,7 @@ describe('Python SDK dsh profile keyless smoke', () => {
}, },
}) })
const tools = modelRequests[0]?.tools as { function?: { name?: string } }[] const tools = modelRequests[0]?.tools as { function?: { name?: string } }[]
expect(modelRequests[0]?.reasoning_effort).toBe('max')
expect(modelRequests[0]?.max_tokens).toBe(1234) expect(modelRequests[0]?.max_tokens).toBe(1234)
expect(tools.map(tool => tool.function?.name)).toContain('list_subagent_models') expect(tools.map(tool => tool.function?.name)).toContain('list_subagent_models')
@@ -45,6 +45,10 @@ const replayPlugin = fileURLToPath(new URL(
: '../../../packages/test-support/llm-replay/src/index.ts', : '../../../packages/test-support/llm-replay/src/index.ts',
import.meta.url, import.meta.url,
)) ))
const dshSdkFixtureDir = join(testsDir, 'fixtures', 'subagent', 'subagent-dsh-sdk')
const dshSdkSnapshotConfig = join(dshSdkFixtureDir, 'snapshot.cordis.yml')
const dshSdkChildConfig = join(dshSdkFixtureDir, 'child.cordis.yml')
const dshSdkChildMockPath = join(dshSdkFixtureDir, 'child-mock-llm.ts')
const MINIMAL_SYSTEM_PROMPT = 'You are the environment-selected minimal software engineer.' const MINIMAL_SYSTEM_PROMPT = 'You are the environment-selected minimal software engineer.'
const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell
@@ -71,7 +75,7 @@ interface SdkScenario {
prompt: string prompt: string
/** Fixed SDK session id, so fixtures and replay binding stay stable. */ /** Fixed SDK session id, so fixtures and replay binding stay stable. */
sessionId: string sessionId: string
/** How many child sessions the turn persists (subagent scenarios). */ /** How many additional session logs the scenario persists. */
children: number children: number
/** Optional scenario-specific live and replay compositions. */ /** Optional scenario-specific live and replay compositions. */
configs?: { live: string; replay: string } configs?: { live: string; replay: string }
@@ -79,6 +83,14 @@ interface SdkScenario {
additionalPatches?: { live: readonly string[]; replay: readonly string[] } additionalPatches?: { live: readonly string[]; replay: readonly string[] }
/** Environment overrides passed to the runtime subprocess. */ /** Environment overrides passed to the runtime subprocess. */
environment?: Readonly<Record<string, string>> environment?: Readonly<Record<string, string>>
/** SDK initialization route for the root runtime. */
sdkRoute?: { provider: string; model: string }
/** Separate DSH SDK child process and the route its persisted request must prove. */
dshSdkChild?: {
config: string
sessionRoot: string
expectedRoute: Readonly<Record<string, unknown>>
}
/** Cwd-relative files whose final contents are part of the scenario contract. */ /** Cwd-relative files whose final contents are part of the scenario contract. */
expectedFiles?: Readonly<Record<string, string>> expectedFiles?: Readonly<Record<string, string>>
/** Assembled model-facing tool names and required argument keys. */ /** Assembled model-facing tool names and required argument keys. */
@@ -111,6 +123,24 @@ const SCENARIOS: SdkScenario[] = [
sessionId: 'sdk-snapshot-subagent', sessionId: 'sdk-snapshot-subagent',
children: 1, children: 1,
}, },
{
name: 'subagent-dsh-sdk-dynamic-route',
prompt: 'Delegate once using the requested child route.',
sessionId: 'sdk-snapshot-dsh-sdk',
children: 1,
configs: { live: dshSdkSnapshotConfig, replay: dshSdkSnapshotConfig },
sdkRoute: { provider: 'mock', model: 'mock-delegate' },
dshSdkChild: {
config: dshSdkChildConfig,
sessionRoot: '.child-dsh/sessions',
expectedRoute: {
provider: 'mock',
model: 'mock-routed',
reasoningEffort: 'max',
maxTokens: 777,
},
},
},
{ {
name: 'persistent-tools', name: 'persistent-tools',
prompt: 'Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9.', prompt: 'Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9.',
@@ -193,6 +223,17 @@ function assembledSystem(log: PersistedLog): string {
return system return system
} }
function assembledRequestConfig(log: PersistedLog): Record<string, unknown> {
const event = log.content.trimEnd().split('\n')
.map(line => JSON.parse(line) as { type?: string; data?: { header?: { config?: unknown } } })
.find(candidate => candidate.type === 'request/header')
const config = event?.data?.header?.config
if (typeof config !== 'object' || config === null || Array.isArray(config)) {
throw new Error('session log has no request/header config')
}
return config as Record<string, unknown>
}
function assembledRuntimeContexts(log: PersistedLog): string[] { function assembledRuntimeContexts(log: PersistedLog): string[] {
return log.content.trimEnd().split('\n').flatMap((line) => { return log.content.trimEnd().split('\n').flatMap((line) => {
const event = JSON.parse(line) as { const event = JSON.parse(line) as {
@@ -300,6 +341,18 @@ async function runScenario(scenario: SdkScenario): Promise<{
? scenario.additionalPatches?.live ?? [] ? scenario.additionalPatches?.live ?? []
: scenario.additionalPatches?.replay ?? [] : scenario.additionalPatches?.replay ?? []
const [parentFixture, ...childFixtures] = replayFixtures const [parentFixture, ...childFixtures] = replayFixtures
let childEnvironment: Record<string, string> = {}
if (scenario.dshSdkChild !== undefined) {
const childHome = join(cwd, '.child-dsh')
const childPatch = join(childHome, 'child.cordis.yml')
await mkdir(childHome, { recursive: true })
await writeFile(childPatch, (await readFile(scenario.dshSdkChild.config, 'utf8'))
.replace("'./child-mock-llm.ts'", JSON.stringify(pathToFileURL(dshSdkChildMockPath).href)))
childEnvironment = {
DSH_TEST_CHILD_PATCHES: JSON.stringify([childPatch]),
DSH_TEST_CHILD_HOME: childHome,
}
}
const env: Record<string, string> = { const env: Record<string, string> = {
...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined)) as Record<string, string>, ...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined)) as Record<string, string>,
DSH_SNAPSHOT: mode, DSH_SNAPSHOT: mode,
@@ -310,6 +363,7 @@ async function runScenario(scenario: SdkScenario): Promise<{
...childFixtures.length > 0 ? { DSH_SNAPSHOT_CHILD_FILES: childFixtures.join(delimiter) } : {}, ...childFixtures.length > 0 ? { DSH_SNAPSHOT_CHILD_FILES: childFixtures.join(delimiter) } : {},
}, },
...scenario.environment, ...scenario.environment,
...childEnvironment,
} }
const harness = new DeepSeekHarness({ const harness = new DeepSeekHarness({
@@ -324,8 +378,8 @@ async function runScenario(scenario: SdkScenario): Promise<{
env, env,
requestTimeoutMs: 110_000, requestTimeoutMs: 110_000,
cwd, cwd,
provider: 'deepseek-official', provider: scenario.sdkRoute?.provider ?? 'deepseek-official',
model: 'deepseek-v4-flash', model: scenario.sdkRoute?.model ?? 'deepseek-v4-flash',
}) })
try { try {
const notifications: HarnessNotification[] = [] const notifications: HarnessNotification[] = []
@@ -334,7 +388,12 @@ async function runScenario(scenario: SdkScenario): Promise<{
onNotification: (notification) => { notifications.push(notification) }, onNotification: (notification) => { notifications.push(notification) },
}) })
await harness.close() await harness.close()
const logs = await persistedLogs(sessionsRoot) const logs = (await Promise.all([
persistedLogs(sessionsRoot),
...(scenario.dshSdkChild === undefined
? []
: [persistedLogs(join(cwd, scenario.dshSdkChild.sessionRoot))]),
])).flat()
const observedFiles = Object.fromEntries(await Promise.all( const observedFiles = Object.fromEntries(await Promise.all(
Object.keys(scenario.expectedFiles ?? {}).map(async (path): Promise<[string, string | MissingFile]> => [ Object.keys(scenario.expectedFiles ?? {}).map(async (path): Promise<[string, string | MissingFile]> => [
path, path,
@@ -350,6 +409,10 @@ async function runScenario(scenario: SdkScenario): Promise<{
/** Order logs parent-first, children by creation time (fixture layout order). */ /** Order logs parent-first, children by creation time (fixture layout order). */
function orderLogs(logs: PersistedLog[], scenario: SdkScenario): PersistedLog[] { function orderLogs(logs: PersistedLog[], scenario: SdkScenario): PersistedLog[] {
if (scenario.dshSdkChild !== undefined) {
expect(logs).toHaveLength(scenario.children + 1)
return logs
}
const parents = logs.filter(log => typeof log.header.parentSession !== 'string') const parents = logs.filter(log => typeof log.header.parentSession !== 'string')
const children = logs.filter(log => typeof log.header.parentSession === 'string') const children = logs.filter(log => typeof log.header.parentSession === 'string')
.sort((left, right) => Number(left.header.createdAt) - Number(right.header.createdAt)) .sort((left, right) => Number(left.header.createdAt) - Number(right.header.createdAt))
@@ -480,7 +543,12 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => {
for (const clause of scenario.runtimeContext.includes) expect(system).not.toContain(clause) for (const clause of scenario.runtimeContext.includes) expect(system).not.toContain(clause)
} }
} }
if (scenario.children > 0) { if (scenario.dshSdkChild !== undefined) {
const child = ordered[1]
if (child === undefined) throw new Error(`${scenario.name} has no child session log`)
expect(assembledRequestConfig(child)).toEqual(scenario.dshSdkChild.expectedRoute)
}
if (scenario.children > 0 && scenario.dshSdkChild === undefined) {
expect(notifications.some(n => n.method === 'subagent.started')).toBe(true) expect(notifications.some(n => n.method === 'subagent.started')).toBe(true)
expect(notifications.some(n => n.method === 'subagent.finished')).toBe(true) expect(notifications.some(n => n.method === 'subagent.finished')).toBe(true)
} }
@@ -0,0 +1,28 @@
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Delegate once using the requested child route."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}}
{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"running"}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"Delegate once using the requested child route."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":5,"time":0,"data":{"title":"Delegate once using the requested","messageSeqs":[4],"source":{"kind":"fallback"}}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"mock","model":"mock-delegate"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"mock","model":"mock-delegate"}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call-delegate","name":"subagent","argumentsDelta":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}],"source":{"kind":"model","provider":"mock","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call-delegate"},"content":[{"type":"tool-result","toolCallId":"call-delegate","content":[{"type":"text","text":"child route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}}}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":167}}}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"source":{"kind":"model","provider":"mock","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":167}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":25,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}}
{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}}
@@ -0,0 +1 @@
{"sessionId":"{{sessionId}}","finalResponse":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}
@@ -0,0 +1,17 @@
{"type":"session","version":0,"id":"session-d9caef61eced4f94a2d4f6265020896e","createdAt":1787254273406,"cwd":"{{cwd}}","delegationDepth":0}
{"type":"agent/inbox/spliced","seq":0,"time":1787254273407,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"report your route and workspace"}],"source":{"kind":"user"},"role":"user","id":"18ece5d3-8dc4-4841-bc67-3287b1eb8ab2"}]}}
{"type":"turn/start","seq":1,"time":1787254273408,"data":{"turn":1}}
{"type":"agent/inbox/spliced","seq":2,"time":1787254273408,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","seq":3,"time":1787254273432,"data":{"turn":1,"step":1}}
{"type":"user/message","seq":4,"time":1787254273432,"data":{"content":[{"type":"text","text":"report your route and workspace"}],"source":{"kind":"user"},"role":"user","id":"18ece5d3-8dc4-4841-bc67-3287b1eb8ab2"},"surfaceOp":"append"}
{"type":"session/title","seq":5,"time":1787254273433,"data":{"title":"report your route and workspace","messageSeqs":[4],"source":{"kind":"fallback"}}}
{"type":"request/header","seq":6,"time":1787254273433,"data":{"header":{"config":{"provider":"mock","model":"mock-routed","reasoningEffort":"max","maxTokens":777},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","seq":7,"time":1787254273433,"data":{"provider":"mock","model":"mock-routed"}}
{"type":"assistant/chunk","seq":8,"time":1787254273438,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":9,"time":1787254273438,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"child route: mock/mock-routed/max/777; cwd: {{cwd}}"}}}
{"type":"assistant/chunk","seq":10,"time":1787254273438,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"child route: mock/mock-routed/max/777; cwd: {{cwd}}"}}}}
{"type":"assistant/chunk","seq":11,"time":1787254273438,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":151}}}}
{"type":"assistant/chunk","seq":12,"time":1787254273438,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":13,"time":1787254273438,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"child route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"source":{"kind":"model","provider":"mock","model":"mock-routed"},"id":"0f6dd3aa-2d48-4c64-9130-4812a99e1e31"},"usage":{"inputTokens":3,"outputTokens":151}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"}
{"type":"step/end","seq":14,"time":1787254273438,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":15,"time":1787254273438,"data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,27 @@
{"type":"session","version":0,"id":"sdk-snapshot-dsh-sdk","createdAt":1787254272178,"cwd":"{{cwd}}","delegationDepth":0}
{"type":"agent/inbox/spliced","seq":0,"time":1787254272180,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Delegate once using the requested child route."}],"source":{"kind":"user"},"role":"user","id":"7f17592a-8a93-4bb4-851c-f5e942372b06"}]}}
{"type":"turn/start","seq":1,"time":1787254272180,"data":{"turn":1}}
{"type":"agent/inbox/spliced","seq":2,"time":1787254272180,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","seq":3,"time":1787254272210,"data":{"turn":1,"step":1}}
{"type":"user/message","seq":4,"time":1787254272210,"data":{"content":[{"type":"text","text":"Delegate once using the requested child route."}],"source":{"kind":"user"},"role":"user","id":"7f17592a-8a93-4bb4-851c-f5e942372b06"},"surfaceOp":"append"}
{"type":"session/title","seq":5,"time":1787254272211,"data":{"title":"Delegate once using the requested","messageSeqs":[4],"source":{"kind":"fallback"}}}
{"type":"request/header","seq":6,"time":1787254272211,"data":{"header":{"config":{"provider":"mock","model":"mock-delegate"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","seq":7,"time":1787254272211,"data":{"provider":"mock","model":"mock-delegate"}}
{"type":"assistant/chunk","seq":8,"time":1787254272214,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":9,"time":1787254272215,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call-delegate","name":"subagent","argumentsDelta":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}
{"type":"assistant/chunk","seq":10,"time":1787254272215,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}}
{"type":"assistant/chunk","seq":11,"time":1787254272215,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":12,"time":1787254272215,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":13,"time":1787254272215,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}],"source":{"kind":"model","provider":"mock","model":"mock-delegate"},"id":"09f8526a-e0ff-493f-8f43-b7f6b5558541"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"}
{"type":"tool/call","seq":14,"time":1787254272215,"data":{"turn":1,"step":1,"callId":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}
{"type":"tool/result","seq":15,"time":1787254273451,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call-delegate"},"content":[{"type":"tool-result","toolCallId":"call-delegate","content":[{"type":"text","text":"child route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"isError":false}],"role":"user","id":"321d12a1-7801-4614-b800-c8c7ff267f52"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
{"type":"step/end","seq":16,"time":1787254273451,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":17,"time":1787254273455,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":18,"time":1787254273459,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":19,"time":1787254273460,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}}}
{"type":"assistant/chunk","seq":20,"time":1787254273460,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}}}}
{"type":"assistant/chunk","seq":21,"time":1787254273460,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":167}}}}
{"type":"assistant/chunk","seq":22,"time":1787254273460,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":23,"time":1787254273460,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"source":{"kind":"model","provider":"mock","model":"mock-delegate"},"id":"373cadb7-313d-44e7-a31a-ec0a675e0255"},"usage":{"inputTokens":10,"outputTokens":167}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"}
{"type":"step/end","seq":24,"time":1787254273460,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":25,"time":1787254273460,"data":{"turn":1,"reason":{"kind":"completed"}}}
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/sdk/client/README.md # pnpm run verify-translation-pairing --write packages/sdk/client/README.md
README.md: bf4f6bcaf2f0928cc7aa95d18f0cbbff3cdbe37d README.md: bff8e3e3257a068a8137c49a3271cbc709226c7b
README.zh.md: ba64ab19c6ba1e9ad685585330fe8369b2ccb381 README.zh.md: b71e9f2e135995216a58d1b4b6c9b88d1a50fefe
+4 -2
View File
@@ -12,21 +12,23 @@ Composition customization stays in the profile system. Install persistent bundle
```ts ```ts
import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client'
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
await using harness = new DeepSeekHarness({ await using harness = new DeepSeekHarness({
profile: 'sdk', profile: 'sdk',
patches: ['./automation.cordis.yml'], patches: ['./automation.cordis.yml'],
provider: 'deepseek-official', provider: 'deepseek-official',
model: 'deepseek-v4-flash', model: 'deepseek-v4-flash',
reasoningEffort: ReasoningEffortId('max'),
maxTokens: 49_152, maxTokens: 49_152,
}) })
const result = await harness.run('say hi') const result = await harness.run('say hi')
console.log(result.finalResponse) console.log(result.finalResponse)
``` ```
The dsh process starts lazily on first use and stays owned across `run()` calls. `close()` (or `await using`) is required. `start()` memoizes the bounded `initialize` handshake; `initializeTimeoutMs` defaults to 10 seconds and its diagnostic names the selected profile with the retained stderr tail. A failed handshake reaps the runtime and lets a later call retry with a fresh process until terminal `close()`. The dsh process starts lazily on first use and stays owned across `run()` calls. `close()` (or `await using`) is required. `start()` memoizes the bounded `initialize` handshake, which carries the workspace cwd, provider/model route, optional adapter-owned `reasoningEffort`, and optional positive `maxTokens` output cap. `initializeTimeoutMs` defaults to 10 seconds, and its diagnostic names the selected profile with the retained stderr tail. The server validates the exact route before accepting prompts; omitting the effort preserves the model's own default. A failed handshake reaps the runtime and lets a later call retry with a fresh process until terminal `close()`. The cap applies to each root-agent request and is inherited by in-process descendants; compaction plugins own their separate summary limits. `session(id?)` opens a named or fresh session handle.
The handshake carries the absolute session workspace plus provider/model and optional positive `maxTokens`. `run(input, { sessionId?, onNotification? })` queues a prompt, waits for its durable inbox receipt, and collects until the whole root agent next becomes idle. It returns `RunResult { sessionId, finalResponse, events, notifications }`; `events` is root-scoped, while notifications also contain discovered descendants. The handshake carries the absolute session workspace plus provider/model, optional `reasoningEffort`, and optional positive `maxTokens`. `run(input, { sessionId?, onNotification? })` queues a prompt, waits for its durable inbox receipt, and collects until the whole root agent next becomes idle. It returns `RunResult { sessionId, finalResponse, events, notifications }`; `events` is root-scoped, while notifications also contain discovered descendants.
## HarnessClient ## HarnessClient
+4 -2
View File
@@ -12,21 +12,23 @@
```ts ```ts
import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client'
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
await using harness = new DeepSeekHarness({ await using harness = new DeepSeekHarness({
profile: 'sdk', profile: 'sdk',
patches: ['./automation.cordis.yml'], patches: ['./automation.cordis.yml'],
provider: 'deepseek-official', provider: 'deepseek-official',
model: 'deepseek-v4-flash', model: 'deepseek-v4-flash',
reasoningEffort: ReasoningEffortId('max'),
maxTokens: 49_152, maxTokens: 49_152,
}) })
const result = await harness.run('say hi') const result = await harness.run('say hi')
console.log(result.finalResponse) console.log(result.finalResponse)
``` ```
dsh 进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须调用 `close()`(或使用 `await using`)。`start()` 会记忆化有界的 `initialize` 握手`initializeTimeoutMs` 默认 10 秒,诊断会写明所选 profile 并附带保留的 stderr 尾部。握手失败会回收 runtime,之后的调用可以用新进程重试,直至终结性的 `close()` dsh 进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须调用 `close()`(或使用 `await using`)。`start()` 会记忆化有界的 `initialize` 握手,其中包含工作区 cwd、提供方/模型路由、可选且由适配器持有的 `reasoningEffort`,以及可选的正整数 `maxTokens` 输出上限。`initializeTimeoutMs` 默认 10 秒,诊断会写明所选 profile 并附带保留的 stderr 尾部。服务器会在接受提示词前校验确切路由;省略推理强度时保留模型自身的默认值。握手失败会回收运行时,之后的调用可以用新进程重试,直至终结性的 `close()`。该上限作用于根 agent(智能体)的每次请求,并由进程内后代继承;压缩(compaction)插件单独持有摘要上限。`session(id?)` 打开具名或全新的会话句柄
握手携带绝对 session workspace、provider/model 和可选的正整数 `maxTokens``run(input, { sessionId?, onNotification? })` 将 prompt 入队,等待持久 inbox 回执,并收集到整个根 agent 下次 idle。它返回 `RunResult { sessionId, finalResponse, events, notifications }``events` 仅限根 sessionnotification 还包括发现的后代。 握手携带绝对 session workspace、provider/model、可选的 `reasoningEffort` 和可选的正整数 `maxTokens``run(input, { sessionId?, onNotification? })` 将 prompt 入队,等待持久 inbox 回执,并收集到整个根 agent 下次 idle。它返回 `RunResult { sessionId, finalResponse, events, notifications }``events` 仅限根 sessionnotification 还包括发现的后代。
## HarnessClient ## HarnessClient
+4 -1
View File
@@ -25,11 +25,12 @@ export class DeepSeekHarness implements AsyncDisposable {
private readonly cwd: string private readonly cwd: string
private readonly provider: string private readonly provider: string
private readonly model: string private readonly model: string
private readonly reasoningEffort: DeepSeekHarnessOptions['reasoningEffort']
private readonly maxTokens: number | undefined private readonly maxTokens: number | undefined
private initialized: Promise<void> | undefined private initialized: Promise<void> | undefined
private closed = false private closed = false
/** @param options - dsh launch configuration plus the session route. */ /** @param options - dsh launch configuration plus the session route, effort, and output cap. */
constructor(options?: DeepSeekHarnessOptions) constructor(options?: DeepSeekHarnessOptions)
constructor(options: DeepSeekHarnessOptions = {}, clientFactory?: () => HarnessClient) { constructor(options: DeepSeekHarnessOptions = {}, clientFactory?: () => HarnessClient) {
this.createClient = clientFactory ?? (() => new HarnessClient(options)) this.createClient = clientFactory ?? (() => new HarnessClient(options))
@@ -40,6 +41,7 @@ export class DeepSeekHarness implements AsyncDisposable {
this.cwd = resolve(options.cwd ?? options.processCwd ?? process.cwd()) this.cwd = resolve(options.cwd ?? options.processCwd ?? process.cwd())
this.provider = options.provider ?? 'deepseek-official' this.provider = options.provider ?? 'deepseek-official'
this.model = options.model ?? 'deepseek-v4-flash' this.model = options.model ?? 'deepseek-v4-flash'
this.reasoningEffort = options.reasoningEffort
this.maxTokens = options.maxTokens this.maxTokens = options.maxTokens
} }
@@ -68,6 +70,7 @@ export class DeepSeekHarness implements AsyncDisposable {
cwd: this.cwd, cwd: this.cwd,
provider: this.provider, provider: this.provider,
model: this.model, model: this.model,
...this.reasoningEffort === undefined ? {} : { reasoningEffort: this.reasoningEffort },
...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens }, ...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens },
}) })
} catch (error) { } catch (error) {
+3 -1
View File
@@ -5,7 +5,7 @@
* @module @deepseek-ai/dsh-sdk-client/types * @module @deepseek-ai/dsh-sdk-client/types
*/ */
import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { ContentBlock, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session'
/** One server-to-client notification as received off the wire. */ /** One server-to-client notification as received off the wire. */
@@ -59,6 +59,8 @@ export interface DeepSeekHarnessOptions extends HarnessClientOptions {
provider?: string provider?: string
/** Model for SDK-created agents (default `deepseek-v4-flash`). */ /** Model for SDK-created agents (default `deepseek-v4-flash`). */
model?: string model?: string
/** Adapter-owned reasoning effort for the selected provider/model route. */
reasoningEffort?: ReasoningEffortId
/** Maximum output tokens for each conversation-model request. */ /** Maximum output tokens for each conversation-model request. */
maxTokens?: number maxTokens?: number
} }
+4 -1
View File
@@ -10,6 +10,7 @@ import { tmpdir } from 'node:os'
import { isAbsolute, join, relative, resolve as resolvePath } from 'node:path' import { isAbsolute, join, relative, resolve as resolvePath } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { import {
DeepSeekHarness, DeepSeekHarness,
HarnessClient, HarnessClient,
@@ -155,13 +156,14 @@ describe('DeepSeekHarness', () => {
await harness.close() await harness.close()
}) })
it('sends the configured cwd/provider/model/maxTokens in the handshake exactly once', async () => { it('sends the configured cwd/provider/model/reasoningEffort/maxTokens in the handshake exactly once', async () => {
const dir = await tempDir('sdk-client-init-') const dir = await tempDir('sdk-client-init-')
const recordFile = join(dir, 'init.jsonl') const recordFile = join(dir, 'init.jsonl')
const harness = createProcessDeepSeekHarness(fakeLaunch({ FAKE_RECORD_INIT: recordFile }), { const harness = createProcessDeepSeekHarness(fakeLaunch({ FAKE_RECORD_INIT: recordFile }), {
cwd: dir, cwd: dir,
provider: 'custom-provider', provider: 'custom-provider',
model: 'custom-model', model: 'custom-model',
reasoningEffort: ReasoningEffortId('max'),
maxTokens: 4096, maxTokens: 4096,
}) })
cleanups.push(() => harness.close()) cleanups.push(() => harness.close())
@@ -173,6 +175,7 @@ describe('DeepSeekHarness', () => {
cwd: dir, cwd: dir,
provider: 'custom-provider', provider: 'custom-provider',
model: 'custom-model', model: 'custom-model',
reasoningEffort: 'max',
maxTokens: 4096, maxTokens: 4096,
}]) }])
}) })
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/sdk/protocol/README.md # pnpm run verify-translation-pairing --write packages/sdk/protocol/README.md
README.md: 9024f9ca34a5467aff1b83cb9cd864c1ec06e56b README.md: fd96d2684bbbb9b06efa71fec23d49a8aacded06
README.zh.md: 28372bf3dcd57a817225a2769ef969d6f0c10936 README.zh.md: 8a201d82e46c49a4a458b3caeea5a05f93a49736
+1 -1
View File
@@ -22,7 +22,7 @@ The shared wire protocol for the DeepSeek Harness SDK runtime: one newline-delim
| server→client | `subagent.started` | `SubagentStartedNotification` | | server→client | `subagent.started` | `SubagentStartedNotification` |
| server→client | `subagent.finished` | `SubagentFinishedNotification` (in-process runs only) | | server→client | `subagent.finished` | `SubagentFinishedNotification` (in-process runs only) |
`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `SessionPromptResult.messageId` identifies the queued `UserMessage`; it does not identify a later assistant message, turn ending, or prompt result. Clients combine the open-ended `session.event` stream with agent-wide `session.status` according to their own activity ownership. `SubagentFinishedNotification.lastAssistantMessage` contains the child's last non-empty assistant message or, when no such message exists, its accumulated assistant text; the field is absent when the child produced neither. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission allows the selected adapter's exact-model default to apply, or otherwise preserves provider behavior. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`. `HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `SessionPromptResult.messageId` identifies the queued `UserMessage`; it does not identify a later assistant message, turn ending, or prompt result. Clients combine the open-ended `session.event` stream with agent-wide `session.status` according to their own activity ownership. `SubagentFinishedNotification.lastAssistantMessage` contains the child's last non-empty assistant message or, when no such message exists, its accumulated assistant text; the field is absent when the child produced neither. `InitializeParams.reasoningEffort` is an optional non-empty adapter-owned identifier for the selected provider/model route; omission preserves that model's own default. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission allows the selected adapter's exact-model default to apply, or otherwise preserves provider behavior. The server resolves the exact route during initialization, so a missing adapter, unavailable model, or unsupported effort rejects before any session prompt. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`.
## Model Experience ## Model Experience
+1 -1
View File
@@ -22,7 +22,7 @@ DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按
| server→client | `subagent.started` | `SubagentStartedNotification` | | server→client | `subagent.started` | `SubagentStartedNotification` |
| server→client | `subagent.finished` | `SubagentFinishedNotification`(仅进程内运行) | | server→client | `subagent.finished` | `SubagentFinishedNotification`(仅进程内运行) |
`HarnessSdkRequestMap``HarnessSdkNotificationMap` 按方法名索引这些类型。`SessionPromptResult.messageId` 标识已排队的 `UserMessage`;它不标识后续的助手消息、轮次结束或提示词结果。客户端根据自己对活动区间的所有权,组合持续开放的 `session.event` 流与 agent 级的 `session.status``SubagentFinishedNotification.lastAssistantMessage` 包含子 agent 最后一条非空 assistant 消息;若不存在这类消息,则包含其累积的 assistant 文本;子 agent 两种输出均未产生时,该字段缺省。`InitializeParams.maxTokens` 是可选的正安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时会应用所选适配器的确切模型默认值,否则提供方行为保持不变。通知载荷类型依赖 `SessionEvent``dsh-session`)、`ContentBlock``dsh-llm`)与 `SubagentStopReason``dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式约定的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime` `HarnessSdkRequestMap``HarnessSdkNotificationMap` 按方法名索引这些类型。`SessionPromptResult.messageId` 标识已排队的 `UserMessage`;它不标识后续的助手消息、轮次结束或提示词结果。客户端根据自己对活动区间的所有权,组合持续开放的 `session.event` 流与 agent 级的 `session.status``SubagentFinishedNotification.lastAssistantMessage` 包含子 agent 最后一条非空 assistant 消息;若不存在这类消息,则包含其累积的 assistant 文本;子 agent 两种输出均未产生时,该字段缺省。`InitializeParams.reasoningEffort` 是所选提供方/模型路由可选的非空适配器自有标识符;省略时保留该模型自身的默认值。`InitializeParams.maxTokens` 是可选的正安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时会应用所选适配器的确切模型默认值,否则提供方行为保持不变。服务器会在初始化期间解析确切路由,因此缺少适配器、模型不可用或推理强度不受支持时,会在任何会话提示词进入前拒绝。通知载荷类型依赖 `SessionEvent``dsh-session`)、`ContentBlock``dsh-llm`)与 `SubagentStopReason``dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式约定的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`
## 模型体验 ## 模型体验
+3 -1
View File
@@ -8,7 +8,7 @@
* @module @deepseek-ai/dsh-sdk-protocol/types * @module @deepseek-ai/dsh-sdk-protocol/types
*/ */
import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { ContentBlock, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { SubagentStopReason } from '@deepseek-ai/dsh-subagent' import type { SubagentStopReason } from '@deepseek-ai/dsh-subagent'
@@ -20,6 +20,8 @@ export interface InitializeParams {
provider: string provider: string
/** Model name every SDK-created agent runs on (the server may mount a fallback adapter; see `HarnessSdkJsonRpcServer.initialize`). */ /** Model name every SDK-created agent runs on (the server may mount a fallback adapter; see `HarnessSdkJsonRpcServer.initialize`). */
model: string model: string
/** Optional adapter-owned reasoning effort for the selected provider/model route. */
reasoningEffort?: ReasoningEffortId
/** Optional positive output-token cap inherited by SDK-created agents and their in-process descendants. */ /** Optional positive output-token cap inherited by SDK-created agents and their in-process descendants. */
maxTokens?: number maxTokens?: number
} }
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/sdk/server/README.md # pnpm run verify-translation-pairing --write packages/sdk/server/README.md
README.md: 2fac60b9313c66a8eb1653adb02405f2f5fe4b08 README.md: d98e1052de09dc38d92899b83954eda55d4f9ca3
README.zh.md: ed2c18f0a2ee96fdacdf0d998314d349f0b0264b README.zh.md: 51ec41c3b49ddc40628064501ef38a7469d2eaf7
+2 -2
View File
@@ -6,7 +6,7 @@ The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-proc
## Wiring ## Wiring
`inject: ['agents']`. The server gets or creates one agent per `sessionId`. It forwards subagent completions only when the service-snapshotted lifecycle `local` flag is true; provider names, child ids, and durable lineage never establish locality. A registered adapter wins, an unowned `deepseek-official` route mounts `dsh-llm-deepseek`, and any other unowned provider fails initialization. Other capabilities come from the surrounding Loader composition. `inject: ['agents']`. The server gets or creates one agent per `sessionId`. It forwards subagent completions only when the service-snapshotted lifecycle `local` flag is true; provider names, child ids, and durable lineage never establish locality. A registered adapter wins, an unowned `deepseek-official` route mounts `dsh-llm-deepseek`, and any other unowned provider fails initialization. The selected adapter resolves the exact model and optional reasoning effort before initialization succeeds. Other capabilities come from the surrounding Loader composition.
## Config ## Config
@@ -22,7 +22,7 @@ The plugin answers `shutdown`, flushes the response, disposes the root context s
## Wire notes ## Wire notes
`initialize` is the runtime-readiness boundary: when the server is mounted by a Loader composition, it waits for the current plugin tree to settle before replying, so async sibling capabilities such as initial MCP tool discovery are visible to the first prompt. Hand-built contexts without Loader remain immediately usable. `initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no SDK cap and allows the selected adapter or provider route default to apply. `session/prompt` queues one identified user message and immediately returns `{ messageId }`. The server streams every durable fact as `session.event` and every whole-agent lifecycle transition as `session.status`; it does not assign an assistant message or `turn/end` to that prompt. Independent requests may enqueue more work on the same session. Persistence roots and persona come from the surrounding composition. `initialize` is the runtime-readiness boundary: when the server is mounted by a Loader composition, it waits for the current plugin tree to settle before replying, so async sibling capabilities such as initial MCP tool discovery are visible to the first prompt. Hand-built contexts without Loader remain immediately usable. `initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. The server validates the provider/model route and optional non-empty `reasoningEffort` through the selected adapter before storing them; omission stores no effort, so the model retains its own default. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no SDK cap and allows the selected adapter or provider route default to apply. `session/prompt` queues one identified user message and immediately returns `{ messageId }`. The server streams every durable fact as `session.event` and every whole-agent lifecycle transition as `session.status`; it does not assign an assistant message or `turn/end` to that prompt. Independent requests may enqueue more work on the same session. Persistence roots and persona come from the surrounding composition.
## Model Experience ## Model Experience
+2 -2
View File
@@ -6,7 +6,7 @@
## 组装 ## 组装
`inject: ['agents']`。服务器按 `sessionId` 获取或创建一个 agent。只有服务对生命周期建立快照时记录的 `local` 标志为 true,服务器才会转发 subagent 完成事件;提供方名称、子级 id 和持久化谱系均不能证明本地性。已注册的适配器优先;尚无适配器负责的 `deepseek-official` 路由会挂载 `dsh-llm-deepseek`,任何其他尚无适配器负责的提供方都会导致初始化失败。其他能力由外围 Loader 组合提供。 `inject: ['agents']`。服务器按 `sessionId` 获取或创建一个 agent。只有服务对生命周期建立快照时记录的 `local` 标志为 true,服务器才会转发 subagent 完成事件;提供方名称、子级 id 和持久化谱系均不能证明本地性。已注册的适配器优先;尚无适配器负责的 `deepseek-official` 路由会挂载 `dsh-llm-deepseek`,任何其他尚无适配器负责的提供方都会导致初始化失败。初始化成功前,所选适配器会解析确切模型与可选推理强度。其他能力由外围 Loader 组合提供。
## 配置 ## 配置
@@ -22,7 +22,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写
## 协议说明 ## 协议说明
`initialize` 是运行时就绪边界:服务器由 Loader 组合挂载时,会等待当前插件树完成所有加载任务后再响应,因此首次提示词能够看到 MCP 初始工具发现等异步同级能力。没有 Loader 的手工组装上下文仍可立即使用。`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送 SDK 上限,并应用所选适配器或提供方路由的默认值。`session/prompt` 将一条带标识的用户消息排入队列,并立即返回 `{ messageId }`。服务器将每个持久事实作为 `session.event` 流式发出,并将整个 agent 生命周期的每次状态转换作为 `session.status` 发出;它不会把某条助手消息或 `turn/end` 归属于该提示词。同一会话上的独立请求可以继续排入更多工作。持久化根目录和 persona 由外围组合提供。 `initialize` 是运行时就绪边界:服务器由 Loader 组合挂载时,会等待当前插件树完成所有加载任务后再响应,因此首次提示词能够看到 MCP 初始工具发现等异步同级能力。没有 Loader 的手工组装上下文仍可立即使用。`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`服务器会通过所选适配器校验提供方/模型路由与可选的非空 `reasoningEffort`,再保存这些值;省略时不会保存推理强度,因此模型保留自身默认值。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送 SDK 上限,并应用所选适配器或提供方路由的默认值。`session/prompt` 将一条带标识的用户消息排入队列,并立即返回 `{ messageId }`。服务器将每个持久事实作为 `session.event` 流式发出,并将整个 agent 生命周期的每次状态转换作为 `session.status` 发出;它不会把某条助手消息或 `turn/end` 归属于该提示词。同一会话上的独立请求可以继续排入更多工作。持久化根目录和 persona 由外围组合提供。
## 模型体验 ## 模型体验
+29 -8
View File
@@ -8,7 +8,7 @@
import type { Context } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm' import { createUserMessage, ReasoningEffortId, type LlmRuntime } from '@deepseek-ai/dsh-llm'
import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope' import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope'
import { SessionId } from '@deepseek-ai/dsh-session' import { SessionId } from '@deepseek-ai/dsh-session'
import type SubagentRuntime from '@deepseek-ai/dsh-subagent' import type SubagentRuntime from '@deepseek-ai/dsh-subagent'
@@ -57,6 +57,7 @@ export class HarnessSdkJsonRpcServer {
private cwd = process.cwd() private cwd = process.cwd()
private provider = 'deepseek-official' private provider = 'deepseek-official'
private model = 'deepseek-official' private model = 'deepseek-official'
private reasoningEffort: ReturnType<typeof ReasoningEffortId> | undefined
private maxTokens: number | undefined private maxTokens: number | undefined
private llmFiber: { dispose(): Promise<void> } | undefined private llmFiber: { dispose(): Promise<void> } | undefined
private readonly sessions = new Map<string, SessionRecord>() private readonly sessions = new Map<string, SessionRecord>()
@@ -107,23 +108,42 @@ export class HarnessSdkJsonRpcServer {
} }
/** /**
* Configure the SDK route, mounting the DeepSeek fallback only when unowned. * Validate and configure the SDK route, mounting the DeepSeek fallback only when unowned.
* @param params - SDK handshake parameters. * @param params - SDK handshake parameters.
* @returns server identity for the handshake. * @returns server identity for the handshake.
*/ */
async initialize(params: InitializeParams): Promise<InitializeResult> { async initialize(params: InitializeParams): Promise<InitializeResult> {
if (params.reasoningEffort !== undefined
&& (typeof params.reasoningEffort !== 'string' || params.reasoningEffort.length === 0)) {
throw new TypeError('initialize reasoningEffort must be a non-empty string')
}
if (params.maxTokens !== undefined if (params.maxTokens !== undefined
&& (!Number.isSafeInteger(params.maxTokens) || params.maxTokens <= 0)) { && (!Number.isSafeInteger(params.maxTokens) || params.maxTokens <= 0)) {
throw new TypeError('initialize maxTokens must be a positive safe integer') throw new TypeError('initialize maxTokens must be a positive safe integer')
} }
this.cwd = resolve(params.cwd) const cwd = resolve(params.cwd)
this.provider = params.provider const provider = params.provider
this.model = params.model const model = params.model
this.maxTokens = params.maxTokens const reasoningEffort = params.reasoningEffort === undefined
if (!this.hasAdapterFor(this.provider)) { ? undefined
if (this.provider !== 'deepseek-official') throw new Error(`no adapter registered for provider "${this.provider}"`) : ReasoningEffortId(params.reasoningEffort)
if (!this.hasAdapterFor(provider)) {
if (provider !== 'deepseek-official') throw new Error(`no adapter registered for provider "${provider}"`)
this.llmFiber = await this.ctx.plugin(LlmDeepSeek, {}) this.llmFiber = await this.ctx.plugin(LlmDeepSeek, {})
} }
// Adapter presence was read from this service above; a successful fallback mount also requires it.
const llm = this.ctx.get('llm') as LlmRuntime
await llm.resolveCallConfig({
provider,
model,
...reasoningEffort === undefined ? {} : { reasoningEffort },
...params.maxTokens === undefined ? {} : { maxTokens: params.maxTokens },
})
this.cwd = cwd
this.provider = provider
this.model = model
this.reasoningEffort = reasoningEffort
this.maxTokens = params.maxTokens
return { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } } return { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }
} }
@@ -230,6 +250,7 @@ export class HarnessSdkJsonRpcServer {
agentOptions: { agentOptions: {
provider: this.provider, provider: this.provider,
model: this.model, model: this.model,
...this.reasoningEffort === undefined ? {} : { reasoningEffort: this.reasoningEffort },
...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens }, ...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens },
}, },
...toolFilter === undefined ...toolFilter === undefined
+93 -6
View File
@@ -1,4 +1,5 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm' import { createUserMessage, LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import { createServer } from 'node:http' import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http' import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { mkdtemp, rm } from 'node:fs/promises' import { mkdtemp, rm } from 'node:fs/promises'
@@ -124,6 +125,7 @@ describe('HarnessSdkJsonRpcServer', () => {
cwd: storageDir, cwd: storageDir,
provider: 'deepseek-official', provider: 'deepseek-official',
model: 'dsagent-model', model: 'dsagent-model',
reasoningEffort: 'max',
maxTokens: 321, maxTokens: 321,
}) as { serverInfo: { name: string } } }) as { serverInfo: { name: string } }
expect(init.serverInfo.name).toBe('deepseek-harness-sdk-runtime') expect(init.serverInfo.name).toBe('deepseek-harness-sdk-runtime')
@@ -135,8 +137,14 @@ describe('HarnessSdkJsonRpcServer', () => {
expect((receipt as { messageId?: unknown }).messageId).toBeTypeOf('string') expect((receipt as { messageId?: unknown }).messageId).toBeTypeOf('string')
await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(1) }) await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(1) })
const body = llmServer.requests[0] as { model: string; messages: { role: string }[]; max_tokens?: number } const body = llmServer.requests[0] as {
model: string
messages: { role: string }[]
reasoning_effort?: string
max_tokens?: number
}
expect(body.model).toBe('dsagent-model') expect(body.model).toBe('dsagent-model')
expect(body.reasoning_effort).toBe('max')
expect(body.max_tokens).toBe(321) expect(body.max_tokens).toBe(321)
expect(body.messages[0]?.role).toBe('system') expect(body.messages[0]?.role).toBe('system')
expect(body.messages.at(-1)?.role).toBe('user') expect(body.messages.at(-1)?.role).toBe('user')
@@ -877,6 +885,73 @@ describe('HarnessSdkJsonRpcServer', () => {
}, },
) )
it.each(['', 42])(
'rejects invalid initialize reasoningEffort %j at the wire boundary',
async (reasoningEffort) => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-invalid-reasoning-'))
const ctx = await makeHarness(storageDir)
try {
const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
await expect(server.handleRequest('initialize', {
cwd: storageDir,
provider: 'deepseek-official',
model: 'model',
reasoningEffort,
})).rejects.toThrow('initialize reasoningEffort must be a non-empty string')
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
},
)
it('rejects an unavailable exact model during initialize', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-invalid-route-'))
const ctx = await makeHarness(storageDir)
class RejectingAdapter extends LlmAdapter {
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.reject(new Error(`model unavailable: ${provider}/${model}`))
}
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
throw new Error('unreachable')
}
}
const disposeAdapter = ctx.llm.registerAdapter(['private'], new RejectingAdapter())
try {
const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
await expect(server.initialize({ cwd: storageDir, provider: 'private', model: 'missing' }))
.rejects.toThrow('model unavailable: private/missing')
expect((server as unknown as { sessions: Map<string, unknown> }).sessions.size).toBe(0)
await server.shutdown()
} finally {
disposeAdapter()
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('rejects an unsupported reasoning effort during initialize', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-unsupported-reasoning-'))
const ctx = await makeHarness(storageDir)
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
try {
const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
await expect(server.handleRequest('initialize', {
cwd: storageDir,
provider: 'deepseek-official',
model: 'deepseek-v4-flash',
reasoningEffort: 'impossible',
})).rejects.toThrow('does not support reasoning effort "impossible"')
expect((server as unknown as { sessions: Map<string, unknown> }).sessions.size).toBe(0)
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('reports no adapter when the LLM service is absent', async () => { it('reports no adapter when the LLM service is absent', async () => {
const ctx = new Context() const ctx = new Context()
try { try {
@@ -948,23 +1023,35 @@ describe('HarnessSdkJsonRpcServer', () => {
it('resolves a relative cwd before creating the session', async () => { it('resolves a relative cwd before creating the session', async () => {
const create = vi.fn<(options: unknown) => Promise<AgentHandle>>() const create = vi.fn<(options: unknown) => Promise<AgentHandle>>()
.mockResolvedValue({ agent: {} as Agent, dispose: () => Promise.resolve() }) .mockResolvedValue({ agent: {} as Agent, dispose: () => Promise.resolve() })
const resolveCallConfig = vi.fn(async (config: unknown) => config)
const ctx = { const ctx = {
on: vi.fn(() => () => undefined), on: vi.fn(() => () => undefined),
agents: { create, get: () => undefined }, agents: { create, get: () => undefined },
get: () => ({ listProviders: () => [{ id: 'mock', name: 'Mock' }] }), get: () => ({ listProviders: () => [{ id: 'mock', name: 'Mock' }], resolveCallConfig }),
} as unknown as Context } as unknown as Context
const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport()) as unknown as { const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport()) as unknown as {
initialize(params: { cwd: string; provider: string; model: string; maxTokens?: number }): Promise<unknown> initialize(params: { cwd: string; provider: string; model: string; reasoningEffort?: string; maxTokens?: number }): Promise<unknown>
getOrCreateSession(sessionId: string): Promise<unknown> getOrCreateSession(sessionId: string): Promise<unknown>
shutdown(): Promise<Record<string, never>> shutdown(): Promise<Record<string, never>>
} }
await server.initialize({ cwd: '.', provider: 'mock', model: 'model', maxTokens: 123 }) await server.initialize({ cwd: '.', provider: 'mock', model: 'model', reasoningEffort: 'high', maxTokens: 123 })
await server.getOrCreateSession('relative') await server.getOrCreateSession('relative')
expect(resolveCallConfig).toHaveBeenCalledWith({
provider: 'mock',
model: 'model',
reasoningEffort: ReasoningEffortId('high'),
maxTokens: 123,
})
expect(create).toHaveBeenCalledWith(expect.objectContaining({ expect(create).toHaveBeenCalledWith(expect.objectContaining({
meta: { cwd: process.cwd() }, meta: { cwd: process.cwd() },
agentOptions: { provider: 'mock', model: 'model', maxTokens: 123 }, agentOptions: {
provider: 'mock',
model: 'model',
reasoningEffort: ReasoningEffortId('high'),
maxTokens: 123,
},
})) }))
await server.shutdown() await server.shutdown()
}) })
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent-dsh-sdk/README.md # pnpm run verify-translation-pairing --write packages/subagent/subagent-dsh-sdk/README.md
README.md: 302baa05afed2b78c2041f57b1ef900713e350cd README.md: fa715e8deed5bea81e7601510a20883df9ae90e1
README.zh.md: e4e1460274170b0c990d9e454f1cbf54546676e9 README.zh.md: 953fe0943e5bf5b61be49273c57c25b6e020c0d9
+6 -4
View File
@@ -6,7 +6,7 @@ The SDK provider runs each subagent as a complete DeepSeek Harness runtime in a
## Start and ownership ## Start and ownership
`start(request)` resolves the child's working directory, spawns the runtime through `DeepSeekHarness`, and completes the `initialize` handshake (with the configured `provider`/`model` route and optional `maxTokens` output cap) before it fulfills. Fulfillment therefore means the child runtime is ready and ownership has transferred to the caller. A spawn, handshake, or pre-publication cancellation failure rejects only after the subprocess has been reaped; a working-directory resolution failure rejects before anything is spawned. `start(request)` resolves the child's working directory and one process-wide SDK route before spawning. Each declared `request.agentOptions` field (`provider`, `model`, `reasoningEffort`, or `maxTokens`) overrides the matching provider-instance default; omission preserves the configured provider/model and optional cap, while reasoning effort remains omitted unless the request supplies it. The provider then spawns through `DeepSeekHarness` and completes the child runtime's `initialize` handshake, including exact-model and effort validation, before it fulfills. Fulfillment therefore means the child runtime is ready and ownership has transferred to the caller. A route, spawn, handshake, or pre-publication cancellation failure rejects only after the subprocess has been reaped; a working-directory resolution failure rejects before anything is spawned.
The working directory resolves exactly like the ACP backend, through the seam's shared out-of-process helpers ([`dsh-subagent`](../subagent/README.md)): the configured `cwd` override when set (validated once at load), else the delegating parent session's cwd — never the server process's own cwd. The resolved path becomes the child process cwd and the workspace cwd of its SDK session. `dshHome` is separately required as an absolute path so a nested runtime cannot accidentally share its parent's profiles, plugin installation, or session storage. The working directory resolves exactly like the ACP backend, through the seam's shared out-of-process helpers ([`dsh-subagent`](../subagent/README.md)): the configured `cwd` override when set (validated once at load), else the delegating parent session's cwd — never the server process's own cwd. The resolved path becomes the child process cwd and the workspace cwd of its SDK session. `dshHome` is separately required as an absolute path so a nested runtime cannot accidentally share its parent's profiles, plugin installation, or session storage.
@@ -20,7 +20,7 @@ The SDK client returns an owned child activity rather than a prompt result. The
## Capabilities and context ## Capabilities and context
The provider advertises no start-time capabilities (`agentOptions`/`outputSchema`/`depthLimit`/`toolFilter`/`persona` all false) and `inheritsParentContext: false`: the child is a fresh runtime in another process, and the only parent-derived input is the workspace cwd. `dsh-tool-subagent` deployments over this provider set `maxDepth: 'provider-managed'` — the child harness owns its own recursion budget. The provider advertises `agentOptions: true`, with `outputSchema`/`depthLimit`/`toolFilter`/`persona` false, and `inheritsParentContext: false`. Agent route values cross the SDK wire as an explicit whitelist; the child remains a fresh runtime in another process, and the only value derived from the parent Agent itself is the workspace cwd. `dsh-tool-subagent` deployments over this provider set `maxDepth: 'provider-managed'` — the child harness owns its own recursion budget.
## Configuration ## Configuration
@@ -40,6 +40,8 @@ The provider advertises no start-time capabilities (`agentOptions`/`outputSchema
| `disposeEofGraceMs` | `6000` | Grace after stdin EOF before platform termination. | | `disposeEofGraceMs` | `6000` | Grace after stdin EOF before platform termination. |
| `disposeGraceMs` | `3000` | Exit-confirmation grace after termination; POSIX also waits this long after SIGTERM before SIGKILL. | | `disposeGraceMs` | `3000` | Exit-confirmation grace after termination; POSIX also waits this long after SIGTERM before SIGKILL. |
Request `agentOptions` override `provider`, `model`, and `maxTokens` independently. `reasoningEffort` has no provider-instance default: an omitted request leaves it absent so the selected child model resolves its own default. The model-facing subagent tool can select provider/model/reasoning per call; `maxTokens` remains deployment-controlled through tool config or this provider's default.
```yaml ```yaml
- id: subagent-dsh-sdk - id: subagent-dsh-sdk
name: '@deepseek-ai/dsh-subagent-dsh-sdk' name: '@deepseek-ai/dsh-subagent-dsh-sdk'
@@ -68,7 +70,7 @@ The package has no default export. Cordis loader unwrapping would otherwise hide
#### What the model sees #### What the model sees
The child runtime's model receives the standalone task as its user message plus that runtime's own configured system prompt, tools, and fresh session. It receives no parent conversation. This provider advertises no optional start-time capabilities, so the local service rejects requests for `agentOptions`, persona, tool filtering, depth enforcement, or structured output instead of silently omitting them. The child runtime's model receives the standalone task as its user message plus that runtime's own configured system prompt, tools, and fresh session. It receives no parent conversation. A parent tool call may choose the child provider, model, and reasoning effort for this run; the selected route and any deployment-owned output cap are fixed for the new child process. Persona, tool filtering, depth enforcement, and structured output remain unsupported and are rejected instead of silently omitted.
#### Token effect #### Token effect
@@ -95,6 +97,6 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work ## Known Limitations and Deferred Work
- **A fresh runtime process per run** — no pooling; a harness runtime boots a full plugin tree, so per-run spawn cost is higher than the ACP backend's typical child. - **A fresh runtime process per run** — no pooling; a harness runtime boots a full plugin tree, so per-run spawn cost is higher than the ACP backend's typical child.
- **No optional start-time capabilities** — the parent cannot apply `agentOptions` or enforce `outputSchema`, depth, tool filters, or persona inside the child process; configure the selected child profile and its ordered patches instead. - **No non-route start-time capabilities** — the parent can select the child Agent route but cannot enforce `outputSchema`, depth, tool filters, or persona inside the child process; configure the selected child profile and its ordered patches instead.
- **The child's transcript stays in the child's own session root** — the parent log records only the delegation tool call/result (the seam's child-isolation rule); the streamed `session.event` channel is consumed for output extraction, not bridged into the parent log. - **The child's transcript stays in the child's own session root** — the parent log records only the delegation tool call/result (the seam's child-isolation rule); the streamed `session.event` channel is consumed for output extraction, not bridged into the parent log.
- **Local child processes only** — the resolved cwd is a local path; a remote runtime would need its own backend. - **Local child processes only** — the resolved cwd is a local path; a remote runtime would need its own backend.
@@ -6,7 +6,7 @@ SDK 提供方会在全新的子进程中把每个 subagent 作为完整的 DeepS
## 启动与所有权 ## 启动与所有权
`start(request)` 先解析子进程工作目录,通过 `DeepSeekHarness` spawn 运行时,并在履行前完成 `initialize` 握手(携带配置的 `provider`/`model` 路由及可选的 `maxTokens` 输出上限)。因此,履行意味着子运行时已就绪、所有权已移交给调用方。spawn、握手或发布前取消失败时,只会在子进程被回收后拒绝;工作目录解析失败则会在尚未 spawn 任何内容时拒绝。 `start(request)` 会在 spawn 前解析子进程工作目录与一条进程级 SDK 路由。`request.agentOptions` 中每个已声明字段(`provider``model``reasoningEffort``maxTokens`)都会覆盖对应的提供方实例默认值;省略时保留已配置的提供方/模型与可选上限,而推理强度只有在请求提供时才会出现。随后,提供方通过 `DeepSeekHarness` spawn 运行时,并在履行前完成子运行时的 `initialize` 握手,其中包括确切模型与推理强度校验。因此,履行意味着子运行时已就绪、所有权已移交给调用方。路由、spawn、握手或发布前取消失败时,只会在子进程被回收后拒绝;工作目录解析失败则会在尚未 spawn 任何内容时拒绝。
工作目录的解析与 ACP 后端完全一致,并使用 seam 共享的进程外辅助工具([`dsh-subagent`](../subagent/README.zh.md)):设置了 `cwd` 覆盖值时使用该值(加载时校验一次),否则使用发起委派的父会话 cwd,绝不使用服务器进程自身的 cwd。解析出的路径同时成为子进程 cwd 和其 SDK 会话的工作区 cwd。`dshHome` 必须另外指定为绝对路径,使嵌套运行时不会意外共享父运行时的 profile、插件安装或会话存储。 工作目录的解析与 ACP 后端完全一致,并使用 seam 共享的进程外辅助工具([`dsh-subagent`](../subagent/README.zh.md)):设置了 `cwd` 覆盖值时使用该值(加载时校验一次),否则使用发起委派的父会话 cwd,绝不使用服务器进程自身的 cwd。解析出的路径同时成为子进程 cwd 和其 SDK 会话的工作区 cwd。`dshHome` 必须另外指定为绝对路径,使嵌套运行时不会意外共享父运行时的 profile、插件安装或会话存储。
@@ -20,7 +20,7 @@ SDK 客户端返回自有子活动,而不是提示词结果。提供方读取
## 能力与上下文 ## 能力与上下文
Provider 不宣告任何启动期能力(`agentOptions`/`outputSchema`/`depthLimit`/`toolFilter`/`persona` 为 false,且 `inheritsParentContext: false`子进程是另一进程里的全新运行时,唯一来自父方的输入是工作区 cwd。基于本 provider `dsh-tool-subagent` 部署应设置 `maxDepth: 'provider-managed'`——子 harness 拥有自己的递归预算。 提供方声明 `agentOptions: true`,同时保持 `outputSchema`/`depthLimit`/`toolFilter`/`persona` 为 false`inheritsParentContext: false`。Agent 路由值通过显式白名单跨越 SDK 协议;子进程是另一进程里的全新运行时,唯一从父 Agent 本身派生的值是工作区 cwd。基于本提供方`dsh-tool-subagent` 部署应设置 `maxDepth: 'provider-managed'`——子 harness 拥有自己的递归预算。
## 配置 ## 配置
@@ -40,6 +40,8 @@ Provider 不宣告任何启动期能力(`agentOptions`/`outputSchema`/`depthLi
| `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限。 | | `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限。 |
| `disposeGraceMs` | `3000` | 终止后的退出确认窗口;POSIX 在 SIGTERM 之后、SIGKILL 之前也等待同样时长。 | | `disposeGraceMs` | `3000` | 终止后的退出确认窗口;POSIX 在 SIGTERM 之后、SIGKILL 之前也等待同样时长。 |
请求 `agentOptions` 会分别覆盖 `provider``model``maxTokens``reasoningEffort` 没有提供方实例默认值:请求省略时保持缺省,由所选子模型解析自身默认值。面向模型的 subagent 工具可在每次调用时选择提供方/模型/推理强度;`maxTokens` 仍由工具配置或本提供方默认值在部署侧控制。
```yaml ```yaml
- id: subagent-dsh-sdk - id: subagent-dsh-sdk
name: '@deepseek-ai/dsh-subagent-dsh-sdk' name: '@deepseek-ai/dsh-subagent-dsh-sdk'
@@ -68,7 +70,7 @@ Provider 不宣告任何启动期能力(`agentOptions`/`outputSchema`/`depthLi
#### 模型看到的内容 #### 模型看到的内容
子运行时的模型会收到作为用户消息的独立任务,以及该运行时自身配置的系统提示词、工具和全新会话。它不会收到父级对话。本提供方不声明可选的启动时能力,因此本地服务会拒绝要求 `agentOptions`persona、工具过滤、深度强制结构化输出的请求,而不是静默省略这些要求 子运行时的模型会收到作为用户消息的独立任务,以及该运行时自身配置的系统提示词、工具和全新会话。它不会收到父级对话。父级工具调用可以为本次运行选择子级提供方、模型与推理强度;所选路由和部署持有的可选输出上限会固定到这个新子进程。persona、工具过滤、深度强制结构化输出仍不受支持,并会被拒绝而不是静默省略。
#### Token 影响 #### Token 影响
@@ -95,6 +97,6 @@ Provider 不宣告任何启动期能力(`agentOptions`/`outputSchema`/`depthLi
## 已知限制与暂缓事项 ## 已知限制与暂缓事项
- **每次运行都使用全新的运行时进程**:不使用进程池;harness 运行时需要启动完整的插件树,因此每次运行的 spawn 成本高于 ACP 后端通常使用的子进程。 - **每次运行都使用全新的运行时进程**:不使用进程池;harness 运行时需要启动完整的插件树,因此每次运行的 spawn 成本高于 ACP 后端通常使用的子进程。
- **不支持可选的启动时能力**:父级无法在子进程内应用 `agentOptions`,也无法强制执行 `outputSchema`、深度限制、工具过滤或 persona;应改为配置所选子 profile 及其有序 patch。 - **不支持路由之外的启动时能力**:父级可以选择子 Agent 路由,但无法在子进程内强制执行 `outputSchema`、深度限制、工具过滤或 persona;应改为配置所选子 profile 及其有序 patch。
- **子进程的 transcript(文本记录)保留在其自身的会话根目录中**:父级日志只记录委派工具调用/结果(seam 的子级隔离规则);流式 `session.event` 通道只用于提取输出,不会桥接到父级日志中。 - **子进程的 transcript(文本记录)保留在其自身的会话根目录中**:父级日志只记录委派工具调用/结果(seam 的子级隔离规则);流式 `session.event` 通道只用于提取输出,不会桥接到父级日志中。
- **仅支持本地子进程**:解析出的 cwd 是本地路径;远程运行时需要独立的后端。 - **仅支持本地子进程**:解析出的 cwd 是本地路径;远程运行时需要独立的后端。
+31 -10
View File
@@ -2,9 +2,10 @@
* Out-of-process SDK subagent backend. Each child is a complete DeepSeek * Out-of-process SDK subagent backend. Each child is a complete DeepSeek
* Harness runtime in its own process own named profile and patch composition, * Harness runtime in its own process own named profile and patch composition,
* session, model route, and tools driven over stdio JSON-RPC through the * session, model route, and tools driven over stdio JSON-RPC through the
* TypeScript SDK client, so it shares no Cordis context and advertises no * TypeScript SDK client, so it shares no Cordis context. It accepts the
* parent-enforced start capabilities; the ONE thing it reads off * provider/model/reasoning/maxTokens subset of `agentOptions`; other start
* `request.parent` is the session's workspace cwd. This plugin uses named * features remain unsupported. The ONE thing it reads off `request.parent`
* is the session's workspace cwd. This plugin uses named
* exports only; a default would hide its loader metadata (see * exports only; a default would hide its loader metadata (see
* `docs/postmortem/0001-acp-default-export-drops-inject.md`). * `docs/postmortem/0001-acp-default-export-drops-inject.md`).
* @module @deepseek-ai/dsh-subagent-dsh-sdk * @module @deepseek-ai/dsh-subagent-dsh-sdk
@@ -14,6 +15,7 @@ import type { Context } from '@deepseek-ai/cordis'
import { statSync } from 'node:fs' import { statSync } from 'node:fs'
import { isAbsolute, resolve } from 'node:path' import { isAbsolute, resolve } from 'node:path'
import z from '@deepseek-ai/schemastery' import z from '@deepseek-ai/schemastery'
import type { AgentOptions } from '@deepseek-ai/dsh-agent'
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import { assertPositiveFinite, NO_START_CAPABILITIES, resolveChildCwd, validateConfiguredCwd } from '@deepseek-ai/dsh-subagent' import { assertPositiveFinite, NO_START_CAPABILITIES, resolveChildCwd, validateConfiguredCwd } from '@deepseek-ai/dsh-subagent'
import { import {
@@ -103,28 +105,47 @@ function resolveConfiguredFile(field: string, value: string): string {
throw new TypeError(`subagent-dsh-sdk ${field} must name an existing file: ${path}`) throw new TypeError(`subagent-dsh-sdk ${field} must name an existing file: ${path}`)
} }
/** DSH SDK can apply Agent route options while the other start features remain child-owned. */
const SDK_START_CAPABILITIES: SubagentCapabilities = Object.freeze({
...NO_START_CAPABILITIES,
agentOptions: true,
})
/** Merge the request's supported route fields over this provider instance's defaults. */
function resolveSdkRoute(config: ResolvedConfig, requested: AgentOptions | undefined): Pick<
SdkRunSpec,
'provider' | 'model' | 'reasoningEffort' | 'maxTokens'
> {
const maxTokens = requested?.maxTokens ?? config.maxTokens
return {
provider: requested?.provider ?? config.provider,
model: requested?.model ?? config.model,
...requested?.reasoningEffort === undefined ? {} : { reasoningEffort: requested.reasoningEffort },
...maxTokens === undefined ? {} : { maxTokens },
}
}
/** /**
* The SDK provider. Advertises NO start-time capabilities: an out-of-process * The SDK provider. It resolves Agent route options into the child runtime's
* child cannot honor `agentOptions`/`outputSchema`/`maxDepth`/`toolFilter`/`persona` (the * process-wide handshake; output schema, depth, tool filter, and persona stay
* service rejects a request needing any of them before `start` runs). * unsupported because their ownership does not cross this process boundary.
*/ */
class SdkSubagentProvider implements SubagentProvider { class SdkSubagentProvider implements SubagentProvider {
readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES readonly capabilities = SDK_START_CAPABILITIES
// Context contract: an out-of-process SDK child starts fresh — no parent conversation crosses the process boundary. // Context contract: an out-of-process SDK child starts fresh — no parent conversation crosses the process boundary.
readonly inheritsParentContext = false readonly inheritsParentContext = false
constructor(readonly name: string, private readonly ctx: Context, private readonly config: ResolvedConfig) {} constructor(readonly name: string, private readonly ctx: Context, private readonly config: ResolvedConfig) {}
start(request: SubagentStartRequest) { start(request: SubagentStartRequest) {
const route = resolveSdkRoute(this.config, request.agentOptions)
const spec: SdkRunSpec = { const spec: SdkRunSpec = {
...this.config.dshBin === undefined ? {} : { dshBin: this.config.dshBin }, ...this.config.dshBin === undefined ? {} : { dshBin: this.config.dshBin },
profile: this.config.profile, profile: this.config.profile,
patches: this.config.patches, patches: this.config.patches,
dshHome: this.config.dshHome, dshHome: this.config.dshHome,
cwd: resolveChildCwd('subagent-dsh-sdk', this.config.cwd, request.parent.session.header.cwd), cwd: resolveChildCwd('subagent-dsh-sdk', this.config.cwd, request.parent.session.header.cwd),
provider: this.config.provider, ...route,
model: this.config.model,
...this.config.maxTokens === undefined ? {} : { maxTokens: this.config.maxTokens },
env: this.config.env, env: this.config.env,
shutdownTimeoutMs: this.config.shutdownTimeoutMs, shutdownTimeoutMs: this.config.shutdownTimeoutMs,
disposeEofGraceMs: this.config.disposeEofGraceMs, disposeEofGraceMs: this.config.disposeEofGraceMs,
@@ -12,7 +12,7 @@
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import { DeepSeekHarness, type DeepSeekHarnessOptions, type HarnessNotification } from '@deepseek-ai/dsh-sdk-client' import { DeepSeekHarness, type DeepSeekHarnessOptions, type HarnessNotification } from '@deepseek-ai/dsh-sdk-client'
import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { ContentBlock, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import { AssistantOutputFold, settleRunResult, subprocessRunHandle } from '@deepseek-ai/dsh-subagent' import { AssistantOutputFold, settleRunResult, subprocessRunHandle } from '@deepseek-ai/dsh-subagent'
@@ -38,6 +38,8 @@ export interface SdkRunSpec {
provider: string provider: string
/** Model the child runtime initializes with. */ /** Model the child runtime initializes with. */
model: string model: string
/** Optional adapter-owned reasoning effort sent in the child runtime's initialize handshake. */
reasoningEffort?: ReasoningEffortId
/** Optional per-request output-token cap sent in the child runtime's initialize handshake. */ /** Optional per-request output-token cap sent in the child runtime's initialize handshake. */
maxTokens?: number maxTokens?: number
/** /**
@@ -114,7 +116,8 @@ function toError(value: unknown): Error {
* after process reap. Disposal shuts the runtime down and reaps it. * after process reap. Disposal shuts the runtime down and reaps it.
* @param request - the start request; its signal is the cancellation channel. * @param request - the start request; its signal is the cancellation channel.
* @param spec - the resolved spawn spec: profile/patches/home/cwd, the child's * @param spec - the resolved spawn spec: profile/patches/home/cwd, the child's
* provider/model route, env, timeouts, and the optional error sink. * provider/model/reasoning route, output cap, env, timeouts, and the optional
* error sink.
* @returns the ready run handle for the child subprocess. * @returns the ready run handle for the child subprocess.
*/ */
export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpec): Promise<SubagentRun> { export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpec): Promise<SubagentRun> {
@@ -136,6 +139,7 @@ export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpe
cwd: spec.cwd, cwd: spec.cwd,
provider: spec.provider, provider: spec.provider,
model: spec.model, model: spec.model,
...spec.reasoningEffort === undefined ? {} : { reasoningEffort: spec.reasoningEffort },
...spec.maxTokens === undefined ? {} : { maxTokens: spec.maxTokens }, ...spec.maxTokens === undefined ? {} : { maxTokens: spec.maxTokens },
}) })
@@ -1,12 +1,9 @@
/** /**
* Keyless REAL-composition coverage for parent-session cwd inheritance across * Keyless REAL-composition coverage for dynamic child routing and parent cwd
* the SDK wire: a test-only cordis.yml boots the headless app through the * inheritance across the SDK wire. A test-only cordis.yml boots through the
* Loader with the SDK backend's `cwd` omitted, a scripted model delegates * Loader, a scripted model selects provider/model/reasoning, tool config adds
* once, and the child a COMPLETE second harness runtime booted from its own * maxTokens, and a COMPLETE second harness runtime echoes the effective route
* cordis.yml and driven over stdio JSON-RPC echoes where it actually ran. * and cwd. The child's persisted request header must carry all four values.
* Both the parent's tool result and the child's own persisted session log
* must carry the parent session's cwd. Mock-only composition, so only this
* keyless tier applies (the with-key tier lives in subagent-sdk.e2e.ts).
*/ */
import { existsSync, realpathSync } from 'node:fs' import { existsSync, realpathSync } from 'node:fs'
@@ -40,8 +37,8 @@ async function sessionEvents(log: string): Promise<SessionEvent[]> {
return lines.slice(1).map(line => JSON.parse(line) as SessionEvent) return lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
} }
describe('SDK subagent cwd inheritance through a real cordis.yml', () => { describe('SDK subagent dynamic routing through a real cordis.yml', () => {
it('runs the child runtime in the parent session workspace', async () => { it('runs the selected child route in the parent session workspace', async () => {
const childHome = await mkdtemp(join(tmpdir(), 'dsh-sdk-subagent-home-')) const childHome = await mkdtemp(join(tmpdir(), 'dsh-sdk-subagent-home-'))
const childPatch = join(childHome, 'child.cordis.yml') const childPatch = join(childHome, 'child.cordis.yml')
await writeFile(childPatch, (await readFile(childConfigPath, 'utf8')) await writeFile(childPatch, (await readFile(childConfigPath, 'utf8'))
@@ -94,10 +91,19 @@ describe('SDK subagent cwd inheritance through a real cordis.yml', () => {
.filter(block => block.type === 'text') .filter(block => block.type === 'text')
.map(block => block.text) .map(block => block.text)
.join('') .join('')
expect(resultText).toBe(`child cwd: ${workspace}`) expect(resultText).toBe(`child route: mock/mock-routed/max/777; cwd: ${workspace}`)
// The child ran a real turn of its own: user message in, assistant out. // The child ran a real turn with the model-selected route and tool-configured cap.
expect(childEvents.some(event => event.type === 'user/message')).toBe(true) expect(childEvents.some(event => event.type === 'user/message')).toBe(true)
const childHeader = childEvents.find(
(event): event is Extract<SessionEvent, { type: 'request/header' }> => event.type === 'request/header',
)
expect(childHeader?.data.header.config).toEqual({
provider: 'mock',
model: 'mock-routed',
reasoningEffort: 'max',
maxTokens: 777,
})
const childAnswers = childEvents.filter(event => event.type === 'assistant/message') const childAnswers = childEvents.filter(event => event.type === 'assistant/message')
expect(childAnswers.length).toBeGreaterThan(0) expect(childAnswers.length).toBeGreaterThan(0)
} finally { } finally {
@@ -13,10 +13,11 @@ import { tmpdir } from 'node:os'
import { join, relative } from 'node:path' import { join, relative } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import SubagentRuntime from '@deepseek-ai/dsh-subagent' import SubagentRuntime from '@deepseek-ai/dsh-subagent'
import type { Agent } from '@deepseek-ai/dsh-agent' import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
import { createProcessDeepSeekHarness } from '../../../sdk/client/src/api.ts' import { createProcessDeepSeekHarness } from '../../../sdk/client/src/api.ts'
import type { RuntimeProcessOptions } from '../../../sdk/client/src/launch.ts' import type { RuntimeProcessOptions } from '../../../sdk/client/src/launch.ts'
import type { DeepSeekHarnessOptions } from '@deepseek-ai/dsh-sdk-client' import type { DeepSeekHarnessOptions } from '@deepseek-ai/dsh-sdk-client'
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import * as sdk from '../src/index.ts' import * as sdk from '../src/index.ts'
import { import {
DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_EOF_GRACE_MS,
@@ -63,8 +64,14 @@ afterEach(() => {
/** A parent Agent stub. The SDK backend reads exactly one thing off it: the session header's cwd (the workspace its child inherits). */ /** A parent Agent stub. The SDK backend reads exactly one thing off it: the session header's cwd (the workspace its child inherits). */
const fakeParent = { id: 'parent', session: { header: { cwd: process.cwd() } } } as unknown as Agent const fakeParent = { id: 'parent', session: { header: { cwd: process.cwd() } } } as unknown as Agent
function request(text = 'p', signal = new AbortController().signal) { function request(text = 'p', signal = new AbortController().signal, agentOptions?: AgentOptions) {
return { label: text, prompt: [{ type: 'text' as const, text }], parent: fakeParent, signal } return {
label: text,
prompt: [{ type: 'text' as const, text }],
parent: fakeParent,
signal,
...agentOptions === undefined ? {} : { agentOptions },
}
} }
/** Mount the SDK backend pointed at the fake runtime, scripted by `fakeEnv`. */ /** Mount the SDK backend pointed at the fake runtime, scripted by `fakeEnv`. */
@@ -183,6 +190,77 @@ describe('dsh-subagent-dsh-sdk provider', () => {
} }
}) })
it('preserves instance defaults around a partial request override', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'subagent-dsh-sdk-partial-route-'))
const recordFile = join(tmp, 'init.jsonl')
try {
const ctx = await setup({ FAKE_RECORD_INIT: recordFile }, { maxTokens: 4096 })
const run = await ctx.subagents.start('dsh-sdk', request('partial', new AbortController().signal, {
reasoningEffort: ReasoningEffortId('high'),
}))
await run.result
await run.dispose()
const { readFileSync } = await import('node:fs')
expect(JSON.parse(readFileSync(recordFile, 'utf8'))).toEqual({
cwd: process.cwd(),
provider: 'fake-provider',
model: 'fake-model',
reasoningEffort: 'high',
maxTokens: 4096,
})
await ctx.fiber.dispose()
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('isolates complete per-run route overrides on concurrent children', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'subagent-dsh-sdk-routes-'))
const recordFile = join(tmp, 'init.jsonl')
try {
const ctx = await setup({ FAKE_RECORD_INIT: recordFile }, { maxTokens: 4096 })
const runs = await Promise.all([
ctx.subagents.start('dsh-sdk', request('first', new AbortController().signal, {
provider: 'provider-a',
model: 'model-a',
reasoningEffort: ReasoningEffortId('high'),
maxTokens: 111,
})),
ctx.subagents.start('dsh-sdk', request('second', new AbortController().signal, {
provider: 'provider-b',
model: 'model-b',
reasoningEffort: ReasoningEffortId('max'),
maxTokens: 222,
})),
])
await Promise.all(runs.map(run => run.result))
await Promise.all(runs.map(run => run.dispose()))
const { readFileSync } = await import('node:fs')
const records = readFileSync(recordFile, 'utf8').trim().split('\n')
.map(line => JSON.parse(line) as Record<string, unknown>)
.sort((left, right) => String(left.provider).localeCompare(String(right.provider)))
expect(records).toEqual([
{
cwd: process.cwd(),
provider: 'provider-a',
model: 'model-a',
reasoningEffort: 'high',
maxTokens: 111,
},
{
cwd: process.cwd(),
provider: 'provider-b',
model: 'model-b',
reasoningEffort: 'max',
maxTokens: 222,
},
])
await ctx.fiber.dispose()
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('scrubs ambient credentials but forwards explicit config env', async () => { it('scrubs ambient credentials but forwards explicit config env', async () => {
process.env.DSH_TEST_AMBIENT_SECRET_KEY = 'leak-me-not' process.env.DSH_TEST_AMBIENT_SECRET_KEY = 'leak-me-not'
try { try {
@@ -433,7 +511,7 @@ describe('dsh-subagent-dsh-sdk provider', () => {
expect(ctx.subagents.getProvider('sdk-hmr')?.name).toBe('sdk-hmr') expect(ctx.subagents.getProvider('sdk-hmr')?.name).toBe('sdk-hmr')
expect(ctx.subagents.getProvider('sdk-hmr')?.inheritsParentContext).toBe(false) expect(ctx.subagents.getProvider('sdk-hmr')?.inheritsParentContext).toBe(false)
expect(ctx.subagents.getProvider('sdk-hmr')?.capabilities).toEqual({ expect(ctx.subagents.getProvider('sdk-hmr')?.capabilities).toEqual({
agentOptions: false, agentOptions: true,
outputSchema: false, outputSchema: false,
depthLimit: false, depthLimit: false,
toolFilter: false, toolFilter: false,
+2 -1
View File
@@ -121,7 +121,8 @@ export interface SubagentStartRequest {
* Optional host-Agent provider, model, reasoning-effort, and output-token * Optional host-Agent provider, model, reasoning-effort, and output-token
* overrides. Requires {@link SubagentCapabilities.agentOptions}; in-process * overrides. Requires {@link SubagentCapabilities.agentOptions}; in-process
* providers merge them over the parent Agent's options when they create the * providers merge them over the parent Agent's options when they create the
* child. * child, while the DSH SDK provider merges them over its instance defaults
* before initializing the separate child runtime.
*/ */
readonly agentOptions?: AgentOptions readonly agentOptions?: AgentOptions
/** /**
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write python/sdk/README.md # pnpm run verify-translation-pairing --write python/sdk/README.md
README.md: 1b03fe5553f25da3bc62f8a7eec2a274b0afb66a README.md: faef82996077bbb7bf3fc3aeaf0e99af9219d36d
README.zh.md: c0bfa8bdd9e2ecbaad0a019a274b94516e219ac6 README.zh.md: b9e028c596ad0ece5acd4fe31fbd697c6f50c20a
+7 -1
View File
@@ -20,13 +20,17 @@ from deepseek_harness import DeepSeekHarness
with DeepSeekHarness( with DeepSeekHarness(
dsh_home="/absolute/path/to/isolated-dsh-home", dsh_home="/absolute/path/to/isolated-dsh-home",
cwd="/absolute/path/to/workspace", cwd="/absolute/path/to/workspace",
provider="deepseek-official",
model="deepseek-v4-flash",
reasoning_effort="max",
max_tokens=49_152,
) as harness: ) as harness:
result = harness.run("Say hi.", session_id="example-001") result = harness.run("Say hi.", session_id="example-001")
print(result.final_response) print(result.final_response)
``` ```
`DeepSeekHarness` starts lazily and reuses its runtime until `close()` or context-manager exit. The initial profile handshake has an independent 30-second default bound through `initialize_timeout_seconds`; ordinary turns remain unbounded unless `request_timeout_seconds` is set. A timeout names the selected profile and includes retained runtime diagnostics. `cwd` is the agent workspace; `runtime_cwd` independently selects the subprocess working directory. Both become absolute before launch. `provider`, `model`, and optional positive `max_tokens` are sent during JSON-RPC initialization. `base_url` and `api_key` explicitly override `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY` in the child environment. `DeepSeekHarness` starts lazily and reuses its runtime until `close()` or context-manager exit. The initial profile handshake has an independent 30-second default bound through `initialize_timeout_seconds`; ordinary turns remain unbounded unless `request_timeout_seconds` is set. A timeout names the selected profile and includes retained runtime diagnostics. `cwd` is the agent workspace; `runtime_cwd` independently selects the subprocess working directory. Both become absolute before launch. `provider`, `model`, optional `reasoning_effort`, and optional positive `max_tokens` are sent during JSON-RPC initialization. `base_url` and `api_key` explicitly override `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY` in the child environment.
## Customize plugins ## Customize plugins
@@ -53,6 +57,8 @@ with DeepSeekHarness(
`profile` may select another existing profile, but that composition must retain `@deepseek-ai/dsh-sdk-app` or another `@deepseek-ai/dsh-sdk-jsonrpc-server` row. Misconfiguration fails during CLI boot or SDK initialization; there is no complete-config fallback. `dsh_bin` may select another `dsh` executable while preserving the same profile grammar. Arbitrary argv replacement remains an internal fake-runtime test adapter, not public API. `profile` may select another existing profile, but that composition must retain `@deepseek-ai/dsh-sdk-app` or another `@deepseek-ai/dsh-sdk-jsonrpc-server` row. Misconfiguration fails during CLI boot or SDK initialization; there is no complete-config fallback. `dsh_bin` may select another `dsh` executable while preserving the same profile grammar. Arbitrary argv replacement remains an internal fake-runtime test adapter, not public API.
`provider` selects a provider route registered by the chosen Cordis composition; `model` is the model id resolved by that adapter. `reasoning_effort` is an optional non-empty adapter-owned identifier for that exact route; omission preserves the model's own default. `max_tokens` is an optional positive per-request output-token cap for the root agent and its in-process descendants; omission leaves the provider default in control. Initialization rejects a missing adapter, unavailable model, or unsupported effort before a prompt runs. Compaction summaries keep the separate limit configured by their compaction plugin. The bundled default composition registers `deepseek-official`. A custom composition can mount `llm-pi-ai`, configure provider-specific credentials/endpoints there, and select any provider/model present in pi-ai's installed catalog.
The shipped `sdk-minimal` profile is a standalone explicit tree rather than an overlay on `dsh-base`. Select it with `profile="sdk-minimal"`; the ordinary `model` argument is the sole runtime model selection, including for model ids outside the adapter's advisory catalog. It provides persistent Bash, the string-replace editor, local execution, and JSONL sessions; settings, managed credentials, telemetry, Web tools, and the full default tool roster remain available through the separate full `sdk` and `web` profiles. The shipped `sdk-minimal` profile is a standalone explicit tree rather than an overlay on `dsh-base`. Select it with `profile="sdk-minimal"`; the ordinary `model` argument is the sole runtime model selection, including for model ids outside the adapter's advisory catalog. It provides persistent Bash, the string-replace editor, local execution, and JSONL sessions; settings, managed credentials, telemetry, Web tools, and the full default tool roster remain available through the separate full `sdk` and `web` profiles.
## Results and notifications ## Results and notifications
+7 -1
View File
@@ -20,13 +20,17 @@ from deepseek_harness import DeepSeekHarness
with DeepSeekHarness( with DeepSeekHarness(
dsh_home="/absolute/path/to/isolated-dsh-home", dsh_home="/absolute/path/to/isolated-dsh-home",
cwd="/absolute/path/to/workspace", cwd="/absolute/path/to/workspace",
provider="deepseek-official",
model="deepseek-v4-flash",
reasoning_effort="max",
max_tokens=49_152,
) as harness: ) as harness:
result = harness.run("Say hi.", session_id="example-001") result = harness.run("Say hi.", session_id="example-001")
print(result.final_response) print(result.final_response)
``` ```
`DeepSeekHarness` 延迟启动运行时,并在调用 `close()` 或退出上下文管理器前复用该进程。首次 profile 握手通过 `initialize_timeout_seconds` 使用独立的 30 秒默认上限;普通轮次在未设置 `request_timeout_seconds` 时仍不设上限。超时诊断会指明所选 profile,并包含保留的运行时诊断。`cwd` 是 agent workspace`runtime_cwd` 独立选择子进程工作目录。两者都会在启动前转成绝对路径。`provider``model` 和可选的正整数 `max_tokens` 通过 JSON-RPC 初始化发送。`base_url``api_key` 会显式覆盖子进程环境中的 `DEEPSEEK_BASE_URL``DEEPSEEK_API_KEY` `DeepSeekHarness` 延迟启动运行时,并在调用 `close()` 或退出上下文管理器前复用该进程。首次 profile 握手通过 `initialize_timeout_seconds` 使用独立的 30 秒默认上限;普通轮次在未设置 `request_timeout_seconds` 时仍不设上限。超时诊断会指明所选 profile,并包含保留的运行时诊断。`cwd` 是 agent workspace`runtime_cwd` 独立选择子进程工作目录。两者都会在启动前转成绝对路径。`provider``model`、可选的 `reasoning_effort` 和可选的正整数 `max_tokens` 通过 JSON-RPC 初始化发送。`base_url``api_key` 会显式覆盖子进程环境中的 `DEEPSEEK_BASE_URL``DEEPSEEK_API_KEY`
## 自定义插件 ## 自定义插件
@@ -53,6 +57,8 @@ with DeepSeekHarness(
`profile` 可以选择另一个已存在的 profile,但该组合必须保留 `@deepseek-ai/dsh-sdk-app` 或另一个 `@deepseek-ai/dsh-sdk-jsonrpc-server` 配置项。配置错误会在 CLI 启动或 SDK 初始化时失败;不存在完整配置回退。`dsh_bin` 可以选择另一个 `dsh` 可执行程序,同时保持相同的 profile 语法。任意 argv 替换仅是内部 fake-runtime 测试适配器,不属于公开 API。 `profile` 可以选择另一个已存在的 profile,但该组合必须保留 `@deepseek-ai/dsh-sdk-app` 或另一个 `@deepseek-ai/dsh-sdk-jsonrpc-server` 配置项。配置错误会在 CLI 启动或 SDK 初始化时失败;不存在完整配置回退。`dsh_bin` 可以选择另一个 `dsh` 可执行程序,同时保持相同的 profile 语法。任意 argv 替换仅是内部 fake-runtime 测试适配器,不属于公开 API。
`provider` 选择指定 Cordis 组合所注册的提供方路由;`model` 是该适配器解析出的模型 ID。`reasoning_effort` 是该确切路由可选的非空适配器自有标识符;省略时保留模型自身的默认值。`max_tokens` 是一个可选的正整数,用于限制根 agent 及其进程内后代在每次请求中输出的 token 数量;省略该参数时,由提供方的默认行为决定输出上限。缺少适配器、模型不可用或推理强度不受支持时,初始化会在提示词运行前拒绝。压缩摘要继续使用压缩插件单独配置的上限。内置默认组合注册 `deepseek-official`。自定义组合可以挂载 `llm-pi-ai`,在其中配置各提供方专属的凭据和端点,并选择 pi-ai 已安装 catalog 中存在的任意提供方/模型组合。
随附的 `sdk-minimal` profile 是独立显式配置树,而不是 `dsh-base` 上的 overlay。使用 `profile="sdk-minimal"` 选择它;普通 `model` 参数是唯一运行时模型选择,也适用于不在适配器建议目录中的模型 id。它提供持久 Bash、字符串替换 editor、本地执行与 JSONL 会话;settings、托管凭据、遥测、Web 工具与完整默认工具清单仍由独立的完整 `sdk``web` profile 提供。 随附的 `sdk-minimal` profile 是独立显式配置树,而不是 `dsh-base` 上的 overlay。使用 `profile="sdk-minimal"` 选择它;普通 `model` 参数是唯一运行时模型选择,也适用于不在适配器建议目录中的模型 id。它提供持久 Bash、字符串替换 editor、本地执行与 JSONL 会话;settings、托管凭据、遥测、Web 工具与完整默认工具清单仍由独立的完整 `sdk``web` profile 提供。
## 结果与通知 ## 结果与通知
+2
View File
@@ -21,6 +21,7 @@ class DeepSeekHarnessConfig:
provider: str = "deepseek-official" provider: str = "deepseek-official"
model: str = "deepseek-v4-flash" model: str = "deepseek-v4-flash"
reasoning_effort: str | None = None
max_tokens: int | None = None max_tokens: int | None = None
cwd: str | None = None cwd: str | None = None
runtime_cwd: str | None = None runtime_cwd: str | None = None
@@ -107,6 +108,7 @@ class DeepSeekHarness:
cwd=self._cwd, cwd=self._cwd,
provider=self.config.provider, provider=self.config.provider,
model=self.config.model, model=self.config.model,
reasoning_effort=self.config.reasoning_effort,
max_tokens=self.config.max_tokens, max_tokens=self.config.max_tokens,
) )
self._initialized = True self._initialized = True
@@ -137,6 +137,7 @@ class HarnessClient:
cwd: str, cwd: str,
provider: str, provider: str,
model: str, model: str,
reasoning_effort: str | None = None,
max_tokens: int | None = None, max_tokens: int | None = None,
) -> InitializeResponse: ) -> InitializeResponse:
payload: JsonObject = { payload: JsonObject = {
@@ -144,6 +145,8 @@ class HarnessClient:
"provider": provider, "provider": provider,
"model": model, "model": model,
} }
if reasoning_effort is not None:
payload["reasoningEffort"] = reasoning_effort
if max_tokens is not None: if max_tokens is not None:
payload["maxTokens"] = max_tokens payload["maxTokens"] = max_tokens
try: try:
+4
View File
@@ -94,6 +94,7 @@ for line in sys.stdin:
with DeepSeekHarness( with DeepSeekHarness(
model="deepseek-v4-flash", model="deepseek-v4-flash",
reasoning_effort="max",
max_tokens=4096, max_tokens=4096,
cwd=str(tmp_path), cwd=str(tmp_path),
_launch_args=(sys.executable, str(script)), _launch_args=(sys.executable, str(script)),
@@ -119,6 +120,7 @@ for line in sys.stdin:
"cwd": str(tmp_path), "cwd": str(tmp_path),
"provider": "deepseek-official", "provider": "deepseek-official",
"model": "deepseek-v4-flash", "model": "deepseek-v4-flash",
"reasoningEffort": "max",
"maxTokens": 4096, "maxTokens": 4096,
} }
@@ -862,7 +864,9 @@ def test_public_signatures_omit_unsupported_wire_parameters() -> None:
assert "profile" not in inspect.signature(Session.run).parameters assert "profile" not in inspect.signature(Session.run).parameters
assert "system_prompt" not in DeepSeekHarnessConfig.__dataclass_fields__ assert "system_prompt" not in DeepSeekHarnessConfig.__dataclass_fields__
assert "max_tokens" in DeepSeekHarnessConfig.__dataclass_fields__ assert "max_tokens" in DeepSeekHarnessConfig.__dataclass_fields__
assert "reasoning_effort" in DeepSeekHarnessConfig.__dataclass_fields__
assert "max_tokens" in inspect.signature(HarnessClient.initialize).parameters assert "max_tokens" in inspect.signature(HarnessClient.initialize).parameters
assert "reasoning_effort" in inspect.signature(HarnessClient.initialize).parameters
assert "client_name" not in HarnessConfig.__dataclass_fields__ assert "client_name" not in HarnessConfig.__dataclass_fields__
assert "client_version" not in HarnessConfig.__dataclass_fields__ assert "client_version" not in HarnessConfig.__dataclass_fields__
assert {"dsh_bin", "profile", "patches", "dsh_home"} <= set( assert {"dsh_bin", "profile", "patches", "dsh_home"} <= set(