DSH / Atlas
2026-07-27implementedfeature

TypeScript SDK client and the SDK subagent backend

TypeScript SDK 客户端与 SDK subagent 后端

The stdio JSON-RPC serving surface (`@deepseek-ai/dsh-sdk-jsonrpc-server`, the [single-exe Agent Note](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)) had exactly one client: the Python SDK. TypeScript consumers wanting the same drive-a-harness-as-a-subprocess capability — repo tests, automation, and above all a subagent backend whose child is a *complete harness runtime* rather than a

English

Problem

The stdio JSON-RPC serving surface (@deepseek-ai/dsh-sdk-jsonrpc-server, the single-exe Agent Note) had exactly one client: the Python SDK. TypeScript consumers wanting the same drive-a-harness-as-a-subprocess capability — repo tests, automation, and above all a subagent backend whose child is a complete harness runtime rather than a generic ACP agent — had nothing to import: the request/notification payload shapes existed only as anonymous object literals inside the server, and the transport class lived inside the server plugin package.

Decision

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-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. TurnResult.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. Deliberate asymmetries with Python: the launch spec is explicit command/args (no bundled-runtime resolution — that is a distribution concern with no TS consumer yet); env replaces rather than merges (callers own credential policy; scrubbedParentEnv from the subprocess seam is one import away); TurnResult 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-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.events — 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 provider/model config feeds the child's initialize; env is where deployments pass the child's own key and DSH_CORDIS_CONFIG.
  • 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); dsh-jsonrpc-agent-pkg (the Python runtime closure) gains the dsh-sdk-protocol dependency line.

Testing

Four tiers, per testing policy:

  • Keyless unitsdk-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 compositionsubagent-dsh-sdk/tests/loader-composition.e2e.ts boots a test-only cordis.yml (examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/) where the child is a REAL second harness runtime with its own cordis.yml; asserts the parent tool result and the child's own persisted transcript both carry the parent session's cwd. The child launch resolves through resolveExampleLaunch, so src/lib modes both hold.
  • Keyless snapshotexamples/jsonrpc-agent/tests/sdk.snapshot.ts is the jsonrpc example's first snapshot suite: the real dsh-jsonrpc-agent runtime driven through the real dsh-sdk-client, replaying recorded fixtures via llm-replay behind the new cordis.snapshot.yml overlay (passed explicitly through DSH_CORDIS_CONFIG; the jsonrpc bin performs no snapshot config swap of its own). Three scenarios — text turn, bash tool, spawn subagent — each pinning the normalized notification stream, the SDK turn result, and the persisted parent+child logs. This also closes the protocol-tier gap the single-exe note's Python-side snapshot left on the vitest side.
  • 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

Import wire types from dsh-sdk-jsonrpc-server instead of extracting a protocol package. Makes every SDK consumer (including subagent-dsh-sdk, which must not serve JSON-RPC) depend on the server plugin and its dsh-agent/dsh-llm-deepseek peer set, and leaves the notification payloads anonymous. The capability-seam rule (Service Definition / Service Provider / Consumer as separate packages) already names this shape; the transport is genuinely two-sided.

Have subagent-dsh-sdk speak raw JSON-RPC without the client SDK. Duplicates the request/notification pairing, subscription fan-out, timeout, and teardown logic the SDK exists to own; the user's ask was explicitly a backend that uses the SDK, and the layering earns its keep by making the backend ~200 lines of policy over a reusable client.

Fold the SDK backend into subagent-acp with a transport switch. The two backends share the subprocess lifecycle but nothing about the wire (ACP SDK connection vs harness JSON-RPC), the child contract (any ACP agent vs a harness runtime), or the result extraction (agent_message_chunk accumulation vs session-event reading). A config discriminant would bury two protocols in one package; the genuinely shared provider-side parts moved into the subagent seam's out-of-process.ts, and the process mechanics live in the dsh-subprocess seam.

Give the TS SDK bundled-runtime resolution parity with Python. Python's carrier resolution exists to ship wheels to users without Node. A TypeScript consumer definitionally has Node and (in-repo) the workspace; inventing a distribution story with no consumer violates the require-current-need rule. Deferred until a real npm-distribution consumer appears.

Export source modules, normalization helpers, and subscription producer operations. These are implementation details with no caller need; exposing them would make callers learn how the client validates and distributes wire input. The package roots instead enumerate the supported client and protocol interfaces, and the client re-exports the one protocol error callers must distinguish.

Reuse dsh-acp-snapshot's runScenario for the SDK snapshots. That harness speaks ACP (ClientSideConnection, InputStep scripts). The SDK suite's whole point is to drive the SDK client as the entry surface; it reuses the normalize/refresh library layer (normalizeSessionLog, refreshFixtureReplacements, …) and leaves the ACP driver alone.

Consequences

Bought: the SDK runtime protocol now has named, compiler-checked types shared by its server and both client SDKs; TypeScript consumers get the same subprocess-driving capability Python has, with typed errors, structured turn reasons, and package roots that expose only caller-owned operations; the subagent seam gains a harness-native out-of-process backend whose children are full peers (own config, persistence, tools) — the recursive-composition story the seam note anticipated; the jsonrpc example finally has snapshot coverage, through the SDK path itself.

Paid: a third package in the sdk/ group and a fourth subagent backend to keep current; the SDK backend boots a complete plugin tree per child (heavier per-run than an ACP child; pooling remains future work, same as ACP); the wire still has no cancel method, so both the SDK's RequestTimeoutError and the backend's dispose settle locally while the server-side turn runs on until process teardown; fixtures for the snapshot suite were recorded against deepseek-v4-flash and re-record on model-behavior drift like every other recorded corpus.

中文

问题

stdio JSON-RPC 对外服务接口(@deepseek-ai/dsh-sdk-jsonrpc-server,见单文件可执行 Agent Note)当时只有一个客户端:Python SDK。想要同样「把 harness 作为子进程驱动」能力的 TypeScript 消费方——仓库测试、自动化,尤其是一个其子进程是完整 harness 运行时(而非通用 ACP agent(智能体))的 subagent 后端——没有可导入的内容:请求/通知载荷形状只以匿名对象字面量存在于服务器内部,传输类也躺在服务器插件包里。

决策

三个包,分层与既有 Python 栈完全一致,外加一个 Service Provider 注册:

  • @deepseek-ai/dsh-sdk-protocolpackages/sdk/protocol/)—— 把线协议做成共享且具名。JsonRpcLineTransportdsh-sdk-jsonrpc-server 原样移入(后者现在导入它),types.ts 为服务器所说的每个载荷命名:InitializeParams/ResultSessionPromptParams/Result、四个通知载荷,以及 HarnessSdkRequestMap/HarnessSdkNotificationMap 索引。该包根显式导出这一完整接口,且不提供指向源模块的深层导入。服务器的 notify() 调用点以这些具名载荷标注类型,服务器漂移会先破坏编译而不是破坏客户端。一处行为变化:错误响应现在以携带线上 code/dataJsonRpcResponseError 拒绝(Python 客户端本就保留这些;旧传输只抛携带消息的裸 Error)。
  • @deepseek-ai/dsh-sdk-clientpackages/sdk/client/)—— python/sdk 的 TypeScript 孪生:HarnessClient(spawn、分帧、通知扇出、有类型的错误表面、经共享 dispose(资源释放)阶梯关闭至完全停稳)之上是 DeepSeekHarness/HarnessSession(惰性启动、记忆化 initializerun() 把一个 session/prompt 与其 session.finished 配对)。其包根消费方接口显式导出两层客户端、面向调用方的类型,以及协议包所拥有的 JsonRpcResponseError;源模块、规范化辅助函数和通知投递端都保留为内部实现。TurnResult.events 只包含根会话的类型化事件,而 notifications 则保留根会话及从 subagent.started 发现的后代各自的会话 id;基于 subagent.started 血缘边的会话树范围限定在客户端完成,镜像 client.py。与 Python 的刻意不对称:启动规格是显式 command/args(无捆绑运行时解析——那是尚无 TS 消费方的发行问题);env 整体替换而非合并(凭据策略归调用方;subprocess seam 的 scrubbedParentEnv 一个 import 即得);TurnResult 携带结构化 reason(Python 只暴露 status);拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出(客户端运行在任何 harness 上下文之外,无法搭乘 ctx.subprocess)。
  • @deepseek-ai/dsh-subagent-dsh-sdkpackages/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)。其 provider/model 配置喂给子进程的 initializeenv 是部署传入子进程自有密钥与 DSH_CORDIS_CONFIG 的地方。
  • subagent seam 新增 out-of-process.ts:两个进程外后端共享的 provider 侧词汇——NO_START_CAPABILITIES、时限校验、子进程 cwd 解析(配置覆盖、否则发起委托的父会话工作区)、绝不拒绝的 settleRunResult、以及 subprocessRunHandle 发布。进程机制(spawn、环境清理、进程树清理)属于 dsh-subprocess seam;subagent-acpctx.subprocess spawn 子进程,本后端则经 SDK 客户端 spawn 子进程(subprocess README 记载的 SDK 托管传输例外)并自行应用该 seam 的 scrubbedParentEnv()

dsh-sdk-jsonrpc-server 的服务不变(协议字节完全一致);dsh-jsonrpc-agent-pkg(Python 运行时闭包)增加 dsh-sdk-protocol 一行依赖。

测试

四层,依测试政策

  • 免密钥单元——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/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/),其中子进程是真实的第二个 harness 运行时、带自己的 cordis.yml;断言父工具结果与子进程自己持久化的 transcript(文本记录)都携带父会话 cwd。子启动经 resolveExampleLaunch 解析,src/lib 两种模式都成立。
  • 免密钥快照——examples/jsonrpc-agent/tests/sdk.snapshot.ts 是 jsonrpc 示例的第一个快照套件:真实 dsh-jsonrpc-agent 运行时经真实 dsh-sdk-client 驱动,在新的 cordis.snapshot.yml 覆盖层后经 llm-replay 回放已录制 fixture(测试前置数据)(经 DSH_CORDIS_CONFIG 显式传入;jsonrpc bin 自身不做快照配置切换)。三个场景——文本轮次、bash 工具、spawn subagent——各自钉住规范化通知流、SDK 轮次结果与持久化的父+子日志。这也补上了单文件可执行 Note 的 Python 侧快照在 vitest 侧留下的协议层缺口。
  • 带密钥 e2e——快照套件的 DSH_SNAPSHOT=record 模式即真实 API 路径(已提交 fixture 由它产出);组合 e2e 设计上无需密钥。

考虑过的替代方案

dsh-sdk-jsonrpc-server 导入协议类型而不是提取协议包。 会让每个 SDK 消费方(包括绝不能提供 JSON-RPC 服务的 subagent-dsh-sdk)依赖服务器插件及其 dsh-agent/dsh-llm-deepseek peer 集合,且通知载荷仍然匿名。能力 seam 规则(Service Definition/Service Provider/Consumer 三个包分立)已经点名了这种形态;这个传输是货真价实的双边物。

subagent-dsh-sdk 直说裸 JSON-RPC、绕开客户端 SDK。 会复制 SDK 存在意义所在的请求/通知配对、订阅扇出、超时与拆除逻辑;用户的要求明确是一个使用 SDK 的后端,分层的回报是后端成为可复用客户端之上约 200 行的纯策略。

把 SDK 后端折进 subagent-acp、用传输开关区分。 两个后端共享子进程生命周期,但协议(ACP SDK 连接 vs harness JSON-RPC)、子进程约定(任意 ACP agent vs harness 运行时)、结果提取(agent_message_chunk 累积 vs 会话事件读取)毫无共享。配置判别字段会把两个协议埋进一个包;真正共享的提供方侧部分移入 subagent seam 的 out-of-process.ts,进程机制则住在 dsh-subprocess seam。

给 TS SDK 与 Python 对等的捆绑运行时解析。 Python 的载体解析是为了给没有 Node 的用户发 wheel 包。TypeScript 消费方按定义就有 Node,且仓库内消费方还有工作区;为尚不存在的消费方编造发行方案违反「只实现当前需求」的规则。推迟到真实的 npm 发行消费方出现时再处理。

导出源模块、规范化辅助函数和订阅投递端操作。 这些都是调用方不需要的实现细节;暴露它们会让调用方不得不理解客户端如何校验与分发协议输入。各包根转而枚举受支持的客户端接口与协议接口,客户端则只重新导出调用方必须区分的那一种协议错误。

复用 dsh-acp-snapshotrunScenario 做 SDK 快照。 那个 harness 说 ACP(ClientSideConnectionInputStep 脚本)。SDK 套件的全部意义就是以 SDK 客户端为入口;它复用 normalize/refresh 库层(normalizeSessionLogrefreshFixtureReplacements……),不动 ACP 驱动器。

后果

收益:SDK 运行时协议现在拥有服务器与两个客户端 SDK 共享的、编译器校验的具名类型;TypeScript 消费方获得与 Python 相同的子进程驱动能力,且带类型化错误与结构化轮次原因,包根也只暴露归调用方所有的操作;subagent seam 获得一个 harness 原生的进程外后端,其子进程是完整对等体(自有配置、持久化、工具)——正是 seam Agent Note 所设想的递归组合方式;jsonrpc 示例终于有了快照覆盖,而且走的就是 SDK 路径本身。

代价sdk/ 组多了第三个包、subagent 多了第四个要保持最新的后端;SDK 后端每个子进程启动完整插件树(单次成本高于 ACP 子进程;池化与 ACP 一样留作未来工作);协议仍无取消方法,SDK 的 RequestTimeoutError 与后端的 dispose 都只在本地结算、服务器侧轮次会继续运行到进程清理为止;快照 fixture 录制于 deepseek-v4-flash,与其他录制语料一样随模型行为漂移而重录。