数据截至 (上游 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:222 的 ToolDefinition):
| 面 | 字段 | 作用 |
|---|---|---|
| 进来的 | parameters | 发给模型的 JSON Schema 参数表 |
| 出去的 | output(schema + render + presentationMeta?) | 强 制的规范输出契约 |
| 给人看的 | presentCall / presentResult | UI 呈现意图,纯函数,可在回放时重算 |
| 给调度器看的 | timeoutMs / isConcurrencySafe / finalizeContent | 永远不发给模型的元数据 |
2.1 进来的:参数 schema 是"编译"出来的,不是手写的
作者不直接写 JSON Schema,而是写一份更窄的 DSL,由 parameterSchemaSpecToJsonSchema(packages/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 收窄到什么程度,由 assertSupportedJsonSchema(packages/core/tools/src/json-schema.ts:385)守住:只有 object/array/string/number/integer/boolean/null 加 oneOf、enum、const 和四个注解关键字。为什么要收窄:
- 这份 schema 要同时能编译成 TypeScript 类型和 Python
TypedDict(Code Mode 用,见 §6),全 JSON Schema 做不到; - 注册表要用同一套规则校验返回值(
validateJsonSchemaValue,json-schema.ts:654),支持越多,校验器越可能有洞。
编译器本身是迭代式的(runSchemaCompiler,schema.ts:275):用一个显式任务栈代替递归下降,深层嵌套 schema 不会爆栈;同时用一个 seen 集合检出循环引用。
2.2 出去的:强制的 output 契约
这是 DeepSeek Harness 和多数 harness 最不一样的一处:工具的 execute 不返回给模型看的文本,只返回一个规范 JSON 值(ToolOutputDefinition,index.ts:212)。
execute() 返回 value (JSON)
|
+--> output.schema 校验:不合 schema 直接 ToolOutputError
|
+--> output.render(args, value) --> ContentBlock[] 模型看这个
|
+--> output.presentationMeta(args, value) --> JsonValue UI 看这个(仅顶层调用)
三条硬规矩,都写在 createSuccessResult(index.ts:1793)里:
- 先校验再投影。 值先被
snapshotToolValue做无损 JSON 快照,再过validateJsonSchemaValue,违规就抛ToolOutputError(code: 'INVALID_TOOL_OUTPUT')。 render必须是纯投影。 它只能读args和已冻结的value;抛异常会被projectionError转成同一个 invalid-output 失败。presentationMeta只给顶层调用算——exec.parent === undefined才跑(index.ts:1806)。嵌套在 Code Mode 里的子调用没有自己的 UI 卡片,算了也没人用。
为什么值得这样分家?因为同一个真实结果要喂三张嘴,而三张嘴要的东西不一样:
| 消费者 | 拿到什么 | 从哪来 |
|---|---|---|
| 模型 | 格式化文本 | render 的 ContentBlock[],进 tool/result 事件 |
| UI(含回放) | 结构化数据 | presentationMeta 的 meta,持久化进同一个事件 |
| 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:46 的 ToolCallView):
| 卡片 | 什么工具用 | 典型字段 |
|---|---|---|
generic | 默认 | title、kind(read/edit/execute…)、rawInput、locations |
terminal | 前台 shell 命令 | title(命令本身)、cwd |
diff | 写/改文件 | diffs(每个文件的 old/new) |
结果侧多两种:search(匹配行 / 路径 列表)和 read(带行号的代码窗口)。
关键约束:这两个方法必须是 args 的纯函数。 因为 UI 既会在流式输出时调它,也会在回放历史会话时调它。defineTool 为此做了一件很细的事(schema.ts:598-609):呈现方法走的是"软校验"——参数对不上就返回 undefined 退回通用卡片,而执行路径上同样的参数会硬抛 ToolArgsError。老日志里旧 schema 的参数不该让 UI 崩掉。
2.4 给调度器看的:三个永不外传的字段
| 字段 | 谁读 | 语义 |
|---|---|---|
timeoutMs | dsh-tool-call-timeout-policy | 协作式超时预算;声明它等于承诺"我会转发 exec.signal" |
isConcurrencySafe(args) | 调度器 executionMode | 只有精确返回 true 才算并行安全,其余一律独占 |
finalizeContent(exec, result) | 注册表收尾阶段 | 最后一公里的内容改写,连流水线失败也会经过 |
schemaOf(index.ts:1256)只白名单 name / description / parameters 三个字段投影给模型,所以上面这些不会漏进请求里。
isConcurrencySafe 的 fail-closed 写得很彻底(executionMode,index.ts:1276): 工具不可见、没声明、返回非 true、甚至分类器自己抛异常,全都归为 exclusive。判断并发安全是个容易判错的问题,判错的代价是数据竞争,所以默认值必须是"慢但对"。
2.5 defineTool:把上面这些缝在一起
defineTool(schema.ts:545)是一层类型推导 + 校验包装:它把 DSL 编译成 JSON Schema,从 DSL 反推出 args 的 TypeScript 类型(InferArgs),并在 execute 前插入参数校验。类型推导有意做了 16 层容器的深度上限(InferValueAt,schema.ts:153),超过就退化成 JsonValue——编译器不会因为一个畸形 schema 卡死。
3. 谁能看见谁:分层可见性
3.1 三种贡献
注册表不是一张 map,而是一堆按作用域分层的贡献表(ToolLayer,index.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),四种情况直接抛:
- 不在 agent 作用域里调用——一个全局限制会遮住所有 agent,那不是"限制",那是"卸载";
allow和deny都没给(空过滤器几乎总是配置物化的 bug);- 名字里出现
run_code——保留传输通道不接受限制,"要限就限真正的能力工具"; - 名字不在当前可限制集里——错字不该静默失效。
guard(index.ts:1110)的设计有个漂亮的不变量:它没有 allow 分支,返回字符串就是否,返回 undefined 就是"不表态"。所以无论监 听器怎么排序,都不可能出现"后面的 guard 把前面的否决翻回准许"。这条性质由类型强制(ToolGuard = (exec) => string | undefined,index.ts:711)。
3.2 view():一次遍历算出所有事实
view(scope)(index.ts:1152)是整章最该看懂的一个函数。它按下面这个顺序解析:
全局层 最远祖先层 ... 最近祖先层 本作用域自己的层
| | |
+----------- 继承面 inherited -------------+ |
| |
过滤:链上任一层的 restriction 都要放行 | (不受过滤)
| |
v v
visible <--------- 同名覆盖 -----------------+
|
+--- mode ≠ native 时追加 run_code(在过滤之外)
三条要点:
- 限制过滤的是"继承来的",不是"自己注册的"。 注释里写了这个豁免为什么是硬需求(
index.ts:1136-1148):委派运行时会把子 agent 的汇报工具、结构化输出工具注册进子 agent 自己的层,一个"限制子 agent 能用哪些能力"的过滤器绝不能顺手把它答题用的机器也剥掉。 - 限制在整条链上求交。 链上任何一层挡掉某个名字,嵌套在它下面的所有作用域都看不到。
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.agent(scopeTarget(this, exec.agent))。
唯一的例外是 tools/change(index.ts:207),它故意不做作用域过滤:注册表变了是全局事实,每个 agent 的下一次组装都可能受影响,所以哪怕是作用域监听器也要看到全部变更。
两个决策类型:
| 决策 | 变体 | 含义 |
|---|---|---|
PreToolDecision(index.ts:588) | allow | 放行 |
deny(reason) | 物化成一条错误结果 | |
ask(reason?) | 交给审批服务;没有审批服务就等于拒绝 | |
PostToolDecision(index.ts:597) | accept{content?} | 接受,可换掉模型可见内容 |
accept{value} | 接受,换掉规范值(会被重新校验重新 render) | |
block{feedback} | 转成错误结果,内容是纠正性反馈 |
PreToolDecision 没有"改写入参"这个变体,注释给了理由(index.ts:582-587):参数已经落账、已经呈现给用户了,事后改会让日志和现实对不上。
accept 的两种形式互斥,同时给 content 和 value 会抛 TypeError(postExecute,index.ts:1757)。
4.2 四段式调度接口 ToolRuntimeScheduler
ToolRuntime.execute()(index.ts:1342)是给"直接调用者"的一口气版本。但主循环需要把有序阶段和可并行阶段拆开,所以注册表额外暴露一个 symbol 键的内部接口(TOOL_RUNTIME_SCHEDULER,index.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 的返回值本身就带路由(ScheduledToolPreparation,index.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 工具能往回递两件东西
工具本体拿到的不是裸参数,而是 ToolRunContext(index.ts:404),比 ToolExecution 多两个方法:
deferContext(msg)—— 攒一条UserMessage,等这次调用的最终结果送达主循环时再追加。复合工具(比如子 agent 委派)用它把嵌套调用产生的上下文摆渡回外层;叶子工具用它插一条插件来源的提示。concludeTurn()—— 把这次成功结果标记为"本 turn 到此为止"。标记只挂在ToolExecutionSuccess上(concludesTurn?: true,index.ts:565),失败结果的类型里根本没这个字段——所以一个被策略转成失败的嵌套结果,没法通过"复合工具转发"来偷偷终止外层 turn。
4.4 取消:两个错误码的区别
注册表用两个码区分取消发生的时机(index.ts:469-472):
| 码 | 何时 | 语义 |
|---|---|---|
ABORTED_BEFORE_DISPATCH | 工具本体还没被调用 | 什么都没发生,安全 |
ABORTED | 工具本体已经开跑 | 副作用可能已经落地 |
选哪个由 cancellationState.bodyInvoked 决定(cancellationResult,index.ts:1518)。
还有一条克制的取消契约:取消从不抛弃 promise。dispatchToolBody(index.ts:1532)会把调用方信号和环绕包装替换的信号熔断成一个(fuseToolSignals,index.ts:1889),等工具本体自己 settle 到静止后,才把成功结果换成 ABORTED。这样做的原因很实在:同进程代码没法硬杀,假装杀掉只会留下一堆还在跑的 I/O。
同一段逻辑还保证了环绕包装不能拆掉调用方的取消——包装可以换 exec.signal,但注册表总会把原始调用方信号重新熔进去。
5. 一个 step 内怎么排队
模型一次可以吐好几个 tool_calls。executeToolCalls(packages/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 就只取它一个。
组内由 runGroup(tool-calls.ts:121)驱动,四条规则:
- 启动前重分类。 池子每次要塞新调用时,会对下一个调用重新调
executionMode(tool-calls.ts:203-204)。注册表在这中间发生了变化(比如某个插件被卸载),队尾那些还没启动的调用会当场翻成独占并截断本组。分类是懒的,永远以启动那一刻为准。 - 只有 dispatch 重叠。
prepare(pre-execute + guard)在fillPool里是await的,也就是说有序策略阶段串行,只有工具本体并发。 - 结果按模型顺序提交。
commitReady(tool-calls.ts:146)只沿着连续的槽位前进:第 2 个调用先跑完也得等第 1 个提交。提交动作 =finalize/finish+ 追加tool/result事件 + 收下additionalContexts。 tool/call在启动时就落账,并把事件的seq记下来,结果事件用sourceEventSeqs反向引用它(appendToolResult,tool-calls.ts:268)。
5.2 abort 时补合成结果:为了让重放合法
这是全章最容易被忽视、也最能体现"日志即事实源"的一处。
模型协议要求:每一个 tool_call 都必须有一条对应的 tool_result,否则下一次请求根本不合法。而 abort 会让一部分调用根本没机会启动。
解法是给它们补一条合成结果(appendSkippedToolCall,tool-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 不知道发生了什么,编造结果等于污染事实源。