# Agent Note: Code Mode——模型针对工具注册表编写 TypeScript

Status: implemented

[English](2026-06-15-code-mode.md) | 中文

## 问题

在注册表的原生呈现方式下，agent loop（智能体循环）将每个可见能力以 JSON Schema 函数定义的形式通告给模型。`ToolRegistry` 将其 schema 贡献给系统提示词组装，组装结果中的 `tools` 落到协议格式（wire format）上（也记录在请求头日志中），模型每步调用一个 `tool-call` 块，循环通过 `ctx.tools.execute()` **逐个**分发每次调用（并行工具执行是 `dsh-tools` 和 [docs/architecture.md](../../../../docs/architecture.md) 中明确标注的 open TODO），且**每一个**中间 `tool-result` 都会在下一次请求时重新进入模型上下文。

对于多步工具操作，这种方式 token 开销大且串行。模型无法组合工具——遍历结果集、根据中间值分支、扇出、后处理——每次调用都需要一次完整的模型往返，而每次往返都会把完整的中间结果拖回上下文，不管模型是否需要。

Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一种替代方案，基于一个简单的观察：LLM（大语言模型）编写代码的能力优于发出工具调用，因为它们见过数百万行真实代码，而人为构造的工具调用 trace 相对很少。模型不再每步发出一次工具调用，而是针对工具生成的 API 编写一段 TypeScript 程序，程序在沙箱运行时中执行，模型只策展返回的内容——仅限它 print 或 return 的部分——而非所有中间结果。

工具呈现属于掌管工具可见性的注册表：如果把第二种呈现方式实现为事后的 waterfall（瀑布式事件）变换，正确性将依赖监听器顺序，并与[可重建请求](../architecture/2026-07-05-reconstructable-requests.md)冲突。执行基底同样属于基础设施而非占位实现：Node `worker_threads` 提供独立 isolate、空环境、堆上限以及对热同步循环的终止能力，同时契合 harness 既有的信任模型（§信任姿态）。

## 决策

三项决策，各自在下方独立小节中展开：

1. **Code Mode 是 `ToolRegistry`（`dsh-tools`）的一等呈现模式**，通过经校验的 `mode` 配置选择：`'native'`（默认，贡献可见能力 schema）、`'code'`（注册表仅贡献其保留的 `run_code` 传输通道加一份生成的 SDK `.d.ts` 到系统提示词中）或 `'both'`（原生 schema 加传输通道 + SDK）。注册表在源头塑造其权威贡献；协作式提示词组装的结果仍具权威性，记录在日志中的请求头精确反映该返回的呈现。
2. **代码执行是一个能力 seam**——`packages/code-runtime/` 包含接口包 `@deepseek-ai/dsh-code-runtime`，拥有 `ctx.codeRuntime`（[能力 seam](../architecture/2026-06-13-capability-seams.md)；消费方 = `dsh-tools`，core 消费 seam 的先例见 `agent-loop` → `dsh-llm`）。运行时对工具一无所知：它接收一段程序和命名的异步绑定，执行程序，报告 `{ value, logs, error? }`。语言和基底是后端属性，因此未来的 Python 或容器后端只是另一个实现包，而非重新设计。
3. **交付的实现是 `@deepseek-ai/dsh-code-runtime-worker`**：每次运行 spawn 一个全新的 Node worker 线程，对模型的 TypeScript 进行 type-strip 后执行，绑定通过消息端口桥接，环境为空，堆/输出/时间上限可配置，并支持硬终止。其信任姿态在设计上等同于 bash——无需 unsafe-acknowledgement flag——因为 harness 已经交付了 `dsh-bash-local`，后者以严格*更高*的环境权限执行模型编写的任意 shell 命令。

本说明负责定义 Code Mode 的呈现、组合、隔离与结算基础。后续的[类型化工具返回值 Agent Note](2026-07-20-code-mode-typed-tool-returns.md)负责定义生成的输出映射、规范绑定值、`ToolCallError` 和无损外层输出边界。

### 注册表拥有模式

`ToolRegistry` 获得一个经 schemastery 校验的配置（`static Config`），这是它的第一个配置：`mode: 'native' | 'code' | 'both'`，默认 `'native'`。部署通过 `cordis.yml` 翻转模式（`tools: { mode: code }`），无需改代码，遵循 no-hardcoded-tunables 约定。

**协议工具列表。** 注册表在 `'native'` 下贡献可见能力，在 `'code'` 下仅贡献 `run_code`，在 `'both'` 下两者都贡献。最终的 `PromptAssembly.tools` 列表记录在请求头中。`run_code` 是一个保留的呈现传输通道，位于注册和限制层之外；直接提示词提供方和组装 waterfall 仍各自负责自己的贡献。

**与 `toolOrder` 的交互，预先说明：** 如果配置的 `systemPrompt.toolOrder` 引用了原生能力名称，在 `mode: 'code'` 下会拒绝所有组装，因为那些名称不在该模式的协议校验范围内。这是正确行为而非 bug：使用 Code Mode 的部署需要更新其 order 配置或移除它。

**SDK 提示词段。** 在 `'code'` 和 `'both'` 下，tool-guidance order band 中的惰性 `tools:sdk` 段为当前 scope 的可见能力渲染 TypeScript 声明加固定的使用说明。它共享查找和执行可见性，排除 `run_code`，并按字典序排列工具以获得字节稳定的输出。

**组装所有权。** `run_code` 和 `tools:sdk` 作为正常的组装输入进入受信任的 `system-prompt/assemble` waterfall。一个 scoped 的 `tools:sdk` 段可以在分发前遮蔽全局默认值，监听器也可以移除或替换任一贡献。waterfall 返回的组装结果是最终的，因此修改这些输入的人有责任在部署期望 Code Mode 可用时保持协议面的完整性；没有恢复 pass 会覆盖有意的组合。

**代码生成。** `jsonSchemaToTs()` 将 `defineTool` 的 JSON Schema 子集映射为 TypeScript，将 schema 描述带入 JSDoc，不支持的构造降级为 `unknown`。SDK 将工具暴露为带引号的对象键，支持任意名称而无需别名或冲突处理。类型是建议性的，因为运行时在执行前会剥离类型。

### run_code 工具与分发桥

在 `'code'` 和 `'both'` 下，注册表拥有 `run_code` 作为保留的呈现传输通道，带两个必需参数 `{ code: string; description: string }`（description 为 UI 标注该调用，沿用 bash 的先例）。它由一个正常的 `ToolDefinition` 表示以供分发，但位于可过滤的能力层之外，因此限制规则不会意外移除 Code Mode 的唯一入口。调用遍历完整的工具流水线——`tools/pre-execute` → 单调守卫 → `tools/execute` 包裹分发 → `tools/post-execute` → 由定义拥有的可选 `finalizeContent` → 不可变的 `tools/result` 通知——与原生调用完全一致；权限插件可以在程序运行前检查程序文本，最终结果观察者看到的是规范化的外层结果。其 `execute(args, exec)`：

1. **构建绑定。** 一个 run 级别的 signal 跟随外层取消，并在 run 结算时被 abort。每个可见工具绑定都会对无损 JSON 参数创建快照，进入原生契约的分发池（调度设计由[实时并行 Agent Note](2026-07-26-code-mode-live-parallel-dispatch.md) 负责），以确定性的 call id 和外层 token 作为 `parent` 执行，通过外层 execution 延后返回的上下文，并记录 `tool/code-dispatch-start`/`tool/code-dispatch` 事件对，其中结算侧携带完整渲染后的结果内容。成功时返回工具最终的规范 JSON 值；失败则变为程序可见的 `ToolCallError`。每个子调用保留自己不可变的执行标识，并遍历完整的工具流水线。
2. **运行程序**：`ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`。运行时接收的是 run 级别的 signal 而非仅调用方的外层 signal，因此外层 run 以任何方式结算都会同时 abort 运行时内部的工作。
3. **完全停稳后结算。** 运行时结算后，桥 abort 未完成的工作并排空分发队列后再返回。成功时返回捕获的日志和完成值，将其作为规范输出；注册表再把该值渲染为持久化的 `tool/result.content`，供结果卡片直接读取。运行时失败变为 `CodeRunFailedError`；后端拒绝使用注册表的正常错误边界。两者都产生结构化的错误结果，且 `run_code` 结算后不允许子调用追加。

**子调用上下文通过父调用延后。** 在 `run_code` 内部注入会破坏父调用/结果的相邻性，因此 `ToolRunContext.deferContext()` 按分发顺序收集每个子结果的 `additionalContexts` 条目。即使程序后来抛出异常，注册表仍携带该数组；循环只在外层结果与步骤中所有兄弟结果之后追加每个条目。外层 post-execute 阻止会丢弃工具延后的条目，只暴露阻止 decision 显式附加的上下文。

**并发被序列化。** 每次 run 拥有一个分发队列，因此即使 `Promise.all` 也按提交顺序执行工具调用。结算时放弃尚未开始的排队调用。并行化需要每个工具的并发安全元数据。

**呈现。** `run_code` 的 render intent 按[呈现意图 Agent Note](../architecture/2026-07-02-tool-render-intent-union.md)在此决定：`presentCall` 创建一个 `generic` 卡片，`kind: 'execute'`，以程序文本作为标题，并将同一程序文本作为 `rawInput`；`run_code` 有意不声明 `presentResult`，因此 TUI 和宿主/客户端运行时（Web）会通过通用原始内容回退机制，使用最终持久化的 `tool/result.content` 补全该卡片，其中包括捕获的日志，以及返回值、失败信息或 post-policy 输出落盘预览。这不是 `terminal` 卡片：该卡片的语义是「工作目录中的 shell 命令」，程序不是。参见[结果卡片完整性说明](../../archived/bug-fix/2026-07-20-code-mode-result-card-completeness.md)。

### 可观测性：`tool/code-dispatch`

每次子分发在进入分发池时追加一个仅日志的 `tool/code-dispatch-start` 事件，并以一个 `tool/code-dispatch` 结算事件收尾，后者包含父子 call id、工具标识、规范化参数以及完整渲染后的 `content`/`isError` 结果。它不进入模型历史，但可供持久化和 UI 使用。追加发生在开放的 `run_code` 轮次内。没有 agent 的直接执行仍然运行，但无法记录该事件。

### code-runtime seam

`packages/code-runtime/code-runtime/`——`@deepseek-ai/dsh-code-runtime`，仅依赖 `cordis`。一个抽象的 `CodeRuntime extends Service`（`super(ctx, 'codeRuntime')`）加上词汇：

- `CodeRunRequest = { program: string; bindings: CodeBindingNamespace[]; signal?: AbortSignal }`
- `CodeBindingNamespace = { global: string; functions: Record<string, (args: unknown) => Promise<CodeJsonValue>>; errorClass?: { name: string; memberNameProperty: string } }`——运行时将每个命名空间作为程序内部的全局异步函数对象暴露；可选描述符要求运行时注入真正的、程序可见的 reject 类，而无需让 seam 获知消费方专用名称。`CodeJsonValue` 是这个低依赖 seam 的结构化无损 JSON 类型，因此绑定参数与解析值可以完整跨越实现的序列化边界。
- `CodeRunResult = { value?: CodeJsonValue; logs: string[]; error?: CodeRunFailure }`——程序执行失败时，执行 promise 仍会 fulfill，并通过 `error` 字段返回失败结果。只有调用方/seam 误用（例如重复的绑定命名空间）时，`run()` 才会 reject；消费方仍在自己的错误边界处理不合规后端的拒绝。
- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit'; message: string }`——按[防御性模式](../../../../docs/defensive-patterns.md)独立报告的正交结果；超时的 run 不是异常，abort 不是超时，有损完成值不是溢出，基底退出也与上述情况相互独立。
- 两个只读的后端描述符，仅供信息参考而非门禁判定：`language`（程序必须使用的语言——交付的后端为 `'typescript'`；Python 后端会声明自己，并在呈现侧配对自己的 SDK 生成器）和 `isolation`（交付的后端为 `'worker-thread'`；未来可为 `'process'`、`'container'` 等）。`dsh-tools` 在 MVP 中要求 `language === 'typescript'`——其代码生成输出 TS——否则组装会大声失败，与 `toolOrder` 违规时的配置错误惯用法相同（如 `mode` 为非 native 但根本没有加载 `ctx.codeRuntime`）。

请求包含所有运行时输入；实现方拥有经校验的超时和上限默认值。注册表仅在组装 Code Mode 时查找可选的运行时，因此 native 模式不依赖它。缺失或语言不兼容的运行时会大声失败。替代基底或语言可以在同一 seam 背后替换实现，配对相应的 SDK 生成器。

### worker-thread 运行时

`@deepseek-ai/dsh-code-runtime-worker`，`packages/code-runtime/` 组的第二个包（package）。每次 `run()`：

1. **宿主侧 type-strip**，使用 Node 内置的 `stripTypeScriptTypes`（`node:module`；在本仓库的整个引擎范围 `^22.19.0 || >=24.0.0` 内可用，且保持位置不变，因此运行时错误行号与模型源码一致）。仅剥离模式拒绝不可擦除的语法（`enum`、namespaces）——该拒绝以 `error.kind: 'exception'` 加 Node 的消息返回，SDK 说明写明「仅限可擦除 TypeScript」，模型像处理其他程序错误一样自我修正。语法级失败不会 spawn worker。
2. **每次 run spawn 一个全新 `Worker`**，来自包自身的 bootstrap 模块：`env: {}`（真正为空——比 spawn 命令的 scrubbed-env 规则更严格），`resourceLimits` 来自配置，`stdout`/`stderr` 捕获到 `logs` 而非继承。不做池化，不跨 run 保留状态：程序的世界随 worker 消亡，这使得 run 仅从日志即可重建，状态泄漏不可表达。
3. **在 bootstrap 中执行**：剥离后的程序成为一个 `AsyncFunction` 的函数体，其参数是绑定全局变量、消费方声明的 reject 类和一个捕获式 `console` shim，因此顶层 `await` 和 `return` 可用。Code Mode 声明 `ToolCallError`，成员属性为 `toolName`；运行时无需硬编码工具即可实体化真正的构造函数。无损 JSON 完成值会精确跨越边界；`undefined` 仍表示缺席，有损值产生 `invalid-output`，过大的外层结果产生 `output-limit`，而不会退化为检查格式化后的字符串替代品。
4. **通过消息端口桥接绑定**：worker 中的每个绑定函数发送 `{ id, global, name, args }` 并等待回复；宿主根据请求的绑定校验名称、调用、并回复 `{ id, ok, value }` 或 `{ id, ok: false, message }`（宿主侧绑定拒绝变为程序侧 rejection）。worker 侧的命名空间对象通过 `defineProperty` 构建为 null-prototype，因此名为 `__proto__`、`constructor` 或 `toString` 的绑定是普通自有属性，而非原型链碰撞。未知名称、重复 id 和结算后消息被拒绝或忽略——端口协议假设对端是恶意的，因为对端运行的是模型代码。
5. **强制独立预算。** `computeMs` 计量 worker 忙碌时间，允许慢速的 awaited 工具而不放过热循环。`maxWallMs` 约束总经过时间，包括未解析的等待。`maxOutputBytes` 只约束序列化后的外层日志、完成值或诊断的组合；中间绑定值没有字节数上限。到期、取消和完成都终止 worker，堆退出或外层溢出会作为显式失败报告。
6. **dispose 至完全停稳**：服务自身的 dispose（资源释放）终止进行中的 worker 并*等待*其退出后再 resolve，遵循[防御性模式](../../../../docs/defensive-patterns.md)。

### 信任姿态

worker 运行时提供的是隔离，而非安全边界：模型代码可以访问 Node API，权限与 bash 工具相当。`worker.terminate()` 停止线程但不停止它 spawn 的 OS 进程。Code Mode 使用与 bash 相同的 `tools/pre-execute` 策略门禁，并额外提供空环境、堆限制、独立 isolate 和对程序本身的硬终止。需要硬多租户边界的部署需要为代码和 bash 都使用容器级后端；运行时的 isolation 描述符让它们能区分该后端。

### 模型看到的内容

SDK 指示模型编写一个异步的可擦除 TypeScript 函数体，通过 `await tools.name(args)` 调用工具，在需要时捕获被拒绝的工具调用，并仅 return 或 log 应重新进入上下文的输出。即使在 `Promise.all` 下调用仍保持顺序。声明前缀可能与原生 schema 一样大，尤其在 `'both'` 下，但对提供方缓存保持稳定。

## 后果

切换到 `'code'` 的部署必须更新任何仅限 native 的 `toolOrder`。组装监听器有责任维护任何被重写的协议面的完整性。子分发保持序列化，而每次调用的上下文会通过外层结果保留其 source、信封与元数据。

## 测试

- **Worker 运行时：** 真实 worker 测试覆盖类型化的绑定值与失败、每一种无损 JSON 根类型的完成值、无效和超限输出、精确的组合账本边界、compute 和 wall 预算、恶意绑定流量、空环境以及 dispose 至完全停稳。一个构建后包测试在纯 Node 下运行 worker 入口。
- **注册表集成：** 测试覆盖代码生成、所有呈现模式、保留名称和限制规则、scoped 可见性、权威组装重写、`toolOrder`、运行时兼容性失败、完整流水线子分发、parent-token 关联、序列化、取消和队列排空、JSON 规范化、错误传播、日志事件、成功与失败程序中的有序上下文延后、外层阻止抑制以及 HMR（热模块替换）清理。
- **带密钥 e2e：** 真实模型在一个程序中组合两次 bash 调用；另一个模型通过 Code Mode fs 分发发现嵌套的工作区指令。测试验证折叠的请求头、关联的分发事件、结果文件、延后上下文和模型行为。
- **快照：** `code-mode-turn`、`both-mode-turn` 和 `code-mode-workspace-context` fixture（测试前置数据）固定 SDK 文本、请求头工具列表、分发事件、延后上下文和结果卡片。

## 曾考虑的替代方案

**一个零核心改动的附加消费方插件。** 否决，因为 `agent/request` 在[可重建请求](../architecture/2026-07-05-reconstructable-requests.md)下仅限 call-config，而变换已组装的工具列表需要在不拥有其配置的情况下撤销 `toolOrder` 规范化，并依赖监听器顺序。向模型提供哪些工具、以何种表示形式提供，是注册表的单一关注点：原生 schema 和 SDK 是同一个可见存储的两种投影。

**`node:vm` 作为参考运行时，加固推迟。** 否决：`node:vm` 不是隔离（原型链逃逸可达宿主 realm）且无法中断热循环。worker 线程提供独立 isolate、空环境、`resourceLimits` 和可靠的 `terminate()`，信任等级等同于 bash，因此参考实现和生产实现是同一个包，无需 unsafe-acknowledgement 仪式。

**在原生工具调用上做结果省略/摘要。** 仅解决问题的上下文膨胀一半：裁剪旧 `tool-result` 作为可重建请求下的日志化表面替换成本低，但仍需每次调用一次模型往返，且无法表达循环、分支或汇合。互补而非竞争；它可以在 Code Mode 下为残余的原生调用分层。

**循环中的并行原生分发。** 往返成本的另一个答案；仍是有效的未来工作（open TODO），仍被并发安全元数据阻塞，且仍无组合能力——它并行化的是模型在一步中已经决定的调用。Code Mode 的序列化队列决策保持两者兼容：当元数据就绪时，原生并行分发和每工具绑定并行化一起解锁。

**始终排他（忠于 Cloudflare，无模式）。** 否决，因为本 SDK 的主要消费方是编码 agent：其日常的单次调用（`bash`、`read`、`edit`）作为原生调用已经是最优的，强制每次编辑都通过程序会给常见场景增加负担。mode 配置让忠实形式（`'code'`）只需一行配置即可启用，而不强加于人。

**每工具可见性分层（此工具 native，彼工具 code-only）。** 推迟：它需要每工具元数据和 `'native' | 'code' | 'both'` 不提供的呈现拆分，且其设计取决于模型在 `'both'` 下如何分配使用的证据。

**SDK 中的清洁化标识符别名**（`my-tool` → `my_tool`，Cloudflare 的做法）。否决：`declare const` 上的带引号键使每个名称可达，零别名碰撞逻辑；模型能正常处理 `tools["my-tool"](…)`。

**REPL 风格的持久内核**（状态跨 `run_code` 调用存活）。在 MVP 中否决：跨调用状态对会话日志不可见，破坏了「每个请求是日志的纯函数」这一可重建性保证；每次 run 全新保持了这一点。内核风格的后端在未来仍可通过同一 seam 表达，配合自己的日志方案。

## 风险

**Worker 不是硬安全边界。** 有意为之且已文档化（§信任姿态）：姿态等同于既有的 bash 工具，隔离程度超过它，门禁使用相同的 seam。需要更强隔离的部署需要未来的 `isolation: 'container'` 后端——作为 seam 设计的扩展点跟踪，而非本设计的 TODO。

**`stripTypeScriptTypes` 标记为 experimental。** 它与 Node 自身原生 `.ts` 执行背后的引擎（amaro/swc）相同，在本仓库的整个引擎范围内作为 API 暴露。缓解措施：运行时的单元测试套件固定了所依赖的行为（位置保持、可擦除限制的拒绝消息形状宽松匹配），调用位于一个私有函数之后，且 `amaro`/`sucrase` 是 API 变化时的直接替代品。仅可擦除子集是面向模型的契约线，错误路径是一个可工作的反馈循环，而非死胡同。

**SDK 的提示词成本，尤其在 `'both'` 下。** `.d.ts` 可能与它补充的原生 schema 体量相当；`'both'` 携带两种表示。前缀稳定性 + 提供方缓存摊销了每会话成本；mode 是每部署的；本 Agent Note 不做无条件节省的声明。何时优先使用哪种模式的量化指导明确属于上线后学习。

**注册表 scope 增长。** `dsh-tools` 吸收了代码生成、一个工具、一个桥和一个事件。通过包内的模块边界（`ts-types.ts`、`code-mode.ts` 与 `schema.ts`/`json-schema.ts`/`presentation.ts` 并列）和 seam 约束：所有基底相关的内容都在 `ctx.codeRuntime` 之后。

**大型无损 JSON 值可能耗尽内存。** 工具绑定会在分发前对无损 JSON 创建快照，并完整返回规范 JSON 解析值。运行时会校验 worker 端口两侧，但不对单次绑定设置字节数上限；结构化克隆成本以及进程或 worker 内存构成实际边界。只有包含日志、完成值和失败诊断的组合外层输出账本受字节数上限约束。

**仅序列化的子分发。** `Promise.all` 尚未获得挂钟并行性，仅减少往返次数；模型可能过度期望。说明中已声明；解除此限制与原生并行分发 TODO 所需的并发安全元数据绑定。

**预算计量读取事件循环，而非 flag。** 忙碌时间轮询（`eventLoopUtilization()`）比精确 CPU 计量更粗糙——预算到期最多延迟一个轮询间隔——且其正确性声明（「pending 的分发不能暂停它」）对恶意程序是承重的。两侧都有单元测试（带 pending 诱饵分发的热循环在 `computeMs` 处死亡；在慢绑定上空闲的程序存活到 `maxWallMs`），轮询间隔是内部常量而非配置——部署无法将其误调为绕过手段。`maxWallMs` 是配置项，且会传入 `setTimeout`，后者会把超过 `MAX_TIMER_DELAY_MS`（2^31-1 ms）的延迟夹到 1 ms；因此仅有正数校验会放行一个 25 天的上限，它在第一个 tick 就到期，使每次运行都超时。worker 运行时正因如此在加载时对该字段做范围校验。`computeMs` 不需要上界，因为它对照的是实测占用率，而不是交给定时器。
