docs(i18n): RFC tree batch — 146 bilingual pairs via the committed pipeline
implemented(除 4 篇超长文档随后补)、proposed、rejected 全树配对; 同一流水线 + 二遍校验(paraphrase-back + 仓库上下文一致性)产出。 docs/rfc/implemented/AGENTS.md 与其 CLAUDE.md 符号链接列入排除 (agent 指令文件,与根 AGENTS.md 同策略)。
This commit is contained in:
132
docs/rfc/implemented/feature/2026-06-15-code-mode.zh.md
Normal file
132
docs/rfc/implemented/feature/2026-06-15-code-mode.zh.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# RFC: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](../../../architecture.md) 中明确标注的 open TODO),且**每个**中间 `tool-result` 都在下一次请求时重新进入模型上下文。
|
||||
|
||||
对于多步工具操作,这种方式 token 开销大且串行。模型无法组合工具——遍历结果集、根据中间值分支、扇出、后处理——每次调用都需要一次完整的模型往返,而每次往返都把整个中间结果拖回上下文,无论模型是否需要。
|
||||
|
||||
Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一种替代方案,基于一个简单观察:LLM(大语言模型)写代码比发出工具调用更擅长,因为它们见过数百万行真实代码,而见过的人造工具调用 trace 相对很少。模型不再每步发出一个工具调用,而是针对工具生成的 API 编写一段 TypeScript 程序,程序在沙箱运行时中执行,模型只取回它打印或返回的内容——而非所有中间结果。
|
||||
|
||||
工具呈现属于拥有工具可见性的注册表:如果把第二种呈现方式实现为事后的 waterfall(瀑布式事件)变换,正确性将依赖监听器顺序,并与[可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)冲突。执行基底同样属于基础设施而非占位符:Node `worker_threads` 提供独立隔离区、空环境、堆上限以及对热同步循环的终止能力,同时契合 harness 现有的信任模型(见§信任姿态)。
|
||||
|
||||
## 决策
|
||||
|
||||
三项决策,各自在下方独立小节中展开:
|
||||
|
||||
1. **Code Mode 是 `ToolRegistry`(`dsh-tools`)的一等呈现模式**,通过经过校验的 `mode` 配置选择:`'native'`(默认,贡献可见能力 schema)、`'code'`(注册表仅贡献其保留的 `run_code` 传输通道加一份生成的 SDK `.d.ts` 放入系统提示词)、或 `'both'`(原生 schema 加传输通道 + SDK)。注册表在源头塑造其规范贡献;协作式 prompt 组装的结果仍具权威性,请求头日志记录的正是该返回的呈现。
|
||||
2. **代码执行是一个能力 seam**——`packages/code-runtime/` 包含接口包 `@deepseek-ai/dsh-code-runtime`,拥有 `ctx.codeRuntime`([能力 seam](../../implemented/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`**:每次运行启动一个全新的 Node worker 线程,对模型的 TypeScript 进行 type-strip 后执行,绑定通过消息端口桥接,环境为空,堆/输出/时间上限可配置,并支持硬终止。其信任姿态在设计上等同于 bash——不需要 unsafe-acknowledgement 标志——因为 harness 已经提供了 `dsh-bash-local`,后者以严格**更大**的环境权限执行模型编写的任意 shell 命令。
|
||||
|
||||
### 注册表拥有模式
|
||||
|
||||
`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` 是一个保留的呈现传输通道,位于注册和限制层之外;直接 prompt 提供方和组装 waterfall 仍各自负责自己的贡献。
|
||||
|
||||
**与 `toolOrder` 的交互,预先声明:** 如果配置的 `systemPrompt.toolOrder` 命名了原生能力,则在 `mode: 'code'` 下会拒绝所有组装,因为这些名称不在该模式的协议校验范围内。这是正确行为,不是 bug:使用 Code Mode 的部署需要更新其 order 配置或移除它。
|
||||
|
||||
**SDK prompt 段落。** 在 `'code'` 和 `'both'` 下,tool-guidance order band 中的惰性 `tools:sdk` 段落为作用域内可见能力渲染 TypeScript 声明加固定的使用说明。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。
|
||||
|
||||
**组装所有权。** `run_code` 和 `tools:sdk` 作为正常的组装输入进入受信任的 `system-prompt/assemble` waterfall。作用域内的 `tools:sdk` 段落可以在分发前遮蔽全局默认值,监听器可以移除或替换任一贡献。waterfall 返回的组装结果是最终的,因此修改这些输入的人有责任在部署期望 Code Mode 可用时保持协议可行;没有恢复 pass 会覆盖有意的组合。
|
||||
|
||||
**代码生成。** `jsonSchemaToTs()` 将 `defineTool` 的 JSON Schema 子集映射为 TypeScript,将 schema 描述带入 JSDoc,并将不支持的构造降级为 `unknown`。SDK 以带引号的对象键暴露工具,支持任意名称而无需别名或冲突处理。类型是建议性的,因为运行时在执行前会剥离类型。
|
||||
|
||||
### run_code 工具与分发桥接
|
||||
|
||||
在 `'code'` 和 `'both'` 下,注册表拥有 `run_code` 作为保留的呈现传输通道,带一个必需参数 `{ code: string }`。它由一个正常的 `ToolDefinition` 表示以便分发,但位于可过滤的能力层之外,因此限制规则不会意外移除 Code Mode 的唯一入口。调用遍历完整的工具流水线——`tools/pre-execute` → 单调守卫 → `tools/execute` 包裹分发 → `tools/post-execute` → 不可变的 `tools/result` 通知——与原生调用完全一致;权限插件可以在程序运行前检查程序文本,最终结果观察者看到的是归一化的外层结果。其 `execute(args, exec)`:
|
||||
|
||||
1. **构建绑定。** 一个 run 作用域的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定对其参数做 JSON 归一化——在分发前拒绝有损值——等待序列化队列,以确定性的 call id 和外层 token 作为 `parent` 执行,并记录 `tool/code-dispatch`。成功的文本变为字符串,非文本块变为占位符;工具错误使绑定 promise reject。每个子调用保留自己的不可变执行身份,并遍历完整的工具流水线。
|
||||
2. **运行程序**:`ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`。运行时接收的是 run 作用域的 signal 而非仅调用方的外层 signal,因此外层 run 以任何方式结算都会同时 abort 运行时内部的工作。
|
||||
3. **静默后结算。** 运行时结算后,桥接 abort 未完成的工作并排空分发队列后再返回。成功时返回捕获的输出和呈现元数据。运行时失败变为 `CodeRunFailedError`;后端拒绝使用注册表的正常错误边界。两者都产生结构化的错误结果,且 `run_code` 结算后没有子调用可以追加。
|
||||
|
||||
**子调用的 `additionalContext` 被省略。** 在 `run_code` 期间注入它会破坏父调用/结果的邻接性,而一个程序可以产生多个上下文。支持它需要一个复数通道或循环级别的子分发缓冲区。
|
||||
|
||||
**并发被序列化。** 每次 run 拥有一个分发队列,因此即使 `Promise.all` 也按提交顺序执行工具调用。结算时放弃尚未开始的排队调用。并行化需要逐工具的并发安全元数据。
|
||||
|
||||
**呈现。** `run_code` 的渲染意图按 [render-intent RFC](../../implemented/architecture/2026-07-02-tool-render-intent-union.md) 在此决定:`presentCall` → 一个 `generic` 卡片,`kind: 'execute'`,title = 程序文本,`rawInput` = 同一段程序文本;`presentResult` → 一个 `generic` 卡片,内容为捕获的输出(来自 `meta`)。程序作为 title 是因为 ACP execute 卡片可靠地渲染该字段,而某些客户端会省略 body 和 raw-input 内容。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。
|
||||
|
||||
### 可观测性:`tool/code-dispatch`
|
||||
|
||||
每次子分发追加一个仅日志的 `tool/code-dispatch` 事件,包含父子 call id、工具身份、归一化参数和结果摘要。它不进入模型历史,但可供持久化和 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<unknown>> }`——运行时将每个命名空间作为程序内部的全局异步函数对象暴露;绑定参数和解析值必须是 structured-cloneable 的(运行时可能跨越序列化边界;我们的实现确实如此)。
|
||||
- `CodeRunResult = { value?: unknown; logs: CodeLogEntry[]; error?: CodeRunFailure }`——程序执行结果,包括异常、超时、abort 和 worker 退出,以 `error` 字段解析。`run()` 仅在调用方/seam 误用时才 reject(例如重复的绑定命名空间);消费方仍在自己的错误边界处理不合规的后端 rejection。
|
||||
- `CodeLogEntry = { source: 'console' | 'stdout' | 'stderr'; level?: 'log' | 'info' | 'warn' | 'error' | 'debug'; text: string }`
|
||||
- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }`——正交的结果按[防御性模式](../../../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 时查找可选的运行时,因此原生模式不依赖它。缺失或语言不兼容的运行时会大声失败。替代基底或语言可以在同一 seam 后面替换实现,配对相应的 SDK 生成器。
|
||||
|
||||
### worker 线程运行时
|
||||
|
||||
`@deepseek-ai/dsh-code-runtime-worker`,`packages/code-runtime/` 组的第二个包。每次 `run()`:
|
||||
|
||||
1. **宿主侧 type-strip**,使用 Node 内置的 `stripTypeScriptTypes`(`node:module`;在本仓库的整个引擎范围 `^22.19.0 || >=24.0.0` 内可用,且保持位置不变,因此运行时错误行号与模型源码一致)。Strip-only 模式拒绝不可擦除的语法(`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` 的函数体,其参数是绑定全局变量和一个捕获式 `console` shim,因此顶层 `await` 和 `return` 可用,程序的完成值即为 run 的 `value`(structured-cloneable 值原样跨越;其他值被替换为其 `util.inspect` 渲染,已文档化)。
|
||||
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` 限制总经过时间,包括未完成的等待。到期、取消和完成都会终止 worker。堆退出和截断被显式报告;compute、wall、heap、log 和返回值上限都是经过校验的配置。
|
||||
6. **Dispose 至静默**:服务自身的 disposal 终止进行中的 worker 并*等待*它们退出后再 resolve,遵循[防御性模式](../../../defensive-patterns.md)。
|
||||
|
||||
### 信任姿态
|
||||
|
||||
worker 运行时提供的是封闭隔离,而非安全边界:模型代码可以触及 Node API,权限与 bash 工具相当。`worker.terminate()` 停止线程但不停止它 spawn 的 OS 进程。Code Mode 使用与 bash 相同的 `tools/pre-execute` 策略门控,并额外提供空环境、堆限制、独立隔离区和对程序本身的硬终止。需要硬多租户边界的部署需要为代码和 bash 都使用容器级后端;运行时的 isolation 描述符让它们能区分该后端。
|
||||
|
||||
### 模型看到的内容
|
||||
|
||||
SDK 指示模型编写一个异步的可擦除 TypeScript 函数体,通过 `await tools.name(args)` 调用工具,在需要时 catch 被 reject 的工具调用,并仅返回或打印应重新进入上下文的输出。即使在 `Promise.all` 下调用仍保持顺序。声明前缀可以与原生 schema 一样大,尤其在 `'both'` 下,但对提供方缓存保持稳定。
|
||||
|
||||
## 后果
|
||||
|
||||
切换到 `'code'` 的部署必须更新任何仅原生的 `toolOrder`。组装监听器负责维护任何被重写的协议表面的完整性。子分发保持序列化,桥接不会传播逐调用的 `additionalContext`,直到为 Code Mode 设计好这些契约。
|
||||
|
||||
## 测试
|
||||
|
||||
- **Worker 运行时:** 真实 worker 测试覆盖输出和值捕获、失败类型、compute 和 wall 预算、敌对绑定流量、空环境、structured-clone 回退、输出上限和 disposal 至静默。一个 built-package 测试在纯 Node 下运行 worker 入口。
|
||||
- **注册表集成:** 测试覆盖代码生成、所有呈现模式、保留名称和限制规则、作用域可见性、权威组装重写、`toolOrder`、运行时兼容性失败、完整流水线子分发、parent-token 关联、序列化、取消和队列排空、JSON 归一化、错误传播、日志事件、省略的 `additionalContext` 和 HMR(热模块替换)清理。
|
||||
- **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;测试验证折叠的请求头、关联的分发事件、生成的文件和精选的回答。
|
||||
- **快照:** `code-mode-turn` 和 `both-mode-turn` fixture(测试前置数据)固定 SDK 段落、头部工具列表、分发事件和结果卡片。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**一个零核心改动的附加消费方插件。** 否决,因为 `agent/request` 在[可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)下仅限 call-config,而变换已组装的工具列表需要在不拥有其配置的情况下撤销 `toolOrder` 规范化,且依赖监听器顺序。模型被提供哪些工具、以何种表示,是注册表的单一关注点:原生 schema 和 SDK 是同一可见存储的两种投影。
|
||||
|
||||
**`node:vm` 作为参考运行时,加固推迟。** 否决:`node:vm` 不是隔离(原型链逃逸可达宿主 realm)且无法中断热循环。worker 线程提供独立隔离区、空环境、`resourceLimits` 和可靠的 `terminate()`,信任等级等同于 bash,因此参考实现和生产实现是同一个包,无需 unsafe-acknowledgement 仪式。
|
||||
|
||||
**对原生工具调用做结果省略/摘要。** 仅解决问题的上下文膨胀一半:裁剪旧 `tool-result` 作为可重建请求下的日志表面替换很容易添加,但仍然每次调用付出一次模型往返,且无法表达循环、分支或汇合。互补而非竞争;它可以在 Code Mode 下为残余的原生调用分层。
|
||||
|
||||
**循环中的并行原生分发。** 往返开销的另一个答案;仍是有效的未来工作(open TODO),仍被并发安全元数据阻塞,且仍无组合能力——它并行化的是模型在一步中已经决定的调用。Code Mode 的序列化队列决策使两者兼容:当元数据就绪时,原生并行分发和逐工具绑定并行化一起解锁。
|
||||
|
||||
**始终排他(忠实于 Cloudflare,无模式)。** 否决,因为本 SDK 的主要消费方是编码 agent:其日常的单次调用(`bash`、`read`、`edit`)作为原生调用已经是理想的,强迫每次编辑都通过程序会加重常见场景的负担。mode 配置让忠实形式(`'code'`)只需一行配置即可启用而不强加。
|
||||
|
||||
**逐工具可见性层级(此工具原生,彼工具仅 code)。** 推迟:它需要逐工具元数据和 `'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 的 prompt 开销,尤其在 `'both'` 下。** `.d.ts` 可以与它补充的原生 schema 相当大;`'both'` 携带两种表示。前缀稳定性 + 提供方缓存摊销了每会话开销;mode 是逐部署的;本 RFC 不做无条件节省的声明。何时偏好哪种模式的量化指导明确是上线后的学习。
|
||||
|
||||
**注册表范围增长。** `dsh-tools` 吸收了代码生成、一个工具、一个桥接和一个事件。通过包内的模块边界(`ts-types.ts`、`code-mode.ts` 与 `schema.ts`/`json-schema.ts`/`presentation.ts` 并列)和 seam 来约束:所有基底形状的东西都在 `ctx.codeRuntime` 后面。
|
||||
|
||||
**Structured-clone 值可以超出 JSON。** 因此工具绑定在分发前对参数做 JSON 归一化,确保每个执行的调用都可以被记录。底层运行时保持其更宽的端口契约,而更严格的消费方在自己的边界处校验。非文本子结果变为占位符。
|
||||
|
||||
**仅序列化的子分发。** `Promise.all` 尚未获得挂钟并行性,仅减少了往返次数;模型可能过度期望。说明中已声明;解除此限制与原生并行分发 TODO 所需的相同并发安全元数据绑定。
|
||||
|
||||
**预算计量读取事件循环,而非标志。** 忙碌时间轮询(`eventLoopUtilization()`)比精确 CPU 计量更粗——预算到期最多延迟一个轮询间隔——且其正确性声明(「pending dispatch 无法暂停它」)对敌对程序是承重的。两侧都有单元测试(带 pending decoy dispatch 的热循环在 `computeMs` 时死亡;idle-on-slow-binding 存活到 `maxWallMs`),轮询间隔是内部常量而非配置——部署无法将其误调为绕过。
|
||||
Reference in New Issue
Block a user