跳到主要内容

数据截至 (上游 commit 99f6f02fecdb)

第 4 章 · 手脚:工具注册表、三段式执行流水线与 Code Mode

30 秒导读: 模型只能吐字符串。这一章讲这些字符串怎么变成"真的干了一件事"——工具怎么被定义、谁能看见它、一次调用要过几道关、一个 step 里多个调用怎么排队、以及 Code Mode 怎么把"调 20 次工具"压成"跑一段程序"。

上一章(主循环)讲到:模型返回的 tool_calls 被交给一个调度器,结果回来后再发下一次请求。这一章就是那个"交给"和"回来"之间发生的全部事情。


1. 先建立直觉:一次工具调用要过几道关

假设模型说"读一下 src/index.ts"。从这句话到磁盘上的字节,中间站着一排关卡:

模型输出 注册表 工具本体
| | |
v v v
tool_calls --解析--> ① 这个名字我这个 agent 看得见吗?
② 参数是合法 JSON 吗?
③ 有人要拦/要问用户吗? (pre-execute)
④ 有人要包一层超时吗? (execute) --> 真正跑
⑤ 结果要改/要挡吗? (post-execute)
⑥ 结果合乎它自己声明的 schema 吗?
|
v
落一条 tool/result 事件

三个关键判断,先说结论:

判断谁做一句话
看得见吗ToolRuntime.view()按 agent 的作用域继承链算出一张可见集,而不是一张全局大表
允许跑吗tools/pre-execute + guard可扩展的监听器链在前,单调的 guard 兜底——guard 只能否,不能"翻案"变准
结果算数吗output.schema + render工具必须声明自己返回什么形状,注册表逐个校验后才允许投影给模型

以及一个贯穿全章的规矩:模型看得见的东西,必须能从会话日志重建(见第 2 章)。所以每一次调用、每一次结果,甚至 Code Mode 里那些模型从没看见过的子调用,都要落账。


2. 一个工具长什么样:ToolDefinition 的四个面

一个注册进来的工具不是"一个函数",而是一个声明了四类事实的对象(packages/core/tools/src/index.ts:222ToolDefinition):

字段作用
进来的parameters发给模型的 JSON Schema 参数表
出去的outputschema + render + presentationMeta?强制的规范输出契约
给人看的presentCall / presentResultUI 呈现意图,纯函数,可在回放时重算
给调度器看的timeoutMs / isConcurrencySafe / finalizeContent永远不发给模型的元数据

2.1 进来的:参数 schema 是"编译"出来的,不是手写的

作者不直接写 JSON Schema,而是写一份更窄的 DSL,由 parameterSchemaSpecToJsonSchemapackages/core/tools/src/schema.ts:449)编译成 JSON Schema。

这段是 read 工具的真实参数声明(packages/fs/tool-fs/src/read.ts:79-83):

parameters: {
file_path: { type: 'string', required: true, description: 'Path to read, resolved by the filesystem backend.' },
offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' },
limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${caps.limit}.` },
},

注意 required每个属性上的标记,不是根上的数组——编译器把它收集成 JSON Schema 的 required: [...]。属性映射本身是一个隐式的开放对象根。

DSL 收窄到什么程度,由 assertSupportedJsonSchemapackages/core/tools/src/json-schema.ts:385)守住:只有 object/array/string/number/integer/boolean/nulloneOfenumconst 和四个注解关键字。为什么要收窄:

  • 这份 schema 要同时能编译成 TypeScript 类型和 Python TypedDict(Code Mode 用,见 §6),全 JSON Schema 做不到;
  • 注册表要用同一套规则校验返回值validateJsonSchemaValuejson-schema.ts:654),支持越多,校验器越可能有洞。

编译器本身是迭代式的(runSchemaCompilerschema.ts:275):用一个显式任务栈代替递归下降,深层嵌套 schema 不会爆栈;同时用一个 seen 集合检出循环引用。

2.2 出去的:强制的 output 契约

这是 DeepSeek Harness 和多数 harness 最不一样的一处:工具的 execute 不返回给模型看的文本,只返回一个规范 JSON 值ToolOutputDefinitionindex.ts:212)。

execute() 返回 value (JSON)
|
+--> output.schema 校验:不合 schema 直接 ToolOutputError
|
+--> output.render(args, value) --> ContentBlock[] 模型看这个
|
+--> output.presentationMeta(args, value) --> JsonValue UI 看这个(仅顶层调用)

三条硬规矩,都写在 createSuccessResultindex.ts:1793)里:

  1. 先校验再投影。 值先被 snapshotToolValue 做无损 JSON 快照,再过 validateJsonSchemaValue,违规就抛 ToolOutputErrorcode: 'INVALID_TOOL_OUTPUT')。
  2. render 必须是纯投影。 它只能读 args 和已冻结的 value;抛异常会被 projectionError 转成同一个 invalid-output 失败。
  3. presentationMeta 只给顶层调用算——exec.parent === undefined 才跑(index.ts:1806)。嵌套在 Code Mode 里的子调用没有自己的 UI 卡片,算了也没人用。

为什么值得这样分家?因为同一个真实结果要喂三张嘴,而三张嘴要的东西不一样:

消费者拿到什么从哪来
模型格式化文本renderContentBlock[],进 tool/result 事件
UI(含回放)结构化数据presentationMetameta,持久化进同一个事件
Code Mode 里的程序原始 JSON 值ToolExecutionSuccess.value故意不入durable 事件

read 工具的注释把第三点说得很直白(packages/fs/tool-fs/src/read.ts:120-122):模型看到的只是文本,行号/语言信息从文本里恢复不出来,所以要单独投影一份 meta 让 UI 在回放时重建代码卡片。

2.3 给人看的:UI 呈现意图

presentCall / presentResult 返回的是一套中立的渲染意图词汇,不是 HTML,也不是某个客户端的组件名(packages/core/tools/src/presentation.ts:46ToolCallView):

卡片什么工具用典型字段
generic默认titlekind(read/edit/execute…)、rawInputlocations
terminal前台 shell 命令title(命令本身)、cwd
diff写/改文件diffs(每个文件的 old/new)

结果侧多两种:search(匹配行 / 路径列表)和 read(带行号的代码窗口)。

关键约束:这两个方法必须是 args 的纯函数。 因为 UI 既会在流式输出时调它,也会在回放历史会话时调它。defineTool 为此做了一件很细的事(schema.ts:598-609):呈现方法走的是"软校验"——参数对不上就返回 undefined 退回通用卡片,而执行路径上同样的参数会硬抛 ToolArgsError。老日志里旧 schema 的参数不该让 UI 崩掉。

2.4 给调度器看的:三个永不外传的字段

字段谁读语义
timeoutMsdsh-tool-call-timeout-policy协作式超时预算;声明它等于承诺"我会转发 exec.signal"
isConcurrencySafe(args)调度器 executionMode只有精确返回 true 才算并行安全,其余一律独占
finalizeContent(exec, result)注册表收尾阶段最后一公里的内容改写,连流水线失败也会经过

schemaOfindex.ts:1256)只白名单 name / description / parameters 三个字段投影给模型,所以上面这些不会漏进请求里。

isConcurrencySafe 的 fail-closed 写得很彻底(executionModeindex.ts:1276):工具不可见、没声明、返回非 true、甚至分类器自己抛异常,全都归为 exclusive。判断并发安全是个容易判错的问题,判错的代价是数据竞争,所以默认值必须是"慢但对"。

2.5 defineTool:把上面这些缝在一起

defineToolschema.ts:545)是一层类型推导 + 校验包装:它把 DSL 编译成 JSON Schema,从 DSL 反推出 args 的 TypeScript 类型(InferArgs),并在 execute 前插入参数校验。类型推导有意做了 16 层容器的深度上限(InferValueAtschema.ts:153),超过就退化成 JsonValue——编译器不会因为一个畸形 schema 卡死。


3. 谁能看见谁:分层可见性

3.1 三种贡献

注册表不是一张 map,而是一堆按作用域分层的贡献表ToolLayerindex.ts:714)。一个插件能往里加三种东西:

方法加什么作用域限制变更通知
register(definition)一个工具全局或 agent 作用域皆可触发 tools/change
restrict({allow, deny})一个可见性过滤器必须在 agent 作用域内触发 tools/change
guard(fn)一个单调否决器全局或 agent 作用域皆可显式 notify: false

三者都返回 disposer——这是全仓的规矩(见第 1 章),注册即 effect。

register 的两条硬拒绝(index.ts:1037-1062):

  • output 缺失或 render 不是函数 → TypeError
  • 名字等于 run_code → 直接报错。这个保留是无条件的,哪怕当前部署跑的是 native 模式——因为任何 agent 都可能在运行时给自己选 Code Mode,那时名字冲突就来不及了。

restrict 更严(index.ts:1071-1098),四种情况直接抛:

  1. 不在 agent 作用域里调用——一个全局限制会遮住所有 agent,那不是"限制",那是"卸载";
  2. allowdeny 都没给(空过滤器几乎总是配置物化的 bug);
  3. 名字里出现 run_code——保留传输通道不接受限制,"要限就限真正的能力工具";
  4. 名字不在当前可限制集里——错字不该静默失效。

guardindex.ts:1110)的设计有个漂亮的不变量:它没有 allow 分支,返回字符串就是否,返回 undefined 就是"不表态"。所以无论监听器怎么排序,都不可能出现"后面的 guard 把前面的否决翻回准许"。这条性质由类型强制(ToolGuard = (exec) => string | undefinedindex.ts:711)。

3.2 view():一次遍历算出所有事实

view(scope)index.ts:1152)是整章最该看懂的一个函数。它按下面这个顺序解析:

全局层 最远祖先层 ... 最近祖先层 本作用域自己的层
| | |
+----------- 继承面 inherited -------------+ |
| |
过滤:链上任一层的 restriction 都要放行 | (不受过滤)
| |
v v
visible <--------- 同名覆盖 -----------------+
|
+--- mode ≠ native 时追加 run_code(在过滤之外)

三条要点:

  1. 限制过滤的是"继承来的",不是"自己注册的"。 注释里写了这个豁免为什么是硬需求(index.ts:1136-1148):委派运行时会把子 agent 的汇报工具、结构化输出工具注册进子 agent 自己的层,一个"限制子 agent 能用哪些能力"的过滤器绝不能顺手把它答题用的机器也剥掉。
  2. 限制在整条链上求交。 链上任何一层挡掉某个名字,嵌套在它下面的所有作用域都看不到。
  3. run_code 最后追加,且按作用域追加。 一个跑 native 的 agent,绝不会因为同进程里另一个 agent 用 Code Mode 就在自己的调度表里发现 run_code

view 一次返回三样东西:visible(过滤后的可见定义)、knownNames(过滤前的能力名,给 prompt 顺序校验用)、restrictableNames(当前全局名,给 restrict 校验用)。三个事实一次遍历算完,避免三处各走一遍链导致口径漂移。

3.3 schemas(scope) 与"看得见 ≠ 调得动"

对外有两个入口,语义故意不同:

方法问题行为
schemas(scope)index.ts:1234这个 agent 的模型该看到哪些 schema深拷贝投影 name/description/parameters
get(name, scope)index.ts:1204这个 agent 眼里这个名字解析成谁与呈现模式无关的纯注册表视图
resolveExecution(...)(私有,index.ts:1221这个调用允许跑吗get 之上再套一层 Code Mode 塌缩判断

第三行是 Code Mode 的安全边界,§6 再展开。这里先记住这个分工原则(也是仓库的成文规矩):决定要在做出它的那个操作里执行——schema 里不发某个工具不算强制,只有执行器拒绝才算。


4. 三段式执行流水线

4.1 三个 waterfall + 一个 emit

tools/pre-execute (waterfall) --> PreToolDecision: allow / deny / ask
|
guard 链(单调,只能否)
|
tools/execute (waterfall) --> 环绕包装:超时、重试、埋点
|
tool.execute(args, exec) --> 规范 JSON 值 --> 校验 + render
|
tools/post-execute (waterfall) --> PostToolDecision: accept / block
|
finalizeContent(工具自有的最后一公里)
|
tools/result (emit) --> 冻结、无损、只读的最终快照

事件声明在 index.ts:142-208。四者都做作用域过滤派发:注册在某个 agent 作用域上的监听器,只会收到那个 agent 的调用——路由键就是 exec.agentscopeTarget(this, exec.agent))。

唯一的例外是 tools/changeindex.ts:207),它故意不做作用域过滤:注册表变了是全局事实,每个 agent 的下一次组装都可能受影响,所以哪怕是作用域监听器也要看到全部变更。

两个决策类型:

决策变体含义
PreToolDecisionindex.ts:588allow放行
deny(reason)物化成一条错误结果
ask(reason?)交给审批服务;没有审批服务就等于拒绝
PostToolDecisionindex.ts:597accept{content?}接受,可换掉模型可见内容
accept{value}接受,换掉规范值(会被重新校验重新 render)
block{feedback}转成错误结果,内容是纠正性反馈

PreToolDecision 没有"改写入参"这个变体,注释给了理由(index.ts:582-587):参数已经落账、已经呈现给用户了,事后改会让日志和现实对不上。

accept 的两种形式互斥,同时给 contentvalue 会抛 TypeErrorpostExecuteindex.ts:1757)。

4.2 四段式调度接口 ToolRuntimeScheduler

ToolRuntime.execute()index.ts:1342)是给"直接调用者"的一口气版本。但主循环需要把有序阶段和可并行阶段拆开,所以注册表额外暴露一个 symbol 键的内部接口(TOOL_RUNTIME_SCHEDULERindex.ts:466;接口在 index.ts:451):

阶段干什么能否重叠
prepare(input)物化参数 + pre-execute + guard否,必须按模型顺序
dispatch(exec)tools/execute 环绕 + 工具本体,这是唯一重叠的一段
finalize(exec, r)post-execute + 收尾 + 落账
finish(exec, r)只做收尾 + 落账(跳过 post-execute)

prepare 的返回值本身就带路由(ScheduledToolPreparationindex.ts:431):dispatch(继续跑)、post-result(已定结论但仍需过 post-execute,比如被 deny)、final-result(已终局,比如 Code Mode 塌缩拒绝)。

为什么 deny 也要走 post-execute?因为像 repeat-tool-reminder 这类插件需要计到被拒的调用——模型反复砸一个被拒的调用,正是最该打断的循环(packages/guard/repeat-tool-reminder/src/index.ts:182-188)。

4.3 工具能往回递两件东西

工具本体拿到的不是裸参数,而是 ToolRunContextindex.ts:404),比 ToolExecution 多两个方法:

  • deferContext(msg) —— 攒一条 UserMessage,等这次调用的最终结果送达主循环时再追加。复合工具(比如子 agent 委派)用它把嵌套调用产生的上下文摆渡回外层;叶子工具用它插一条插件来源的提示。
  • concludeTurn() —— 把这次成功结果标记为"本 turn 到此为止"。标记只挂在 ToolExecutionSuccess 上(concludesTurn?: trueindex.ts:565),失败结果的类型里根本没这个字段——所以一个被策略转成失败的嵌套结果,没法通过"复合工具转发"来偷偷终止外层 turn。

4.4 取消:两个错误码的区别

注册表用两个码区分取消发生的时机(index.ts:469-472):

何时语义
ABORTED_BEFORE_DISPATCH工具本体还没被调用什么都没发生,安全
ABORTED工具本体已经开跑副作用可能已经落地

选哪个由 cancellationState.bodyInvoked 决定(cancellationResultindex.ts:1518)。

还有一条克制的取消契约:取消从不抛弃 promisedispatchToolBodyindex.ts:1532)会把调用方信号和环绕包装替换的信号熔断成一个fuseToolSignalsindex.ts:1889),等工具本体自己 settle 到静止后,才把成功结果换成 ABORTED。这样做的原因很实在:同进程代码没法硬杀,假装杀掉只会留下一堆还在跑的 I/O。

同一段逻辑还保证了环绕包装不能拆掉调用方的取消——包装可以换 exec.signal,但注册表总会把原始调用方信号重新熔进去。


5. 一个 step 内怎么排队

模型一次可以吐好几个 tool_callsexecuteToolCallspackages/core/agent-loop/src/tool-calls.ts:59)负责把它们排完。

5.1 分组:独占 = barrier,并行 = 有界滚动池

模型顺序: A(并行) B(并行) C(独占) D(并行) E(并行)
| | | | |
组 1 [A B] ---- 滚动池,上限 maxParallelToolCalls (默认 10)
组 2 [C] ---- 独占:独自跑,形成 barrier
组 3 [D E] ---- 新的滚动池

分组规则只有两行(tool-calls.ts:88-89):看队头调用的分类,是 parallel 就把剩下全部作为候选组,是 exclusive 就只取它一个。

组内由 runGrouptool-calls.ts:121)驱动,四条规则:

  1. 启动前重分类。 池子每次要塞新调用时,会对下一个调用重新调 executionModetool-calls.ts:203-204)。注册表在这中间发生了变化(比如某个插件被卸载),队尾那些还没启动的调用会当场翻成独占并截断本组。分类是的,永远以启动那一刻为准。
  2. 只有 dispatch 重叠。 prepare(pre-execute + guard)在 fillPool 里是 await 的,也就是说有序策略阶段串行,只有工具本体并发。
  3. 结果按模型顺序提交。 commitReadytool-calls.ts:146)只沿着连续的槽位前进:第 2 个调用先跑完也得等第 1 个提交。提交动作 = finalize/finish + 追加 tool/result 事件 + 收下 additionalContexts
  4. tool/call 在启动时就落账,并把事件的 seq 记下来,结果事件用 sourceEventSeqs 反向引用它(appendToolResulttool-calls.ts:268)。

5.2 abort 时补合成结果:为了让重放合法

这是全章最容易被忽视、也最能体现"日志即事实源"的一处。

模型协议要求:每一个 tool_call 都必须有一条对应的 tool_result,否则下一次请求根本不合法。而 abort 会让一部分调用根本没机会启动。

解法是给它们补一条合成结果(appendSkippedToolCalltool-calls.ts:249):

appendToolResult(session, turn, step, block, {
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
error: { message: 'tool call aborted before dispatch', info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
}, callSeq)

补账分两处:组内没启动的(tool-calls.ts:240)和后面整组都还没轮到的(tool-calls.ts:96)。顺序也讲究——先让已启动的调用 settle 并提交、上下文收下,再给剩余的补合成结果,这样日志里的顺序仍然是模型顺序。

对比一下另一种失败模式:调度器自己出错(不是工具出错)。这时的处理是相反的(tool-calls.ts:231-235)——排空在飞的 dispatch,然后把第一个错误抛出去,不伪造任何 tool result。理由是:abort 是一个语义明确的用户动作,"取消了"是一条可以诚实写进日志的事实;而调度器崩溃时,harness 不知道发生了什么,编造结果等于污染事实源。


6. Code Mode:把工具表渲染成 SDK

6.1 它解决什么问题

想象"统计每个包里的 TODO 标记"。原生工具调用要走:glob → 20 次 read → 模型自己在脑子里数。这意味着 21 个来回、20 份文件全文塞进上下文。

Code Mode 的做法:模型写一段程序,程序里调 20 次工具,只有程序 return/print 的那点东西回到模型

native 模式: code 模式:
模型 -> read a -> 全文进上下文 模型 -> run_code(一段程序)
模型 -> read b -> 全文进上下文 |
模型 -> read c -> 全文进上下文 +-- 程序内并发调 a/b/c
... 20 个来回 |
模型 -> 汇总 模型 <- "共 37 处"(只有这一行)

6.2 三种呈现模式

Config.modeindex.ts:665):

模式模型收到的 schema模型能直接调什么
native(默认)所有可见工具所有可见工具
code只有 run_code只有 run_code;其余从程序里调
both所有可见工具 + run_code都能

模式可以按作用域覆盖presentAs(mode)index.ts:946)只能在 agent 作用域调用,链上最近的声明获胜。这就是"同一个进程里,一个 preset 下的 agent 跑 Code Mode,旁边的 agent 跑 native"的实现方式。一个作用域只允许声明一次——"模型看到哪种形态"有两个答案是矛盾,不是合并。

6.3 塌缩必须在执行器上强制

code 模式下,如果模型偏要直接调 read 会怎样?

判断集中在一个私有谓词 collapses(name, scope, nested)index.ts:1324):

return !nested && this.modeFor(scope) === 'code' && name !== RUN_CODE_NAME

三处共用它,因此不可能漂移:resolveExecution(能不能跑)、createExecution(进流水线前的拒绝)、以及系统提示词里那句话collapseSectionindex.ts:855——text 用的就是同一个 modeFor 判断,所以提示词绝不会声明一条注册表不强制的规则)。

两个细节值得学:

  • 塌缩拒绝发生在策略流水线之前createExecutionindex.ts:1423)。理由写在注释里:一个注定失败的调用,绝不能让 pre-execute 监听器看到,更不能让审批弹窗去"批准"它。
  • 拒绝信息带路。 它不是干巴巴的 unknown tool "read",而是"只有 run_code 能直接调,请在 run_code 程序里调 read"(index.ts:1439-1442)。因为提示词刚刚才声明过 read,一个裸的 unknown tool 会让模型以为部署坏了而不是纠正自己。

nested 那一项是关键:Code Mode 的子调用带着 parent 令牌(ToolExecutionInput.parentindex.ts:335),带令牌就绕过塌缩。"有没有 parent 令牌"就是"是不是模型直调"的判据。

6.4 SDK 是从可见集渲染出来的

tools:sdk 提示词段(sdkSectionindex.ts:875)在每次组装时从调用方作用域重新生成,语言由 ctx.codeRuntime.language 决定,查 SDK_RENDERERS 表(index.ts:60):typescriptrenderToolsSdkpackages/core/tools/src/ts-types.ts:273),pythonrenderToolsSdkPypackages/core/tools/src/py-types.ts:763)。

TypeScript 版渲染出来大致长这样:

## Writing code for run_code
... 固定用法说明 ...

```ts
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }

interface ToolArgsMap {
/** Read a UTF-8 text file and return line-numbered content. */
read: { file_path: string; offset?: number; limit?: number };
}
interface ToolOutputMap {
read: { path: string; offset: number; lines: {...}[]; totalLines: number };
}
type ToolName = keyof ToolOutputMap
declare class ToolCallError extends Error { readonly toolName: ToolName; }
declare const tools: { [K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]> }
```

(结构照 ts-types.ts:282-292 的真实拼装;工具内容是示意。)

三个工程细节:

  1. 参数和输出都进类型。 sdkSchemasindex.ts:1239)除了投影 name/description/parameters,还额外带上 output.schema——所以程序拿到的是带类型的规范 JSON 值,而不是模型可见的那串文本。这正是 §2.2 那个"三张嘴"分家的兑现。
  2. 确定性排序。 两个渲染器都按名字字典序排(ts-types.ts:274py-types.ts:764),工具集不变就产出逐字节相同的文本——这是 KV cache 前缀稳定的前提。
  3. Python 版要多干很多活。 它得给每个参数/输出对象生成具名 TypedDict、处理保留字与非法标识符(tools["my-tool"] 走下标)、还要保证 docstring 落在方法体的第一条语句上(py-types.ts:776-806)——因为 code 模式下这份 SDK 是模型唯一能读到的工具说明。

6.5 一次 run_code 内部的调度

createRunCodeToolpackages/core/tools/src/code-mode.ts:296)把每个可见工具(除 run_code 自己)绑成一个 binding 函数(code-mode.ts:614-617),枚举依据是 registry.schemas(exec.agent)——和 SDK 段声明的是同一张视图,所以程序能绑定的恰好等于提示词承诺的。

程序里每调一次工具,走这条路:

binding(name)(args)
|
+-- 参数做两份无损 JSON 快照:一份用于派发,一份用于落账
| (落账那份是兄弟副本——工具改了自己的入参也污染不了日志)
|
+-- 入 pendingQueue,唤醒唯一的 driver lane
|
driver ----+-- 启动前重分类 classify()(和主循环同样的懒分类)
+-- 独占则等池排空、独自跑、且 barrier 一直持到 commit 完成
+-- 并行则最多 maxParallelSubCalls 个在飞(默认 10)
|
commit ----+-- finalize/finish -> 转发 additionalContexts / concludesTurn
+-- settle:程序立刻拿到值
+-- 落 tool/code-dispatch 事件(异步旁路,带背压)

这个 driver(code-mode.ts:395drive)刻意复刻了主循环的时序契约:所有有序阶段(起始事件、prepare、commit、结算事件)都在同一条 lane 里跑,只有工具本体并发。所以 maxParallelSubCalls: 1 就退化成严格串行。

几处很讲究的地方:

  • 独占 barrier 持到 commit 结束code-mode.ts:410),而不是本体跑完就放——和原生独占组的语义对齐,post-execute 也算在 barrier 内。
  • 程序立刻拿到值,落账是旁路code-mode.ts:489-526)。日志内容监听器(比如溢出策略要把结果存盘)绝不能拖慢程序、也不能占用一个调度槽位。
  • 旁路有背压while (logWork.size > maxParallel) await Promise.race(logWork)code-mode.ts:585)。每个待落账任务都持有一份完整结果,不设上限的话,慢存储后端会让内存无界增长。
  • run 结束必定排空finally 里先 runController.abort('run_code settled')drainDispatches()code-mode.ts:635-636),确保所有结算事件都落在这个 run_code 尚未关闭的 turn 内。在飞的子调用被中止而不是被遗弃,排队未启动的则被放弃且不落账。

6.6 记账:子调用逐条落,模型只看外层

两个日志专用事件(packages/core/tools/src/types.ts:25-58):

事件何时载什么
tool/code-dispatch-start调度器真的启动该子调用时rootCallId / parentCallId / subCallId / 工具名 / 规范化参数
tool/code-dispatch该子调用结算时上面全部 + isError + 完整模型侧内容

subCallId 是确定性的 <parent>:code:<n>,按提交顺序编号。

这两个事件都被 deriveMessages() 忽略——也就是说子调用永远不会重新进入模型上下文,而 UI 和持久化拿到了每一次调用。这一条正是 Code Mode 省上下文的根本机制,同时又不违反"模型可见 ⟺ 已落账"(子调用对模型不可见,所以不需要进 transcript;但它对用户可见,所以必须落账)。

外层 run_code 的规范输出只有两个字段(code-mode.ts:315-323):

schema: { type: 'object', additionalProperties: false, properties: {
logs: { type: 'array', required: true, items: { type: 'string' } },
result: { type: 'json' },
} }

renderlogsresult 拼成一段文本;程序没输出就是 (run_code completed with no output)。工具描述里那句"Only what you print or return is program output — curate it"(packages/core/tools/src/code-mode.ts:52)是对模型的显式告知:你要自己做裁剪


7. 流水线上挂的四个现成插件

这四个都不是特例代码,全是普通的监听器——它们同时也是"怎么在这条流水线上做扩展"的范本。

7.1 超大输出:溢出到磁盘,只回一段预览

dsh-spill-policypackages/spill/spill-policy/src/index.ts:110)挂两个臂:

事件管什么
模型侧tools/post-execute:190顶层调用的结果文本超过 maxInlineBytes 就存盘,换成 头/尾预览 + 定位符
日志侧tools/code-dispatch-log:217同一套阈值用在 tool/code-dispatch 事件的日志副本

存储走 SpillStore.saveTextpackages/spill/spill/src/index.ts:55),Service Definition 窄到只有这一个方法。

刻意留的洞(注释在 :18-35):

  • 不配 maxInlineBytes什么都不注册,是个真正的 no-op;
  • 只处理纯文本结果,含非文本块的一律不碰;
  • 跳过 read,否则会形成 read → 溢出 → 再 read 的死循环;但日志臂不跳过 read,因为日志副本不是模型上下文,而 read 恰恰是产生巨型日志的那个工具;
  • 尽力而为:没有会话属主、没有后端、存储失败,一律记一条 warn 然后原样返回。溢出失败绝不能把一次成功调用变成 isError

还有一个容易踩的坑他们处理了(:163-186):预览 + 提示语的总大小必须仍然在 cap 之内。所以先按最坏情况给提示语预留字节,再拿剩下的预算做预览;如果连提示语本身都超了 cap,就放弃溢出保留原文——因为溢出后反而更大,就违背了这个 cap 的承诺。

7.2 超时:协作式,不抢跑

dsh-tool-call-timeout-policypackages/guard/timeout-policy/src/index.ts:55)是一个 tools/execute 环绕包装,二十行说完:

using d = deadline(exec.signal, timeoutMs, TOOL_TIMEOUT)
const upstream = exec.signal
exec.signal = d.signal
try {
const result = await next()
if (timeoutOf(d.signal, TOOL_TIMEOUT) !== undefined) return toolTimeoutResult(timeoutMs)
return result
} finally { exec.signal = upstream }

三个点:

  • 它不 race,也不抛弃工具的 promise——先 await next() 等工具自己因为 signal 中止而静止,再决定要不要替换结果。
  • 超时归属靠 code 区分timeoutOf(d.signal, TOOL_TIMEOUT))。外层还有别的 deadline 先响时,这里读到 undefined,于是那次取消被当成普通的上游取消,而不是"我的超时"。
  • finally 里把信号换回去,所以 post-execute 监听器看到的永远是调用方自己的信号,而不是这个插件那个可能已经 abort 的派生信号。

7.3 重复调用:只提醒,不否决

dsh-repeat-tool-reminderpackages/guard/repeat-tool-reminder/src/index.ts:213)维护每个 agent 一条"连续相同调用"链,命中阈值(默认 [3, 5, 8])就往 additionalContexts 里塞一条提醒。

设计上的三个选择:

  1. 挂在 post-execute 而不是 pre-execute,因为被拒的调用也会走 post-execute——模型反复砸一个被拒的调用正是最该打断的循环(:182-188)。
  2. 观察-增强,绝不否决:先计数(状态无条件推进),再 next() 让后面的监听器还能 block,最后把提醒折到人家的决策上。block 分支也带上提醒。
  3. 参数要规范化再比较:深度按键排序后 stringify(canonicalize:103),所以只有属性顺序不同的两次调用被认作同一次。提醒里引用的参数会被截断(默认 500 字符),但比较用的永远是完整串——截断只约束模型可见文本,不能影响检测。

用户插话会重置链(agent/pre-step 监听器,:229):上下文变了,跨越它的重复不算循环。

7.4 审批与权限:以 pre-execute 监听器的身份介入

审批不是注册表的特权功能,而是一条普通的 ask 决策 + 一个可选服务:

某个 pre-execute 监听器 返回 { kind: 'ask', reason }
| (例:Claude Code 钩子桥,hooks-claude-code/src/index.ts:242)
v
注册表 serviceAsk()(index.ts:1689)
|
+-- ctx.get('approval') 没有? -> deny(历史降级)
+-- exec.agent 没有? -> deny(没有会话可审计、没有 UI 可路由)
|
v
ApprovalService.request()(packages/interaction/user-approval/src/index.ts:257)
|
+-- 落 approval/asked
+-- 策略 'never'? -> 直接 'rejected'
+-- 否则 waterfall 'approval/request' 问各个应答者
+-- 落 approval/decided
|
v
allowed-once -> allow ;rejected / cancelled / unavailable -> 三条不同措辞的 deny

值得学的五处:

  • 可选服务用 ctx.get('approval') 读,不用静态 inject。 否则整个 ctx.tools 和它背后的全部工具插件,都会被"必须有审批服务"绑架。
  • 四种非授予状态措辞各不相同index.ts:1715-1726),这样模型能区分"人拒绝了"和"根本没有审批通道"。
  • 'never' 策略在服务自己的 request 路径里判,不做成监听器user-approval/src/index.ts:307-312)。注释解释得很清楚:任何监听器都可能被一个 prepend: true 的监听器抢到前面,那就守不住"确定性拒绝"的承诺——只有服务自己的路径能。
  • 审批必须发生在 turn 内:259)。审计对 approval/asked + approval/decided 必须被 turn 这个提交/重放边界包住,否则重载时和崩溃尾部无法区分。
  • 应答者抛异常只让"问题"失败,不让"工具调用"失败:328)——seam 包住自己的回调。

permission-presetspackages/interaction/permission-presets/src/index.ts:175)在这之上再抽一层:它把"沙箱模式"和"审批策略"两个独立旋钮打包成用户能选的 preset(默认 workspace-writedanger-full-access)。切换时它先记一条 permission/preset 表达用户意图,再通过各自的规范 setter 写旋钮(:410-411)。执行、提示词叙述、回放三边仍然各读各的旋钮折叠,preset 事件只是保住"两个 preset 恰好绑定同一组旋钮时,用户选的是哪个"。


8. 巧妙之处(可以带走的)

  1. 强制的输出契约把"结构化值"和"模型文本"彻底分家。 因为 execute 只返回 JSON 值,同一个工具才能既服务原生调用(渲染成文本)、又服务 Code Mode(程序拿原值)、还服务 UI 回放(presentationMeta),一处实现三处受益(index.ts:212index.ts:1793)。
  2. guard 没有 allow 分支。 用类型消灭"监听器顺序能翻案"这一整类 bug(index.ts:711)。
  3. 同一个谓词同时驱动"提示词说什么"和"执行器拒什么"。 collapsescollapseSection 的 text 和 resolveExecution 共用(index.ts:1324:861),提示词不可能声明一条注册表不强制的规则。
  4. 限制只作用于继承面,本层注册豁免。 这条豁免让"给子 agent 装能力过滤器"不会顺手剥掉它答题用的机器(index.ts:1152 及其注释)。
  5. abort 补合成结果 ≠ 崩溃伪造结果。 前者是可以诚实记录的事实,后者是污染事实源(tool-calls.ts:249 vs tool-calls.ts:231)。
  6. Code Mode 的落账是旁路 + 背压。 程序立刻拿到值,日志写入不阻塞调度,但用池上限约束待落账任务数(code-mode.ts:489:577)。
  7. 参数落账用的是"兄弟副本"而不是同一个对象。 工具改自己的入参也没法让日志和实际派发的值脱节(jsonNormalizeArgscode-mode.ts:153)。
  8. schema 编译器和 JSON 渲染器都是迭代式的。 显式任务栈 + 缩进上限,畸形深嵌套只会退化格式,不会爆栈或指数膨胀(schema.ts:275code-mode.ts:187)。

9. 边界与局限

  • 协作式取消,不能硬杀。 ToolDefinition.execute 的文档写明了:注册表能保住取消语义、不抛弃 promise,但杀不掉同进程代码index.ts:226-231)。一个不转发 exec.signal 的工具,超时和 abort 对它没有实际约束力。
  • 语言绑定不随请求走。 提示词组装和 run_code 执行分别ctx.codeRuntimerequireCodeRuntimeindex.ts:1019)。目前只有一个后端语言时无害,但如果一次热重载在两次读之间换了语言,就会把针对某个 SDK 写的程序交给另一个运行时。代码注释明确说这是有意推迟到第二个后端落地时再解决。
  • 溢出策略只认纯文本。 结果里有任何非文本块就整体放过(spill-policy/src/index.ts:201),所以图片、结构化块类的巨型结果不受这个 cap 约束。
  • presentationMeta 只算顶层。 Code Mode 子调用没有 meta,UI 渲染子调用只能靠 tool/code-dispatch 里的模型侧 contentindex.ts:1806types.ts:41-56)。
  • ToolRuntimeScheduler 不是扩展点。 它是 symbol 键的内部接口,注释里明说普通调用者应该用 ToolRuntime.executeindex.ts:445-460)。想插行为就用三个 waterfall。
  • 只有两种 SDK 语言。 CodeSdkLanguage'typescript' | 'python'code-mode.ts:82);加第三种要同步改三张表(渲染器表、flavor 表、union),少改一处 typecheck 会挂,但它管不住那些用文字复述这些值的散落文档(index.ts:30-44 自己列了这份清单)。

10. 代码地图

主题文件符号
工具定义与输出契约packages/core/tools/src/index.tsToolDefinitionToolOutputDefinition
注册 / 限制 / 守卫packages/core/tools/src/index.tsToolRuntime.registerrestrictguardToolLayer
作用域可见性解析packages/core/tools/src/index.tsToolRuntime.viewschemasgetresolveExecution
三段式流水线事件packages/core/tools/src/index.tstools/pre-executetools/executetools/post-executetools/result
四段式调度接口packages/core/tools/src/index.tsTOOL_RUNTIME_SCHEDULERToolRuntimeSchedulerprepareScheduledExecutiondispatchScheduledExecutionfinalizeScheduledExecutionfinishScheduledExecution
决策类型packages/core/tools/src/index.tsPreToolDecisionPostToolDecisionToolGuard
结果规范化与冻结packages/core/tools/src/index.tscreateSuccessResultnormalizeDispatchResultmaterializeFinalResult
取消语义packages/core/tools/src/index.tsfuseToolSignalsTOOL_ABORTEDTOOL_ABORTED_BEFORE_DISPATCH
审批接入点packages/core/tools/src/index.tsToolRuntime.serviceAsk
参数 DSL 与类型推导packages/core/tools/src/schema.tsdefineToolparameterSchemaSpecToJsonSchemarunSchemaCompilerInferArgs
JSON Schema 子集与校验packages/core/tools/src/json-schema.tsassertSupportedJsonSchemavalidateJsonSchemaValue
UI 渲染意图词汇packages/core/tools/src/presentation.tsToolCallViewToolResultView
Code Mode 传输通道packages/core/tools/src/code-mode.tsRUN_CODE_NAMEcreateRunCodeToolCodeSdkLanguageRUN_CODE_FLAVORS
Code Mode 子调度器packages/core/tools/src/code-mode.tsdrivedrainDispatchesjsonNormalizeArgs
子调用落账事件packages/core/tools/src/types.tstool/code-dispatch-starttool/code-dispatch
SDK 渲染(TS / Py)packages/core/tools/src/ts-types.tspy-types.tsrenderToolsSdkrenderToolsSdkPyjsonSchemaToTsjsonSchemaToPy
一个 step 的调度算法packages/core/agent-loop/src/tool-calls.tsexecuteToolCallsrunGroupcommitReadyappendSkippedToolCall
溢出存储与策略packages/spill/spill/src/index.tspackages/spill/spill-policy/src/index.tsSpillStore.saveTextspillReplacement
超时与重复守卫packages/guard/timeout-policy/src/index.tspackages/guard/repeat-tool-reminder/src/index.tsTOOL_TIMEOUTtoolTimeoutResultobservecanonicalize
审批服务与权限预设packages/interaction/user-approval/src/index.tspackages/interaction/permission-presets/src/index.tsApprovalService.requesteffectiveApprovalPolicyPermissionPresetServicePresetSpec
一个真实工具的完整写法packages/fs/tool-fs/src/read.tsdefineTool({ name: 'read' … })

继续读: 工具本体怎么拿到"文件系统""shell""浏览器"这些能力,而不把自己钉死在某个实现上,是第 5 章 · 能力接缝的内容。工具集怎么按 preset 每会话组装、提示词怎么拼,见第 6 章