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.JsonRpcLineTransportmoves here verbatim fromdsh-sdk-jsonrpc-server(which now imports it), andtypes.tsnames every payload the server speaks:InitializeParams/Result,SessionPromptParams/Result, the four notification payloads, and theHarnessSdkRequestMap/HarnessSdkNotificationMapindexes. The package root explicitly exports that complete interface and provides no source-module deep imports. The server'snotify()call sites are typed against these named payloads, so server drift breaks compilation, not clients. One behavioral change: an error response now rejects withJsonRpcResponseErrorcarrying the wirecode/data(the Python client already preserved these; the old transport threw a bareErrorwith only the message).@deepseek-ai/dsh-sdk-client(packages/sdk/client/) — the TypeScript twin ofpython/sdk:HarnessClient(spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) underDeepSeekHarness/HarnessSession(lazy start, memoizedinitialize,run()pairing onesession/promptwith itssession.finished). Its package-root consumer interface explicitly exports both client layers, caller-facing types, and the protocol-ownedJsonRpcResponseError; source modules, normalization helpers, and the notification producer stay internal.TurnResult.eventscontains only the root session's typed events, whilenotificationsretains session ids across the root and descendants discovered fromsubagent.started; session-tree scoping is client-side, mirroringclient.py. Deliberate asymmetries with Python: the launch spec is explicitcommand/args(no bundled-runtime resolution — that is a distribution concern with no TS consumer yet);envreplaces rather than merges (callers own credential policy;scrubbedParentEnvfrom the subprocess seam is one import away);TurnResultcarries the structuredreason(Python exposes onlystatus); teardown walks a private stdin-EOF → SIGTERM → SIGKILL ladder to actual exit (the client runs outside any harness context, so it cannot ridectx.subprocess).@deepseek-ai/dsh-subagent-dsh-sdk(packages/subagent/subagent-dsh-sdk/) — the second out-of-processSubagentProvider, structured assubagent-acp's sibling: same all-false capabilities andinheritsParentContext: false, same publish-after-handshake ownership transaction, same result-never-rejects flattening through anonErrorsink, same parent-namespace run id. The child answer is read from streamedsession.events — the last completeassistant/message, else accumulatedtext-deltachunks, so partial answers survive cancellation. Stop reasons map from the child's structuredTurnEndReason(completed/max-tokens/abortedpass through; everything else, including a settled-without-turn child, iserror). Itsprovider/modelconfig feeds the child'sinitialize;envis where deployments pass the child's own key andDSH_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-rejectsettleRunResult, and thesubprocessRunHandlepublication. Process mechanics (spawn, env scrub, tree-scoped teardown) live in thedsh-subprocessseam;subagent-acpspawns throughctx.subprocess, while this backend spawns through the SDK client (the subprocess README's documented exception for SDK-managed transports) and applies the seam'sscrubbedParentEnv()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 unit —
sdk-clientdrives a scripted fake runtime (tests/fake-runtime.ts, env-scripted, protocol-only — the Pythontest_client.pypattern) over real stdio;subagent-dsh-sdkdrives 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.tsboots 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 throughresolveExampleLaunch, so src/lib modes both hold. - Keyless snapshot —
examples/jsonrpc-agent/tests/sdk.snapshot.tsis the jsonrpc example's first snapshot suite: the realdsh-jsonrpc-agentruntime driven through the realdsh-sdk-client, replaying recorded fixtures viallm-replaybehind the newcordis.snapshot.ymloverlay (passed explicitly throughDSH_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=recordmode 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-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-client(packages/sdk/client/)——python/sdk的 TypeScript 孪生:HarnessClient(spawn、分帧、通知扇出、有类型的错误表面、经共享 dispose(资源释放)阶梯关闭至完全停稳)之上是DeepSeekHarness/HarnessSession(惰性启动、记忆化initialize、run()把一个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-sdk(packages/subagent/subagent-dsh-sdk/)—— 第二个进程外SubagentProvider,采用与subagent-acp对等的结构:同样的全 false 能力与inheritsParentContext: false,同样的握手后发布所有权事务,同样通过onErrorsink 将结果归一为绝不拒绝,同样的父命名空间 run id。子答案从流式session.event读取——最后一条完整assistant/message,否则累积的text-delta块,部分答案在取消时得以保留。停止原因由子进程的结构化TurnEndReason映射(completed/max-tokens/aborted直通;其余一切、包括未运行任何轮次便已结束的子进程,都是error)。其provider/model配置喂给子进程的initialize;env是部署传入子进程自有密钥与DSH_CORDIS_CONFIG的地方。- subagent seam 新增
out-of-process.ts:两个进程外后端共享的 provider 侧词汇——NO_START_CAPABILITIES、时限校验、子进程 cwd 解析(配置覆盖、否则发起委托的父会话工作区)、绝不拒绝的settleRunResult、以及subprocessRunHandle发布。进程机制(spawn、环境清理、进程树清理)属于dsh-subprocessseam;subagent-acp经ctx.subprocessspawn 子进程,本后端则经 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,环境变量脚本化、纯协议——即 Pythontest_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-snapshot 的 runScenario 做 SDK 快照。 那个 harness 说 ACP(ClientSideConnection、InputStep 脚本)。SDK 套件的全部意义就是以 SDK 客户端为入口;它复用 normalize/refresh 库层(normalizeSessionLog、refreshFixtureReplacements……),不动 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,与其他录制语料一样随模型行为漂移而重录。