26 KiB
Agent Note: Code Mode——模型针对工具注册表编写 TypeScript
Status: implemented
English | 中文
问题
在注册表的原生呈现方式下,agent loop(智能体循环)将每个可见能力以 JSON Schema 函数定义的形式通告给模型。ToolRuntime 将其 schema 贡献给系统提示词组装,组装结果中的 tools 落到协议格式(wire format)上(也记录在请求头日志中),模型每步调用一个 tool-call 块,而在本 note 写作时,循环通过 ctx.tools.execute() 逐个分发每次调用(并行工具执行当时还是 open TODO;此后有界的并行分发已经交付——见并行工具调用 note,以及 docs/architecture.md 中的 rolling pool)——且每一个中间 tool-result 都会在下一次请求时重新进入模型上下文。
对于多步工具操作,这种方式 token 开销大且串行。模型无法组合工具——遍历结果集、根据中间值分支、扇出、后处理——每次调用都需要一次完整的模型往返,而每次往返都会把完整的中间结果拖回上下文,不管模型是否需要。
Cloudflare 的 Code Mode 提出了一种替代方案,基于一个简单的观察:LLM(大语言模型)编写代码的能力优于发出工具调用,因为它们见过数百万行真实代码,而人为构造的工具调用 trace 相对很少。模型不再每步发出一次工具调用,而是针对工具生成的 API 编写一段 TypeScript 程序,程序在沙箱运行时中执行,模型只筛选返回的内容——仅限它 print 或 return 的部分——而非所有中间结果。
工具呈现属于掌管工具可见性的注册表:如果把第二种呈现方式实现为事后的 waterfall(瀑布式事件)变换,正确性将依赖监听器顺序,并与可重建请求冲突。执行基底同样属于基础设施而非占位实现:Node worker_threads 提供独立 isolate、空环境、堆上限以及对热同步循环的终止能力,同时契合 harness 既有的信任模型(§信任姿态)。
决策
三项决策,各自在下方独立小节中展开:
- Code Mode 是
ToolRuntime(dsh-tools)的一等呈现模式,通过经校验的mode配置选择:'native'(默认,贡献可见能力 schema)、'code'(注册表仅贡献其保留的run_code传输通道加一份生成的 SDK.d.ts到系统提示词中)或'both'(原生 schema 加传输通道 + SDK)。注册表在源头构建其规范贡献;协作式提示词组装的结果仍具权威性,记录在日志中的请求头精确反映该返回的呈现。 - 代码执行是一个能力 seam——
packages/code-runtime/包含 Service Definition 包@deepseek-ai/dsh-code-runtime,拥有ctx.codeRuntime(能力 seam;消费方 =dsh-tools,core 消费 seam 的先例见agent-loop→dsh-llm)。运行时对工具一无所知:它接收一段程序和命名的异步绑定,执行程序,报告{ value, logs, error? }。语言和基底是后端属性,因此未来的 Python 或容器后端只是另一个 Service Provider 包,而非重新设计。 - 交付的实现是
@deepseek-ai/dsh-code-runtime-worker-thread:每次运行 spawn 一个全新的 Node worker 线程,对模型的 TypeScript 进行 type-strip 后执行,绑定通过消息端口桥接,环境为空,堆/输出/时间上限可配置,并支持硬终止。其信任姿态在设计上等同于 bash——无需 unsafe-acknowledgement flag——因为 harness 已经交付了dsh-bash-local,后者以严格更高的环境权限执行模型编写的任意 shell 命令。
本说明负责定义 Code Mode 的呈现、组合、隔离与结算基础。后续的类型化工具返回值 Agent Note负责定义生成的输出映射、规范绑定值、ToolCallError 和无损外层输出边界。
注册表拥有模式
ToolRuntime 获得一个经 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;语言分发 note 加入了 Python 与按 ctx.codeRuntime.language 选择的渲染器表)。它共享查找和执行可见性,排除 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):
- 构建绑定。 一个 run 级别的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定都会对无损 JSON 参数创建快照,进入原生约定的分发池(调度设计由实时并行 Agent Note 负责),以确定性的 call id 和外层 token 作为
parent执行,通过外层 execution 延后返回的上下文,并记录tool/code-dispatch-start/tool/code-dispatch事件对,其中结算侧携带完整渲染后的结果内容。成功时返回工具最终的规范 JSON 值;失败则变为程序可见的ToolCallError。每个子调用保留自己不可变的执行标识,并遍历完整的工具流水线。 - 运行程序:
ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })。运行时接收的是 run 级别的 signal 而非仅调用方的外层 signal,因此外层 run 以任何方式结算都会同时 abort 运行时内部的工作。 - 完全停稳后结算。 运行时结算后,桥 abort 未完成的工作并排空分发队列后再返回。成功时返回捕获的日志和完成值,将其作为规范输出;注册表再把该值渲染为持久化的
tool/result.content,供结果卡片直接读取。运行时失败变为CodeRunFailedError;后端拒绝使用注册表的正常错误边界。两者都产生结构化的错误结果,且run_code结算后不允许子调用追加。
子调用上下文通过父调用延后。 在 run_code 内部注入会破坏父调用/结果的相邻性,因此 ToolRunContext.deferContext() 按分发顺序收集每个子结果的 additionalContexts 条目。即使程序后来抛出异常,注册表仍携带该数组;循环只在外层结果与步骤中所有兄弟结果之后追加每个条目。外层 post-execute 阻止会丢弃工具延后的条目,只暴露阻止 decision 显式附加的上下文。
并发是有界的,而非被序列化。 每次 run 拥有一个分发队列,严格按提交顺序启动调用,并通过 registry.executionMode 对每个调用分类——与原生循环所用的 fail-closed isConcurrencySafe 约定相同。连续的 parallel 类调用最多重叠 maxParallelSubCalls 个(默认 10;设为 1 恢复串行分发);exclusive 类调用会排空池并单独运行。结算时放弃尚未开始的排队调用。本 note 交付的是被序列化的占位实现;取代它的调度器由实时并行 Agent Note 负责。
呈现。 run_code 的 render intent 按呈现意图 Agent Note在此决定:presentCall 创建一个 generic 卡片,kind: 'execute',以程序文本作为标题,并将同一程序文本作为 rawInput;run_code 有意不声明 presentResult,因此 TUI 和宿主/客户端运行时(Web)会通过通用原始内容回退机制,使用最终持久化的 tool/result.content 补全该卡片,其中包括捕获的日志,以及返回值、失败信息或 post-policy spill 预览。这不是 terminal 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。参见结果卡片完整性说明。
可观测性: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 }——按防御性模式独立报告的正交结果;超时的 run 不是异常,abort 不是超时,有损完成值不是溢出,基底退出也与上述情况相互独立。- 两个只读的后端描述符,仅供信息参考而非门禁判定:
language(程序必须使用的语言——首个后端为'typescript';Python 后端声明'python',并在呈现侧配对自己的 SDK 生成器)和isolation(交付的后端为'worker-thread';未来可为'process'、'container'等)。dsh-tools接受任何注册了 SDK 渲染器与run_codeflavor 的language(TypeScript 与 Python 已交付;见语言分发 note),否则组装会显式失败,与toolOrder违规时的配置错误惯用法相同(如mode为非 native 但根本没有加载ctx.codeRuntime)。
请求包含所有运行时输入;实现方拥有经校验的超时和上限默认值。注册表仅在组装 Code Mode 时查找可选的运行时,因此 native 模式不依赖它。缺失或语言不兼容的运行时会显式失败。替代基底或语言可以在同一 seam 背后替换实现,配对相应的 SDK 生成器。
worker-thread 运行时
@deepseek-ai/dsh-code-runtime-worker-thread,packages/code-runtime/ 组的第二个包。每次 run():
- 宿主侧 type-strip,使用 Node 内置的
stripTypeScriptTypes(node:module;在本仓库的整个引擎范围^22.19.0 || >=24.0.0内可用,且会保留源码位置,因此运行时错误行号与模型源码一致)。仅剥离模式拒绝不可擦除的语法(enum、namespaces)——该拒绝以error.kind: 'exception'加 Node 的消息返回,SDK 说明写明「仅限可擦除 TypeScript」,模型像处理其他程序错误一样自我修正。语法级失败不会 spawn worker。 - 每次 run spawn 一个全新
Worker,来自包自身的 bootstrap 模块:env: {}(真正为空——比 spawn 命令的 scrubbed-env 规则更严格),resourceLimits来自配置,stdout/stderr捕获到logs而非继承。不做池化,不跨 run 保留状态:程序的世界随 worker 消亡,这使得 run 仅从日志即可重建,状态泄漏不可表达。 - 在 bootstrap 中执行:剥离后的程序成为一个
AsyncFunction的函数体,其参数是绑定全局变量、消费方声明的 reject 类和一个捕获式consoleshim,因此顶层await和return可用。Code Mode 声明ToolCallError,成员属性为toolName;运行时无需硬编码工具即可实体化真正的构造函数。无损 JSON 完成值会精确跨越边界;undefined仍表示缺席,有损值产生invalid-output,过大的外层结果产生output-limit,而不会退化为检查格式化后的字符串替代品。 - 通过消息端口桥接绑定:worker 中的每个绑定函数发送
{ id, global, name, args }并等待回复;宿主根据请求的绑定校验名称、调用、并回复{ id, ok, value }或{ id, ok: false, message }(宿主侧绑定拒绝变为程序侧 rejection)。worker 侧的命名空间对象通过defineProperty构建为 null-prototype,因此名为__proto__、constructor或toString的绑定是普通自有属性,而非原型链碰撞。未知名称、重复 id 和结算后消息被拒绝或忽略——端口协议假设对端是恶意的,因为对端运行的是模型代码。 - 强制独立预算。
computeMs计量 worker 忙碌时间,允许慢速的 awaited 工具而不放过热循环。maxWallMs约束总经过时间,包括未解析的等待。maxOutputBytes只约束序列化后的外层日志、完成值或诊断的组合;中间绑定值没有字节数上限。到期、取消和完成都终止 worker,堆退出或外层溢出会作为显式失败报告。 - dispose(资源释放)至完全停稳:服务自身的 dispose 终止进行中的 worker 并等待其退出后再 resolve,遵循防御性模式。
信任姿态
worker 运行时只能约束程序的运行,而不构成安全边界:模型代码可以访问 Node API,权限与 bash 工具相当。worker.terminate() 停止线程但不停止它 spawn 的 OS 进程。Code Mode 使用与 bash 相同的 tools/pre-execute 策略门禁,并额外提供空环境、堆限制、独立 isolate 和对程序本身的硬终止。需要硬多租户边界的部署需要为代码和 bash 都使用容器级后端;运行时的 isolation 描述符让它们能区分该后端。
模型看到的内容
SDK 指示模型编写一个所加载运行时语言的异步函数体(默认可擦除 TypeScript;Python 运行时下为 Python async 函数体——见语言分发 note),通过 await tools.name(args) 调用工具,在需要时捕获被拒绝的工具调用,并仅 return 或 log 应重新进入上下文的输出。两种 flavor 用各自的原语陈述同一约定:相互独立的只读调用可以(MAY)在 Promise.all(TypeScript)或 asyncio.gather(Python)下重叠,有副作用的调用按提交顺序单独运行,有依赖的工作用 await 排序。声明前缀可能与原生 schema 一样大,尤其在 'both' 下,但对提供方缓存保持稳定。
传输自身的 description 与两种 flavor 的 SDK 说明都以点名 code 和 description 这两个必填参数开头。把该调用描述成「传入一个程序」的散文会让第二个参数只能从参数 schema 中发现,而只发出 {code} 的模型会因 INVALID_ARGS 被拒,连同已写好的整个程序一起丢失。
后果
切换到 '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-contextfixture(测试前置数据)固定 SDK 文本、请求头工具列表、分发事件、延后上下文和结果卡片。
曾考虑的替代方案
一个零核心改动的附加消费方插件。 否决,因为 agent/request 在可重建请求下仅限 call-config,而变换已组装的工具列表需要在不拥有其配置的情况下撤销 toolOrder 规范化,并依赖监听器顺序。向模型提供哪些工具、以何种表示形式提供,是注册表的单一关注点:原生 schema 和 SDK 是同一个可见存储的两种投影。
node:vm 作为参考运行时,加固推迟。 否决:node:vm 不是隔离(原型链逃逸可达宿主 realm)且无法中断热循环。worker 线程提供独立 isolate、空环境、resourceLimits 和可靠的 terminate(),信任等级等同于 bash,因此参考实现和生产实现是同一个包,无需 unsafe-acknowledgement 仪式。
在原生工具调用上做结果省略/摘要。 仅解决问题中上下文膨胀这一半:裁剪旧 tool-result 作为可重建请求下的日志化表面替换成本低,但仍需每次调用一次模型往返,且无法表达循环、分支或汇合。互补而非竞争;它可以在 Code Mode 下为残余的原生调用分层。
循环中的并行原生分发。 决策当时对往返成本的另一个答案;它被并发安全元数据阻塞,且无论如何都不提供组合能力——它并行化的是模型在一步中已经决定的调用。Code Mode 的队列决策保持了两者兼容,两者后来都已交付:元数据即 isConcurrencySafe(见并行工具调用 note),原生 rolling-pool 分发加每工具绑定并行化则基于同一个分类器。
始终排他(忠于 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 工具,约束能力强于它,门禁使用相同的审批与沙箱策略。需要更强隔离的部署需要未来的 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 并列),所有 code-runtime 专用实现都由 ctx.codeRuntime 提供。
大型无损 JSON 值可能耗尽内存。 工具绑定会在分发前对无损 JSON 创建快照,并完整返回规范 JSON 返回值。运行时会校验 worker 端口两侧,但不对单次绑定设置字节数上限;结构化克隆成本以及进程或 worker 内存构成实际边界。只有包含日志、完成值和失败诊断的组合外层输出账本受字节数上限约束。
子分发的重叠由工具自身的安全声明限定,而非由调用方决定。 程序里的 Promise.all 或 asyncio.gather 只在工具自己分类为并发安全的调用之间换来挂钟并行性;一串 exclusive 调用仍要按顺序付出各自的往返开销,模型可能过度期望。两种 flavor 的 SDK 说明都陈述了真实约定。本 note 交付的是使该风险绝对化的序列化占位实现;调度器及其重叠上限由实时并行 Agent Note 负责。
预算计量读取事件循环,而非 flag。 忙碌时间轮询(eventLoopUtilization())比精确 CPU 计量更粗糙——预算到期最多延迟一个轮询间隔——且其正确性声明(「pending 的分发不能暂停它」)是抵御恶意程序的关键。两种情况均有单元测试(带 pending 诱饵分发的热循环会在耗尽 computeMs 预算时终止;等待慢速绑定的空闲程序则会持续运行至 maxWallMs),轮询间隔是内部常量而非配置——部署无法将其误调为绕过手段。maxWallMs 是配置项,且会传入 setTimeout,后者会把超过 MAX_TIMER_DELAY_MS(2^31-1 ms)的延迟夹到 1 ms;因此仅有正数校验会放行一个 25 天的上限,它在第一个 tick 就到期,使每次运行都超时。worker 运行时正因如此在加载时对该字段做范围校验。computeMs 不需要上界,因为它对照的是实测占用率,而不是交给定时器。