DSH / Atlas
2026-07-27implementedfeaturearchived 2026-09-04

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 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, and 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 result carries the final root-session assistant text but no prompt-level status or turn reason. 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. A failed handshake installs a fresh client after successful cleanup so a later call retries with a new process; if initialization and SDK-owned cleanup both fail, start() rejects with an ordered AggregateError and retains the failed client rather than spawning beside a process whose exit was not proved. 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 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 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.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, and ordinary aborted pass through; blocked becomes refusal; other non-completed values become error. Reachable child failures and SDK errors add the bounded safe diagnostic defined by the out-of-process diagnostics decision, using one category plus the current provider stage. 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.

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. Because JSON-RPC requests can dispatch concurrently, it rejects session/prompt until one initialization has completed successfully, preventing pending or invalid routes from falling back to constructor defaults. 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

Four tiers, per testing policy:

  • 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, covering per-run route overrides, reachable child reasons, typed errors, and initialize/session-run/shutdown diagnostics. 100% per-file coverage on all three packages.
  • Keyless Loader composition — subagent-dsh-sdk/tests/loader-composition.e2e.ts boots its package-owned test composition (packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/), where the child is a real second dsh --profile sdk runtime with its own isolated home and ordered patch; the tool result and persisted request header prove provider, model, reasoning effort, maxTokens, and parent-session cwd, while the failure case pins a model-visible child-error diagnostic separately from partial output.
  • Keyless snapshot — snapshots/sdk/sdk.snapshot.ts drives the real dsh --profile sdk runtime through the real dsh-sdk-client, replaying recorded fixtures through ordered llm-replay patches. One DSH SDK scenario pins a model-selected route through the delegation tool, a second SDK runtime, and the child's persisted request header; another pins the normalized notification stream, SDK result, persisted log, and foreground/background failure text for safe diagnostics.
  • 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.

Resolve dsh only from PATH. Rejected: a Node consumer does not reliably inherit a project-local .bin directory. The same-version package dependency supplies the built CLI for installed consumers and the source entry for a clean checkout.

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-session-snapshot's ACP runScenario for the SDK snapshots. That adapter 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 adapter alone.

Consequences

Bought: the SDK runtime protocol 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 has a harness-native out-of-process backend whose children are full peers (own config, persistence, tools), and parent agents receive minimal safe child/SDK failure facts through the same SDK path; the SDK profile pins both successful and failed delegation behavior 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 位于此处,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 活动配对)。其包根消费方接口显式导出两层客户端、面向调用方的类型,以及协议包所拥有的 JsonRpcResponseError;源模块、规范化辅助函数和通知投递端都保留为内部实现。RunResult.events 只包含根会话的类型化事件,而 notifications 则保留根会话及从 subagent.started 发现的后代各自的会话 id;会话树范围限定在客户端完成,镜像 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 时读取,因此凭据策略归调用方,且调用方可在首次使用前完成环境准备。握手失败但清理成功时,实例会换入全新 client,使后续调用通过新进程重试;若初始化与 SDK 自有清理均失败,start() 会以有序 AggregateError 拒绝并保留失败的 client,而不会在尚未证明原进程退出时并排 spawn 新进程。拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出,因为 client 运行在任何 harness 上下文之外。
  • @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 直通,blocked 变为 refusal,其他非完成值变为 error。可达子失败与 SDK 错误会附加进程外诊断决策定义的有界安全诊断,只使用一个 category 和当前提供方 stage。其 dshBin/profile/patch/home 配置选择隔离的 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()。

dsh-sdk-jsonrpc-server 会在 initialize 期间校验确切的提供方/模型/推理强度路由,只保存显式提供的推理强度与 token 值,并使用这条固定的进程级路由创建每个 SDK 根 Agent。由于 JSON-RPC 请求可能并发分派,它会在一次初始化成功完成前拒绝 session/prompt,避免待定或非法路由回退到构造期默认值。TypeScript 与 Python 客户端都通过 dsh --profile sdk 公开同一组初始化字段;Python wheel 会打包该 CLI 及其封闭依赖树。

测试

四层,依测试政策:

  • 免密钥单元——sdk-client 通过真实 stdio 驱动脚本化伪运行时(tests/fake-runtime.ts,环境变量脚本化、纯协议——即 Python test_client.py 的模式);subagent-dsh-sdk 经真实提供方驱动同一伪运行时,覆盖逐次路由覆盖、可达子原因、typed 错误,以及 initialize/session-run/shutdown 诊断。三个包全部 100% 逐文件覆盖。
  • 免密钥 Loader 组合——subagent-dsh-sdk/tests/loader-composition.e2e.ts 启动包自有测试组合(packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/),其中子进程是真实的第二个 dsh --profile sdk 运行时,拥有独立 home 与有序 patch;工具结果与持久化请求 header 会证明提供方、模型、推理强度、maxTokens 与父会话 cwd,失败场景则固定与部分输出分离的模型可见子错误诊断。
  • 免密钥快照——snapshots/sdk/sdk.snapshot.ts 通过真实 dsh-sdk-client 驱动真实 dsh --profile sdk 运行时,并通过有序 llm-replay patch 回放已录制 fixture(测试前置数据)。一个 DSH SDK 场景把模型选择的路由固定在委派工具、第二个 SDK 运行时及子级持久化请求 header 中;另一个场景固定安全诊断的规范化通知流、SDK 结果、持久日志与前台/后台失败文本。
  • 带密钥 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。

只从 PATH 解析 dsh。 拒绝:Node 消费方不一定继承项目本地 .bin 目录。同版本包依赖为已安装消费方提供构建后 CLI,并为干净 checkout 提供源码入口。

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

复用 dsh-session-snapshot 的 ACP runScenario 做 SDK 快照。 该适配器说 ACP(ClientSideConnection、InputStep 脚本)。SDK 套件的全部意义就是以 SDK 客户端为入口;它复用 normalize/refresh 库层(normalizeSessionLog、refreshFixtureReplacements……),不动 ACP 适配器。

后果

收益:SDK 运行时协议拥有服务器与两个客户端 SDK 共享的、编译器校验的具名类型;TypeScript 消费方获得与 Python 相同的子进程驱动能力,且带类型化错误与结构化轮次原因,包根也只暴露归调用方所有的操作;subagent seam 拥有一个 harness 原生的进程外后端,其子进程是完整对等体(自有配置、持久化、工具),父 agent 还能经同一 SDK 路径收到最小安全的子轮次/SDK 失败事实;SDK profile 通过 SDK 路径本身同时固定成功与失败的委派行为。

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