工具执行流水线:把关、审批与结算

📑 目录

第 9 篇末尾说,工具清单是模型与执行世界之间的合同。合同这端,模型在一次步骤里可能连发几个 tool-call;合同那端,每个调用都要经过把关、审批、执行、结算,最终以 tool/result 事件落进会话日志。本篇拆开 packages/core/tools 里的这条流水线,看一次调用从注册到落盘穿过哪些站,以及 dsh 为什么在几个看似可以省事的环节选择了不省事。

一次调用的完整旅程

入口在 ToolRuntime.execute()(packages/core/tools/src/index.ts:1369)。它不发执行,只装配执行:为这次调用分配一个 symbol 令牌,把模型给出的参数做无损快照并深冻结,捕获内容投影器——所有可变的东西在这一步被钉死,之后流水线里流转的是同一份事实。

随后是策略段。tools/pre-execute waterfall 给出第一刀,决策是四值的:allow、deny、cancel、ask(index.ts:1505 的默认实现是 allow)。ask 走向审批服务:请求经 ctx.approval.request()(index.ts:1744)发出,带上 agent、工具名、调用 ID 与建议的询问理由。裁决有四种:allowed-once 放行这一次;rejected 变成 deny,理由是「用户拒绝了此工具」;cancelled 同样 deny,但额外标记审批本身被取消——调用方信号与审批信号是分开的两条;unavailable 则是「此工具需要审批,但没有可用的审批通道」。连「调用没有 agent 可路由」这种边角也明确 deny。四个出口没有一个是默认放行——fail-closed 的原则在审批环节同样成立。deny 与 cancel 直接终结这次调用,理由是机器可读的字符串,原样写进结果的反馈。

策略段的第二刀是守卫。guard() 注册的守卫按「全局层,再到作用域链,远者在前」的顺序逐个询问(guardReason(),index.ts:1150),返回第一个拒绝理由即停。注意守卫的签名:(exec) => string | undefined,返回值只有拒绝理由,没有 allow。这是刻意的单调性设计——任何监听顺序都无法把一次拒绝翻成许可,只能维持或追加拒绝。策略意志一经表达,不能被后来者稀释。守卫和 waterfall 的分工由此清晰:waterfall 管「这次调用该不该发生」,守卫管「有没有一条不可翻案的拒绝」。

执行段由 tools/execute waterfall 开场,timeout-policy 之类的包装层挂在这里;包装层可以替换执行的 signal,却不能替换调用的身份——令牌在入口就分配好了。真正的工具体拿到信号后干活,产出经 projectContent 投影成内容块。然后是结算段:tools/post-execute waterfall 默认 accept(index.ts:1781),监听者可以替换 content 或 value 之一,也可以 block——block 把结果转成 isError: true 并附上反馈,模型由此知道这次调用为何被拦下。两种决策都能附带 additionalContexts,进入所属步骤的活跃批次队列;但工具体暂存的上下文在 block 时会被丢弃,只有拦截决策显式给出的部分幸存——被拦下的调用没有理由把自己的半成品上下文塞进模型视野。finalizeContent 回调恰好执行一次,连绕过 post-execute 的失败路径也要走它,保证内容定稿只有一处。最后是 materialize:无损 JSON 校验加深冻结,事件作为 tool/result 落进日志,进程内的 tools/result 通知随后发出,观察者抛错会被隔离——事实已经落盘,通知只是通知(第 8 篇的四域分类在这里闭环)。

flowchart TD
  C["createExecution: 令牌 + 参数深冻结 + 投影器"] --> P["tools/pre-execute 默认 allow"]
  P -->|"ask"| A["审批服务 allowed-once 无服务则 deny"]
  P -->|"allow"| G["单调守卫 第一个拒绝理由即停"]
  P -->|"deny / cancel"| R1["tool/result 带拒绝理由"]
  A --> G
  G --> X["tools/execute 包装层 可换 signal 不换身份"]
  X --> T["工具体执行"]
  T --> PC["projectContent 投影"]
  PC --> PO["tools/post-execute 默认 accept"]
  PO -->|"accept 换 content 或 value"| F["finalizeContent 恰好一次"]
  PO -->|"block"| R2["转 isError + 反馈"]
  F --> M["materialize 校验 + 深冻结"]
  R2 --> M
  M --> E["tool/result 落盘 → tools/result 通知"]

并发调度:按模型顺序提交

一次步骤常有多个 tool-call,调度器是 executeToolCalls(packages/core/agent-loop/src/tool-calls.ts:60)。它先分类:工具声明 isConcurrencySafe 且对当前参数返回精确 true 才进并行组,否则一律独占——异常、未声明都按独占处理,又一个 fail-closed。独占调用构成排序屏障,屏障后的调用在组启动前重新分类,注册表的变化对未开始的调用立即生效。

并行组是有界滚动池:maxParallelToolCalls 上限内持续补齐新调用,分发可以重叠,但策略与结果按模型给出的顺序提交——池子里先跑完的调用要排队,等排在它前面的调用都出了结果,才轮到它落盘。这个约束直接服务模型:LLM 看到的工具结果次序,就是它发出调用的次序,思考链不被调度器的快慢打乱。中止时,已开始的调用排空并记录,未开始的逐个补「已跳过」的结果——appendSkippedToolCall(tool-calls.ts:250)为每个被跳过的调用补一对事件:tool/call 加上错误结果,info.code 是 TOOL_ABORTED_BEFORE_DISPATCH,模型读到的反馈与正常失败是同一种语言。日志不留空洞,这与第 7 篇的配对补全纪律一脉相承。

flowchart LR
  subgraph 模型给出的顺序
    C1["调用 A isConcurrencySafe=true"] --> C2["调用 B 未声明"] --> C3["调用 C 声明但返回 false"]
  end
  C1 -->|"parallel 滚动池"| P1["可并发分发"]
  C2 -->|"exclusive 屏障"| P2["独占执行 后续重新分类"]
  C3 -->|"exclusive 屏障"| P2
  P1 --> O["按模型顺序提交 tool/result"]
  P2 --> O

左侧的分类只发生一次,右侧的提交次序却由模型决定:执行可以乱序完成,落盘必须对号入座。

三个不省事的设计

ptc 折叠前置拒绝。 模式折叠的调用在策略流水线之前就被终止,pre-execute 监听者与审批服务永远看不到一个注定失败的调用。审计视角里只有合法的候选,拒绝发生在任何策略被消耗之前。

规范结果防伪造。 注册表规范化过的结果记在 canonicalResults WeakMap 里,以执行令牌为键;包装层返回的外部对象一律重新过 output 契约。没有令牌,就过不了物化——「我替它说结果」这条路在类型与数据两层都被封死。

失败配对补全。 步骤中途失败时,ToolCallRecovery(packages/core/session/src/repair.ts:105)在步骤作用域内重放事件:流里出现过 tool-call 但从未记录 tool/call 的,补「未启动」错误结果;记录了调用却没有结果的,补「结果未知」,并明确提示模型只重试只读或幂等操作。已提交的结果原样保留,结果不明的操作绝不自动重试。流水线可以失败,但日志必须完整。

可见性:注册表的遮蔽链

工具在哪里可见,由 view()(index.ts:1178)按层解析:全局层打底,祖先层链上近者遮蔽远者,restrict() 的过滤只作用于继承面——本层的自有注册永不被过滤豁免规则误伤。这条豁免写进了注释:委派运行时会往子 agent 自己的层注册结构化输出工具,过滤名单不能连回答问题的机械一并剥掉。注册用 register(definition)(index.ts:1063),返回精确注销句柄;名字 run_code 为呈现传输保留,注册即抛错。每一项注册都是副作用,随插件卸载自动撤销——第 4 篇的 Cordis 纪律,在工具注册表上又应验一次。

小结

一条工具调用穿过十站:令牌与冻结、预执行、审批、单调守卫、执行包装、工具体、投影、后执行、定稿、物化。策略段的两刀——waterfall 与守卫——分别管「这次调用该不该发生」与「有没有一条不可翻案的拒绝」;调度器保证结果按模型顺序提交;物化把守日志质量的大门。流水线之外,可见性的遮蔽链和注册即副作用的纪律,让「谁能用什么工具」成为可组合、可撤销的配置事实。

模型侧的通道至此齐备。下一篇我们下到协议的更底层:LLM 适配层如何定义流式词汇,一次请求如何被冻结、重试,以及 DeepSeek 官方路由的私有扩展长什么样。