一切皆插件:DeepSeek Harness 源码精读 12 / 14

第 12 章

协议边界:Typert、MCP、ACP、双 SDK

源码核对基于 deepseek-ai/deepseek-harness commit `b150a551b8`(2026-08-21),tag `course-anchor-20260823`对比语料 anthropics/claude-code commit `45bdfa96`(2026-08-23),tag `v2.1.241`本章字数:约 9860 字(不含代码与图)

场景还原

你的团队把 agent 接进了发布流水线。你分到的任务是:写一个程序,让它给 harness 发一个任务、拿回结果,过程中可能还要替它答一次权限问题。你打开代码库想找「给程序用的门」,发现入口全是给人用的:CLI、TUI、Web UI。给人用的门多,给程序用的门一扇都没有。

你决定自己开一扇门。给它加 HTTP 接口:写路由、写参数转换表、写客户端 stub、写输入校验。三个月后业务方法改了签名,路由和 stub 各改各的,接口在线上悄悄返回 500,你查了两天。更糟的是,你在浏览器里能用的那套能力,程序这边一个都够不着。

于是你开始想:能不能让 harness 自己生成这扇门。这正是本章要读的东西。一个 agent harness 怎么同时服务人、服务别的程序、服务别的模型协议?dsh 的答案是四扇门:Typert 让业务方法自动变成 RPC,ACP 把 harness 变成另一个 agent 能驱动的自动化端点,SDK 用一份 wire 协议同时投影出 TypeScript 和 Python 两个客户端,MCP client 让外部工具进得来。四扇门共用同一个 agent loop,人、程序、模型协议各走各的边界。

本章按门逐一精读。先看结论:dsh 对「程序怎么进门」的回答,和它对「人怎么进门」的回答是同一个策略换皮。人走 UI,程序走协议,UI 和协议都只是同一批业务 Service 的投影。

逐行精读

第一扇门:Typert,类型图生成的 RPC

先讲结论:dsh 的 Remote 调用没有手写协议。业务方只声明「这个方法可以被远程调」,其余全部由生成器从类型图产出。先看生成器的自我定位:

packages/typert/generator/README.md5:5
5TypeScript project analyzer and model-driven Typert generator. It converts the developer-authored source type tree into compiler-independent `FaceModel` and `TypeGraph` data before any artifact is rendered. Static analysis can consume that model without Cordis; emitters never receive TypeScript AST or checker objects.

注意最后一句:发射器永远拿不到 AST 或 checker 对象。分析先产出与编译器无关的模型,渲染只消费模型。这意味着类型图本身是协议的唯一事实来源,渲染只是它的一种视图。

那「一次调用」被描述成什么?看协议侧的核心类型:

packages/typert/protocol/src/types.ts172:211
172/** Carrier-independent description of one exported method invocation. */173export interface InvocationDescriptor {174  /** Globally stable generated identity. */175  readonly id: string176  /** Cordis service key owning the method. */177  readonly service: string178  /** Wire namespace, defaulting to the service key. */179  readonly namespace: string180  /** Public instance method name. */181  readonly method: string182  /** Service member invoked when the exported method name is an alias. */183  readonly implementation?: string184  /** Receiver selection mode. */185  readonly invocation:186    | { readonly kind: 'direct' }187    | {188      readonly kind: 'context'189      readonly context: string190      readonly wire: string191      readonly codec: TypertCodec192    }193  /** Optional consuming-Context projection for one direct lookup parameter. */194  readonly scope?: {195    /** Context kind whose Client binder supplies the identity. */196    readonly context: string197    /** Lookup parameter wire field replaced by the Context identity. */198    readonly wire: string199  }200  /** Ordered business parameters. */201  readonly parameters: readonly InvocationParameterDescriptor[]202  /** Transport cancellation injected after business parameters instead of entering wire args. */203  readonly cancellation?: {204    /** Reserved final Host method parameter. */205    readonly parameter: 'signal'206  }207  /** Codec for the resolved method result. */208  readonly result: TypertCodec209  /** Source declaration used only for diagnostics. */210  readonly sourceLocation?: InvocationSourceLocation211}

这段代码要证明的事:一次远程调用需要的全部信息,收在一个与传输无关的对象里。方法名、服务归属、接收者选择模式、参数顺序、wire 字段名、结果 codec,全在。注释里有一行值得圈出来:取消信号在业务参数之后注入,不会进入 wire args。后面 gateway 里会看到它怎么被追加。

接下来看 codec 的两种模式:

packages/typert/protocol/src/types.ts138:147
138/** Codec attached to one invocation parameter or result. */139export type TypertCodec =140  | {141    readonly mode: 'strict'142    readonly typeSymbol: string143    readonly schema: TypertSchema144  }145  | {146    readonly mode: 'src-json'147  }

strict 模式带生成出来的 schema,src-json 模式只标一个身份。两种模式对应两条启动路径:LIB(构建产物,严格生成)和 SRC(源码直跑,弱解析)。同一个 descriptor 结构装下两档强度,这是后面边界条件会用到的事实。

现在关键问题:descriptor 在网上走吗?子系统文档给了一个明确的回答:

docs/subsystems/typert.md41:41
41An `InvocationDescriptor` is local reflection, not a wire message. Host and consumer builds generate corresponding descriptors; the request sends only the endpoint and named `args`. Strict codecs carry generated schemas, while SRC codecs enforce JSON-safe values without structural type recovery. Cancellation is an out-of-band carrier signal injected after business parameters and never enters `args`.

descriptor 是本地反射。Host 端和消费端各自从同一模型生成自己的 descriptor,wire 上只走 endpoint 和具名 args。这是全章最关键的一个决定,设计决策一节会展开。

那为什么选生成,不选手写?决策笔记第一段就说了:

.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md13:13
13The contract for a direct method call belongs to the business Service that implements it. Business developers declare only which methods are remotely callable, without also maintaining a central API interface, routing table, parameter conversion table, client stub, and Zod schema.

业务开发者只声明「哪些方法可远程调」,不维护中心 API 接口、路由表、参数转换表、客户端 stub、Zod schema。这五样东西是手写协议的五份副本,这里全部删掉。

门关上之前还有一道闸:Host 侧的 gateway。它负责把 wire 上的请求变成一次真实的方法调用:

packages/api/gateway/src/index.ts145:184
145  async invoke(request: InvokeRemoteRequest): Promise<unknown> {146    const endpoint = endpointOf(request.namespace, request.method)147    const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint)148    assertExactArguments(request.args, descriptor, endpoint)149    const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint)150    const receiver = receiverContext.get(descriptor.service) as unknown151    if (!isObject(receiver)) {152      throw new TypertGatewayError(153        'service-unavailable',154        endpoint,155        `active Service ${JSON.stringify(descriptor.service)} is unavailable`,156      )157    }158    validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint)159    const args = await Promise.all(descriptor.parameters.map(parameter =>160      this.resolveParameter(parameter, request.args, endpoint)))161    if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL)162    const implementation = descriptor.implementation ?? descriptor.method163    const method = Reflect.get(receiver, implementation) as unknown164    if (typeof method !== 'function') {165      throw new TypertGatewayError(166        'method-unavailable',167        endpoint,168        `active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`,169      )170    }171172    let result: unknown173    try {174      result = await Reflect.apply(method, receiver, args) as unknown175    } catch (error) {176      if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(endpoint, error)177      throw error178    }179    // A weak descriptor declares no return type, so nothing returned is a void180    // result and rides the wire as an absent value field. A strict descriptor181    // keeps its schema: there, undefined has to be a declared result.182    if (result === undefined && descriptor.result.mode !== 'strict') return result183    return decode(descriptor.result, result, 'result-invalid', endpoint, 'result')184  }

读这条链的输入输出:请求进来是 namespace、method、args、signal 四样。第一步 assertExactArguments 按 descriptor 校验 wire 字段,多一个少一个都不行。然后参数逐个解码,lookup 参数换成活对象,signal 追加到业务参数末尾。最后 Reflect.apply 直接调业务方法,结果再过一次 result codec。业务方法本身零改造,gateway 也不知道 Agent、Session 长什么样。错误分类集中在一个 closed union 里:

packages/api/gateway/src/types.ts18:36
18/** Stable infrastructure and boundary failures emitted before or after business execution. */19export type TypertGatewayErrorCode =20  | 'ambiguous-endpoint'21  | 'arguments-invalid'22  | 'binding-invalid'23  | 'context-failed'24  | 'context-not-found'25  | 'context-unavailable'26  | 'definition-unavailable'27  | 'input-invalid'28  | 'invocation-unavailable'29  | 'lookup-failed'30  | 'lookup-not-found'31  | 'lookup-unavailable'32  | 'method-unavailable'33  | 'provider-mismatch'34  | 'result-invalid'35  | 'service-unavailable'36  | 'signature-invalid'

17 个错误码,全是基础设施和边界失败。业务异常不在这里,它们由 RPC adapter 折叠成传输层的 internal 错误。错误分类本身也是协议的一部分。

这张时序图把第一扇门的一次完整调用走一遍:从消费端发起,到网关把 wire 请求变成一次真实方法调用,再到结果编码回送。

sequenceDiagram participant C as ConsumerRemote participant Conn as Connection participant G as TypertGateway participant S as HostService C->>C: 本地 InvocationDescriptor 编码 args C->>Conn: rpc.call 发送 endpoint 与具名 args Conn->>G: invoke namespace method args signal G->>G: resolveDescriptor 与 assertExactArguments G->>S: Reflect.apply 业务方法 S-->>G: 业务结果 G->>G: result codec 解码 G-->>Conn: RPC result 回送 Conn-->>C: 按相同 rpcId 投递

第二扇门:ACP,另一个 agent 的入口

Typert 是给「同进程的浏览器客户端」用的,ACP 是给「另一个 agent」用的。先看模块头:

packages/acp/acp/src/index.ts1:10
1/**2 * Automation-only Agent Client Protocol server over JSON-RPC stdio.3 *4 * The bridge exposes fresh harness sessions to trusted programmatic clients. It5 * carries prompt text/images, committed assistant text/images, cancellation,6 * and one-shot permission decisions; presentation and human-interaction7 * features stay with the harness's UI modules.8 *9 * @module @deepseek-ai/dsh-acp10 */

三句话讲完这个模块的全部职责:fresh sessions、prompt 文本/图片、committed 输出、取消、一次性权限。演示和人类交互留在 UI 模块。automation-only 是标题,正文每一处都照这个标题执行。

权限通道是理解「automation-only」的最佳样例:

packages/acp/acp/src/index.ts271:285
271  ctx.on('approval/request', (request, next) => {272    const record = ownedRecord(request.agent)273    if (record === undefined || request.callId === undefined) return next()274    return conn.requestPermission({275      sessionId: record.agent.session.id,276      toolCall: { toolCallId: request.callId },277      options: [278        { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },279        { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },280      ],281    }).then(({ outcome }) => {282      if (outcome.outcome === 'cancelled') return 'cancelled'283      return outcome.optionId === 'allow-once' ? 'allowed-once' : 'rejected'284    })285  })

选项只有两个:allow-once 和 reject-once。没有「记住这个选择」。approval 事件被转成 machine policy channel,一次一问,答完即忘。审批决策本身留在 approval seam 里,ACP 只搬运决策结果。

会话参数校验是另一处收窄:

packages/acp/acp/src/index.ts538:545
538/** Reject session features outside the automation contract. */539function validateSessionParams(params: NewSessionRequest): void {540  if (!isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)541  if (params.additionalDirectories !== undefined && params.additionalDirectories.length > 0) {542    throw invalidParams('additionalDirectories is not supported')543  }544  if (params.mcpServers.length > 0) throw invalidParams('mcpServers is not supported')545}

客户端想带目录、想挂 MCP server,直接拒绝。注意是 throw 不是忽略。这背后是 fail loud 原则,边界条件一节再展开。

这张状态图回答:一个 ACP prompt 从进门到出结果,中间经过哪几个状态,取消能在哪些状态赢。

stateDiagram-v2 [*] --> Idle Idle --> Admitted: session/prompt 通过校验 Admitted --> Queued: 消息进入持久化收件箱 Queued --> Running: turn 开始 Running --> Quiescing: turn/end 到达 Quiescing --> Settled: 输出交付完毕 Settled --> [*] Admitted --> Settled: 取消赢过准入 Running --> Settled: 取消或错误

第三扇门:SDK,程序的双语入口

SDK 门有自己的协议包。整个 wire 长什么样?一个文件的两张表就装完了:

packages/sdk/protocol/src/types.ts92:105
92/** Server-to-client notifications by JSON-RPC method name. */93export interface HarnessSdkNotificationMap {94  'session.event': SessionEventNotification95  'session.status': SessionStatusNotification96  'subagent.started': SubagentStartedNotification97  'subagent.finished': SubagentFinishedNotification98}99100/** Client-to-server request methods with their param and result shapes. */101export interface HarnessSdkRequestMap {102  'initialize': { params: InitializeParams; result: InitializeResult }103  'session/prompt': { params: SessionPromptParams; result: SessionPromptResult }104  'shutdown': { params: undefined; result: Record<string, never> }105}

客户端能做的只有三件事:握手、发 prompt、关机。服务器主动推的只有四件事:会话事件、状态迁移、子代理开始、子代理结束。对比一下 Typert 的 17 个错误码,SDK 的整个协议就两张表,这个「小」是设计出来的,后面会讲。

服务端实现把三件事映射到三个 handler:

packages/sdk/server/src/server.ts190:201
190  async handleRequest(method: string, params: Record<string, unknown> | undefined): Promise<unknown> {191    switch (method) {192      case 'initialize':193        return this.initialize(params as unknown as InitializeParams)194      case 'session/prompt':195        return this.prompt(params as unknown as SessionPromptParams)196      case 'shutdown':197        return this.shutdown()198      default:199        throw new Error(`unknown DeepSeek Harness SDK runtime method: ${method}`)200    }201  }

未知方法直接抛错,变成 JSON-RPC error response。协议没有「扩展点」的设计,新方法就是改这张 switch 和协议包两张表,一起提交。

客户端有两个。TypeScript 客户端自己的模块头把关系讲清楚了:

packages/sdk/client/src/client.ts1:13
1/**2 * Low-level JSON-RPC client for a DeepSeek Harness SDK runtime subprocess.3 * {@link HarnessClient} owns the child process: it spawns the runtime, speaks4 * the `@deepseek-ai/dsh-sdk-protocol` wire over the child's stdio, fans5 * server notifications out to subscriptions, and tears the child down to6 * quiescence through a private EOF → SIGTERM → SIGKILL ladder. The design7 * twin is the Python SDK's `HarnessClient` (`python/sdk`); both drive the8 * same runtime protocol. This client runs OUTSIDE any harness context, so it9 * spawns directly rather than through the `dsh-subprocess` service — the10 * seam's documented exception for SDK-managed transports.11 *12 * @module @deepseek-ai/dsh-sdk-client/client13 */

「design twin」:Python 的 HarnessClient 和它是同一份协议的镜像实现。两个客户端都直接 spawn runtime 子进程,不走 dsh-subprocess seam,因为这个客户端本身就在 harness 上下文之外。teardown 用 EOF 到 SIGTERM 到 SIGKILL 的阶梯,和上一章读过的进程树终止是同一种思路。

Python 侧对应物:

python/sdk/src/deepseek_harness/client.py37:38
37class HarnessClient:38    """Synchronous JSON-RPC client for the DeepSeek Harness SDK runtime over stdio."""

同步 API,而 TypeScript 侧是异步。同一个 wire,两种语言惯用法。高层封装再垫一层:

python/sdk/src/deepseek_harness/api.py48:54
48class DeepSeekHarness:49    """Reusable synchronous SDK for running DeepSeek Harness agent turns.5051    The runtime subprocess starts lazily and remains owned by this instance52    across calls to :meth:`run`. Use the instance as a context manager, or call53    :meth:`close` explicitly when finished, so the subprocess is always reaped.54    """

runtime 子进程懒启动、归实例所有、用 close 显式收尾。python/sdk 的 README 写明:安装 deepseek-harness-sdk 会装上同版本的 deepseek-harness-runtime-bin 平台 wheel,零配置跑起来靠 DSH_CORDIS_CONFIG 注入默认 cordis.yml。协议之外的东西,包括默认组合,都是配置。

两个客户端投影同一个 loop,这条规则写进了 AGENTS.md:

AGENTS.md127:127
127- **Both SDKs project the loop.** Agent-loop, session-lifecycle, and `SessionEventMap` changes update the TypeScript and Python SDK expected outputs in the same PR; `pnpm run test` covers neither ([surfaces](docs/testing.md#when-a-snapshot-test-is-required)).

注意最后半句:pnpm run test(单元测试)两边都不覆盖。那靠什么兜底?testing.md 给了答案:

docs/testing.md49:49
49Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples/<name>/tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/test-support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the internal canonical-event JSONL snapshots and replay fixtures. The `pwsh-tool-turn` ACP scenario boots real `pwsh` and skips where it is absent. Completed interactive-terminal journeys use JSONL-driven scenarios under `apps/cli/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. The two SDKs project the agent loop, session lifecycle, and `SessionEventMap` independently, so changing any of those updates both: `examples/jsonrpc-agent/tests/snapshots/` owns the TypeScript client; `scripts/snapshots/python-sdk-single-exe/` owns the Python client, which only the required `python-runtime` CI job runs. New capability seams, lifecycle variants, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation.

两个客户端各自有快照套件:TS 的归 examples/jsonrpc-agent/tests/snapshots/,Python 的归 scripts/snapshots/python-sdk-single-exe/。loop 一动,两个套件的期望输出都得跟着动,同一 PR 里提交。

这张流程图回答:loop 一改,什么必须跟着动,动到哪里算完。

flowchart TD X[agent-loop 或 SessionEventMap 改动] --> Y1[TS 客户端期望输出] X --> Y2[Python 客户端期望输出] Y1 --> Z[双快照套件] Y2 --> Z Z --> W[同一 PR 提交]

反向门:MCP,外部工具进来

前三扇门都是「走出去」,MCP client 是「请进来」:

packages/mcp/mcp-client/src/index.ts1:14
1/**2 * MCP client bridge plugin: connects to an external MCP server and registers3 * its tools on `ctx.tools` under server-qualified public names4 * (`mcp__<serverName>__<rawName>`). Each plugin instance connects to one MCP5 * server; load multiple instances in `cordis.yml` for multiple servers.6 *7 * Namespace plugin (named exports, no default export). Lifecycle is8 * effect-scoped: disposal disconnects from the server, unregisters all tools,9 * and releases the `serverName` namespace reservation. HMR hot-swaps by10 * disposing the old instance and creating a new one; identical `serverName`11 * reproduces identical public tool names.12 *13 * @module @deepseek-ai/dsh-mcp-client14 */

每个 MCP server 是一个插件实例,工具注册到 ctx.tools,公开名带 server 限定前缀。工具桥的实现文件把命名契约写得更细:

packages/mcp/mcp-client/src/tools.ts1:13
1/**2 * Tool bridge: discovers MCP tools, registers them on the harness ToolRuntime3 * under deterministic server-qualified public names, and handles re-sync when4 * the server's tool list changes.5 *6 * Naming contract (see the mcp-client Agent Note "Naming invariants"): every MCP tool7 * has the stable identity `(serverName, rawName)`; the model-facing public name8 * is `mcp__<serverName>__<rawName>`, normalized to the DeepSeek function-name9 * constraints. The raw name is only ever sent on the wire (`tools/call`); the10 * public name is never parsed to recover it.11 *12 * @module13 */

两个方向严格分开:模型看到的是 mcp__<serverName>__<rawName>,线上发给 MCP server 的是原始 rawName,公开名永远不被解析回去。这个命名方案在 Agent Note 里被论证为行业标准:

.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md97:97
97This server-qualified shape is the de-facto standard among multi-server agent clients — every surveyed end-user product qualifies MCP tools by server ([Claude Code](https://code.claude.com/docs/en/agent-sdk/mcp#tool-naming-convention) `mcp__github__list_issues`, [Codex](https://openai.com/index/unrolling-the-codex-agent-loop/) `mcp__weather__get-forecast`, [Gemini CLI](https://geminicli.com/docs/tools/mcp-server/#3-tool-naming-and-namespaces), [VS Code](https://github.com/microsoft/vscode/blob/ab9ec62c6a61e429a9abd612ff220c3f4834c9ea/src/vs/workbench/contrib/mcp/common/mcpServer.ts#L217-L260), [Cline](https://github.com/cline/cline/blob/52fdbb1d72f7324a28142a7ba7678d4b53c902f4/sdk/packages/core/src/extensions/mcp/name-transform.ts#L20-L35), [Roo Code](https://github.com/RooCodeInc/Roo-Code/blob/b867ec9145750d0ae1ff7f02d35406e9bf2a0b16/src/utils/mcp-name.ts#L117-L140), [Goose](https://github.com/block/goose/blob/b3a012cbdde854b0fe14f95b1c48543bf6517c0a/crates/goose/src/agents/extension_manager.rs#L1391-L1441), [OpenCode](https://github.com/anomalyco/opencode/blob/d199b1bff90282a4f9cd6251b5fc7b16875a52f6/packages/opencode/src/mcp/catalog.ts#L117-L120)); the exact `mcp__<server>__<tool>` spelling follows Claude Code and Codex. The `mcp__` marker keeps MCP registrations out of the native tools' namespace and gives permission/telemetry rules a stable shape (`mcp__*`, `mcp__github__*`).

拼写学 Claude Code 和 Codex,mcp__ 前缀把 MCP 注册挡在原生工具命名空间之外,权限和遥测规则因此有稳定形状(mcp__*mcp__github__*)。命名不变式收成五条:

.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md131:137
131### Naming invariants1321331. Every MCP tool has the stable identity `(serverName, rawName)`; every active identity has exactly one public name.1342. Public names are deterministic, globally unique, and satisfy the DeepSeek 64-char `[A-Za-z0-9_-]` contract.1353. MCP `tools/call` always receives the original raw name.1364. Connecting, disconnecting, or re-syncing an unrelated server never renames an existing tool.1375. Registration order never determines which tool is available.

第五条值得单独读:哪个工具可用,永远与插件加载顺序无关。这是对「碰撞时再说」方案的正面否定,横向对比一节会看到另一侧怎么做。

这张图把四扇门摆在一起:左侧是 harness 的同一批业务能力,右侧是四类客人。

flowchart LR L[Harness loop 与业务 Service] L --- T[Typert Remote 生成 RPC] L --- A[ACP automation-only server] L --- S[SDK JSON-RPC server] L --- M[MCP client 桥] T --> H1[浏览器客户端] A --> H2[另一个 agent] S --> H3[TS 客户端] S --> H4[Python 客户端] M --> H5[外部 MCP server]

设计决策分析

决策一:类型图生成 RPC,为什么不用手写协议

手写协议的问题在于它把一份契约复制成多份。路由表一份、参数转换一份、客户端 stub 一份、schema 一份,业务方法自己又是一份。改签名要同步五处,漏一处就是线上漂移,而漂移的错在运行时才暴露:参数对不上、字段名拼错、类型悄悄收窄。

dsh 的做法是把五份副本合成一份来源:业务方法的类型本身。Agent Note 第一段说清了所有权归属,contract 属于实现它的业务 Service,业务开发者只声明「哪些方法可远程调」。generator README 又补了实现边界:分析先产出与编译器无关的模型,发射器不碰 AST。这一层隔离让协议生成不绑定 TypeScript 生态,模型可以喂给任何渲染器。

代价是生成器本身要写。Typert 的 analyzer 有 3113 行(packages/typert/generator/src/analyzer.ts),比任何一张手写路由表都贵。这个成本在只有三五个端点时不合算,在业务 Service 持续增长时划算:每加一个 @Remote 方法,descriptor、双端 codec、wire 命名全部自动跟上。

还有个细节值得注意:descriptor 从不上网,双端各自从同一模型生成。这意味着 wire 上没有版本协商问题,也不存在对端伪造 descriptor 的攻击面。协议的唯一事实来源在构建期,不在运行时。

决策二:ACP 只做 automation,权限面刻意收窄

automation-only 的来龙去脉在简化笔记里。原话是问题本身:

.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md9:9
9The ACP bridge had become a second interactive product UI. It translated durable events into editor cards, terminal metadata, diffs, plans, titles, reasoning, commands, modes, model and permission pickers, session navigation, and human elicitation. Those responsibilities duplicated the TUI and the Web client while coupling an automation transport to UI services, persistence queries, presentation policy, and editor-specific conventions.

ACP bridge 变成了第二个交互式产品 UI。编辑器卡片、终端元数据、diff、计划、标题、推理、命令、模式、模型选择器、权限选择器、会话导航、人类征询,全塞进一个自动化传输协议里。那些职责重复了 TUI 和 Web 客户端,同时把传输层耦合到 UI 服务、持久化查询、展示策略和编辑器专属约定。

收窄之后的公开协议,笔记第 17 行列了全清单:版本协商、新会话、每会话一个 in-flight prompt、committed 文本/图片更新、按会话取消、并发会话、连接所有权的 teardown。拒绝清单同样明确:additionalDirectories、MCP servers、audio、embedded resources、格式错误的 prompt、未知会话、重叠 prompt。第 21 行补了输出侧:只发 committed 的 assistant/message 文本和图片,推理、原始 chunk、工具活动、todos、计划、标题、重试标记、终端元数据、diff、locations、资源链接都留在 durable session log 或 UI 专属传输里。还明确不提供 session load/list/delete、commands、modes、配置选择器、模型切换、计划评审、人类征询。

不这样做会出什么事:自动化传输会持续长出第二个 UI 的每一块骨头。交互契约有两个在演化,编辑器约定渗进自动化边界,权限通道变成人机混淆的开口。现在 ACP 只回答七件事,任何一件被质疑「能不能加个 UI 功能」都有同一句拒绝词:那是 TUI 和 Web 的活。

决策三:双 SDK 投影同一 loop,改 loop 必须同步两边期望输出

双 SDK 是「一份协议、两套镜像、三个套件」的结构。协议就是 packages/sdk/protocol/src/types.ts 那两张表,TS 和 Python 客户端各自独立实现同一个 wire,loop 改动时必须同时更新两边快照,因为两边都投影了 loop 的期望输出,而 pnpm run test 两边都不覆盖。

为什么说这是「投影」而不是「两份 SDK」:两个客户端不各自定义行为语义,它们的行为语义就是 wire 协议加上服务器那边的实现。session/prompt 返回 messageId 之后什么算完成,由客户端自己拼 session.event 流和 session.status 决定,但判定依据是同一套事件。这就是 testing.md:49 说的「project the agent loop, session lifecycle, and SessionEventMap independently」:两个投影互相独立,来源相同。

协议刻意小,小到协议包自己的 README 承认三个缺口:

packages/sdk/protocol/README.md37:39
37- **No protocol-version negotiation** — the handshake carries only `serverInfo.version` (`0.0.1`, unvalidated by clients); pre-release stance, no compatibility promise.38- **No cancel or session-close methods** — a client abandons a turn by closing the runtime process; see the [`dsh-sdk-jsonrpc-server` README](../server/README.md).39- **Server→client requests are dead capability** — the transport supports them, but the server never sends one; the Python SDK's responder surface exists for future approval flows.

没有版本协商、没有取消方法、server 到 client 的请求是死能力。这三个缺口被明说:预发布立场,没有兼容性承诺;取消靠关进程;回应面留给未来审批流。协议边界的最小化在这里是显式决策:三件事的调用方都不需要,就先不做。

不这样做会出什么事:loop 行为变了,比如 turn/end 的 reason 词汇加了新值,两个客户端的期望输出各自落后,一个先修一个后修,中间窗口里 Python 用户和 TypeScript 用户看到的行为不一致。快照套件的存在把「必须同步」从口头约定变成提交门禁:哪个套件红了,哪个客户端没跟上。

决策四:不这样做,四扇门各自会出什么事

合起来看,本章四扇门对应四种失败的形态。Typert 门不生成而手写,出协议漂移:路由表、转换表、stub、schema 四处各演各的。ACP 门不收窄,出职责蔓延:自动化传输长成第二个 UI,权限通道混入人机交互。SDK 门不投影同源,出行为分叉:同一个 loop 在两个语言里长出两种语义,快照门禁形同虚设。MCP 门不恒前缀,出碰撞与改名:工具可用性依赖加载顺序,session history 和权限规则在会话中途被改名打断。四扇门是同一个策略的四个实例:边界上的东西越少,越不容易各自漂移。

边界条件剖析

边界一:strict descriptor 被撤销后,SRC fallback 永久禁止

问:一个 endpoint 先用 LIB 严格生成注册过,之后这个注册被撤销,网关会不会退回 SRC 弱解析?

答案是明确的拒绝。看网关的 descriptor 解析:

packages/api/gateway/src/index.ts224:235
224  private resolveDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor {225    const strict = this.ctx.typert.local.get(endpoint)226    if (strict !== undefined) return strict227    if (this.ctx.typert.local.hasSeen(endpoint)) {228      throw new TypertGatewayError(229        'definition-unavailable',230        endpoint,231        'its strict definition was withdrawn and SRC fallback is forbidden',232      )233    }234    return this.resolveSrcDescriptor(namespace, method, endpoint)235  }

hasSeen 记住了这个 endpoint 曾经以 strict 形态存在过,一旦见过,即使定义被撤销也禁止 SRC fallback,抛 definition-unavailable。registry 的 hasSeen 契约在 packages/typert/protocol/src/types.ts:340-344 写明:endpoint 至少注册过一次就返回 true,即使已撤销。

为什么这样设计:同一个 endpoint 在同一进程生命周期内不允许在 strict 和 weak 两种语义之间震荡。插件热更新时定义被卸载又重新挂载,如果中间退回了 SRC,调用方会先看到弱校验后看到强校验,同一个请求两种结局。HMR 是常态,这种震荡防的就是 HMR 期间的语义漂移。

边界二:ACP 客户端带上 mcpServers 或 additionalDirectories

问:自动化客户端在 session/new 里要求附加目录或挂 MCP server,会怎样?

validateSessionParamspackages/acp/acp/src/index.ts:538-545):直接 throw invalidParams。注意是拒绝,不是忽略。

拒绝的理由:如果忽略,客户端会以为服务器接上了附加目录,实际没有,后续工具调用在错误前提上跑。fail loud 让错误提前到握手那一刻,客户端当场拿到失败。这个选择与 AGENTS.md 的「Misconfiguration fails loud at load when self-contained, otherwise at the earliest resolvable point」一致。同理,会话参数里 mcpServers.length > 0 就拒绝,因为 ACP 组合根本不装 MCP 相关服务,接了也没人消费。

边界三:Python SDK 怎么把子代理的通知归到父会话

问:父会话跑 Session.run() 时,子代理的通知会不会混进结果?

Python 客户端维护了一张亲子关系表,subagent.started 通知到达时记录 parentSessionIdchildSessionId 的边(python/sdk/src/deepseek_harness/client.py:494-503_record_session_relationship_locked)。投递时用祖先链判定(同文件 _notification_belongs_to_session_tree):通知携带的 sessionId 只要沿父子边能走到目标根会话就投递,走不到就过滤。

注意一个细节:subagent.finished 只发 in-process 子代理,remote runs 不报(packages/sdk/protocol/src/types.ts:74-75 的注释写明)。所以远程子代理的通知不会出现,也就不存在归属问题。这是协议层就把边界定死的又一个例子:不支持的形态在协议里直接不存在。

边界四:两个 MCP server 抢同一个 serverName

问:cordis.yml 里配了两个 mcp-client 实例,serverName 相同,会怎样?

加载时后者直接失败。packages/mcp/mcp-client/src/index.ts:38-39activeServerNames WeakMap 记录按 root Context 划分的存活命名空间,重复即配置错误,在插件加载时报错,绝不静默 shadowing。Agent Note 的命名不变式第 4 条顺带钉死了另一个场景:任何无关 server 的连接、断开、重同步都不会改名已有工具。re-sync 遇到 registry 冲突时,这一代生成全部回滚,这个 server 的工具一个都不注册,错误记日志。

为什么这样设计:如果允许后者覆盖前者,已有工具会悄悄改名或消失,session history 里记录的工具名和权限规则全部作废,而且工具集变得依赖插件加载顺序。五条不变式里第五条「Registration order never determines which tool is available」是这条边界的正面表述。

横向对比

对比组一:MCP 工具命名,同一个问题的两种答案

同一个问题:外部 MCP 工具进入模型的工具集时,怎么起名才不撞?

dsh 的答案:mcp__<serverName>__<rawName>serverName 是本地配置,明确来自 cordis.yml 的插件实例,与远端 serverInfo.name 无关(远端名字不可信、跨部署不唯一、升级会变)。claude-code 的答案在它的插件文档里:

claude-code/plugins/plugin-dev/skills/mcp-integration/references/tool-usage.md7:7
7Once an MCP server is configured, its tools become available with the prefix `mcp__plugin_<plugin-name>_<server-name>__<tool-name>`. Use these tools in commands and agents just like built-in Claude Code tools.

格式如下:

claude-code/plugins/plugin-dev/skills/mcp-integration/references/tool-usage.md14:14
14mcp__plugin_<plugin-name>_<server-name>__<tool-name>

两种拼写共享同一个骨架:mcp__ 恒前缀加两段限定加原始名。差异在中间段:claude-code 多一个 <plugin-name> 段,因为它的 MCP server 配置挂在插件作用域下,同名 server 在不同插件里是不同实例,必须带插件名才能全局唯一。dsh 的 server 本身就是插件实例,serverName 已经唯一,不需要额外段。

各自代价:dsh 的名字短一段,但 serverName 唯一性要靠配置纪律保证,重复就加载失败。claude-code 的名字长,每次模型调用都多付 token,但作用域隔离是结构性的:插件 A 的 server 和插件 B 的 server 永远不会撞名。两侧还有个共同点值得注意:公开名都只对模型可见,claude-code 端同样有 allowed-tools: ["mcp__plugin_asana_asana__*"] 这种通配权限形状,dsh 端则是 mcp__*mcp__github__*。两边的权限规则都受益于恒前缀。

对比组二:外部程序接入,自建协议双 SDK 对 Agent SDK

第二个问题:程序想驱动这个 harness,用什么接入?

dsh 的答案是在自己仓库里闭环:自定 wire 协议(packages/sdk/protocol)、自写服务端(packages/sdk/server)、双语言客户端(TS 与 Python)、双快照套件、打包进 wheel 的 bundled runtime。全链路可审计,CI 无 key 可复现。

claude-code 侧的答案:检索结论先行。上游公开仓库(commit 45bdfa96,v2.1.241)没有主产品 SDK 实现源码,检索关键词:json-rpc、agent-sdk、client、sdk、stdio、stream-json。仓库里只有 plugins、examples、CHANGELOG 和文档。能从公开材料确认的是行为记录:

claude-code/CHANGELOG.md5057:5057
5057- SDK: The Claude Code SDK is now the Claude Agent SDK
claude-code/CHANGELOG.md4932:4932
4932- Removed legacy SDK entrypoint. Please migrate to @anthropic-ai/claude-agent-sdk for future SDK updates: https://platform.claude.com/docs/en/agent-sdk/migration-guide
claude-code/CHANGELOG.md3191:3191
3191- Fixed Python Agent SDK: `type:'sdk'` MCP servers passed via `--mcp-config` are no longer dropped during startup

三条记录拼出形状:SDK 是独立产品线(Claude Agent SDK),经历过 entrypoint 迁移,有 Python 变体,MCP server 能通过 SDK 的 --mcp-config 注入,还有 setMcpServers() 之类的运行时 API(CHANGELOG 第 27 行)。实现细节在公开仓库之外,无法逐行比对。

两种答案的代价:dsh 把协议、服务端、客户端、测试全部放在自己仓库里,改动闭环、快照可离线验证,代价是协议自己维护,SDK 生态只有两个官方客户端。claude-code 把 SDK 作为外置产品,面向更广的生态,代价是公开仓库看不到实现,行为只能靠发布说明观测。拿不准的地方标注为推断:dsh 的 fork 点之后两边各自演进,差异可能来自主动选择,也可能来自上游后来自己改的。

dsh 内部对照:ACP 自动化面与 SDK 程序面

再用 dsh 内部对照补一组:同一个 harness 的会话能力,ACP 和 SDK 两种暴露。ACP 的服务对象是另一个 agent:一次一个 prompt、只给 committed 文本/图片、权限一次一问。SDK 的服务对象是程序:自由订阅 session.event 全量流、自主判定活动边界、子代理通知带父子归属。ACP 的回合语义严格,因为调用方需要确定性;SDK 的事件流开放,因为调用方需要灵活性。两边的取舍反过来看就是代价:ACP 的客户端拿不到工具轨迹和中间推理,SDK 的客户端要自己拼完成判定。

互动演示设计

形态:格式实验台。 一句话结论:同一个「让 agent 跑一个 turn」的意图,在四扇门前长成四种不同的帧,协议边界的工作就是让它们都能到达同一个 loop。

舞台元素: 中间一盏 harness loop 灯箱(发光呼吸效果),周围四个协议窗格:Typert wire、ACP 帧、SDK 帧、MCP 帧。比喻:同一份指令从四个窗口递进同一间办公室,每个窗口有自己的纸(格式)。底部一条逻辑轨迹面板,逐行滚动真实代码。右上角一个「意图」选择器:创建 goal、发 prompt、查子代理。

分步与字幕(6 步):

  1. 用户选「创建 goal」,四窗格全部清空待命。字幕:「一个意图,四条通道,同一间办公室。」
  2. Typert 窗格亮起,显示 wire 帧 POST /api/goals/create{"args": {"agentId": "...", "request": {...}}}。字幕:「Typert 的线上只走 endpoint 和 args,descriptor 留在两端自己生成。」
  3. ACP 窗格亮起,显示 session/new、prompt、agent_message_chunk 三个帧。字幕:「ACP 是另一个 agent 的门,一次一问,只回 committed 答案。」
  4. SDK 窗格亮起,显示 initialize、session/prompt、session.event 三帧。字幕:「SDK 的门最窄也最宽:三个请求四个通知,事件全量流。」
  5. MCP 窗格亮起,显示 mcp__github__create_issue 进工具表、tools/call 出 rawName。字幕:「MCP 是反向门,工具进得来,名字永远带门牌。」
  6. 四窗格同帧播放,loop 灯箱闪一次。字幕:「四扇门各有各的纸,进的都是同一个 loop。」

逻辑轨迹面板伪代码(右侧标真实行号,随步进高亮):

text
const descriptor = this.resolveDescriptor(namespace, method, endpoint)   // api/gateway/src/index.ts:147
assertExactArguments(request.args, descriptor, endpoint)                 // api/gateway/src/index.ts:148
args.push(request.signal ?? NEVER_ABORTED_SIGNAL)                        // api/gateway/src/index.ts:161
result = await Reflect.apply(method, receiver, args)                     // api/gateway/src/index.ts:174
return decode(descriptor.result, result, 'result-invalid', endpoint, ...) // api/gateway/src/index.ts:183

步骤 2 高亮 147、148,步骤 3 高亮 174,步骤 4 高亮 161 与 183,其余步骤面板变暗。

读者可操作: 切换右上角「意图」选择器,观察同一意图在四个窗格里的帧差异;点任意帧可展开帧头解释。面板左下角一个「快照」开关:打开后显示双 SDK 快照套件路径(TS 归 jsonrpc-agent、Python 归 python-sdk-single-exe),配字幕「改 loop 的 PR 必须两个套件一起绿」。

可迁移结论

  1. 协议由生成器产出,声明即契约。 值得抄。最小成本形态:一张类型图加一个渲染模板,业务方只写声明,路由、转换、stub、schema 全部自动生成。前提是你有超过五六个端点,且业务类型可以集中描述。端点少时手写更便宜,这是体量判断。

  2. 给程序的面刻意收窄。 值得抄,且不依赖 TypeScript。一个模块只回答七个问题,多一个都不要,拒绝清单写进文档。你的「自动化接口」只要开始回答人机交互问题,它就会长成第二个 UI。最小形态:写接口之前先列「不做清单」,把每个「能不能加个展示功能」的答案定成「那是前端的事」。

  3. 一份 wire、两套镜像、双向快照。 值得抄,这是本章唯一不依赖 TypeScript 的完整方案,Python 侧就是证据。最小形态:一份协议文件(两张表),两个薄客户端,一个录制回放测试。改核心行为时,两个客户端的事先写好的断言一起改,哪个没跟上传言哪个。你不需要 JSON-RPC 或快照框架,任何「先记录再比对」的测试都可以。

  4. 外部命名恒前缀,碰撞从结构上消失。 值得抄。<来源>__<名字> 加上确定性归一化,长度溢出时拼 hash 防碰撞,公开名永远不回解析成来源。最小形态:一个纯函数加五条不变式测试。注意这条的边界:前缀方案保证的是「不会因为改名破坏历史」,它不能代替权限检查,命名隔离和授权是两件事。

思考题

  1. InvocationDescriptor 为什么从不上网?如果把 descriptor 序列化到 wire 上让对端直接使用,会引入哪几类问题?提示:从版本同步、攻击面、双端同源三个角度想。

  2. (动手题)在任意 cordis.yml 里配两个 mcp-client 插件实例,serverName 都填同一个值,启动 harness 观察报错。再把其中一个改个名字,验证两个 server 的工具并存为 mcp__a__*mcp__b__*。具体做法:参考 examples/mcp-memory/ 的组合写法,起一个本地 fixture MCP server(仓库测试里用 @modelcontextprotocol/server-filesystem 即可),改动只在配置层,不需要改源码。观察到的错误消息应该指向命名空间重复,加载到此为止。

  3. validateSessionParamsmcpServers 选择 throw。把 throw 换成忽略会改变谁的什么假设?结合「Misconfiguration fails loud」条款回答:忽略的版本里,客户端在什么时间点、以什么方式发现 mcpServers 没生效?

  4. 双 SDK 的期望输出为什么用 keyless snapshot 而不用真实模型 E2E?从 CI 可复现性、成本、失败定位三个角度分析,并说明「keyless」这个限定词为什么是必要的。