青雲的博客
深入浅出 Pi 第三部:Agent Loop 如何继续 第 16 章

工具执行前先经过哪些检查

从截断响应、工具查找、参数准备、TypeBox 校验与 beforeToolCall hook 逐级追踪,解释失败为什么会变成 tool result 而不是打断整场 run。

源码版本
v0.83.0
验证日期
Commit
845d6ff1f6643aba440341cce877ce1c43ebbc39

模型返回一个 toolCall content block,只能证明它提出了调用请求。Pi 还要回答四个问题:这条 assistant response 是否完整,工具是否存在,arguments 是否符合 schema,运行时策略是否允许执行。任何一步失败,都不该把一段未经确认的数据交给 execute()

预检还有一个 UI 层容易误读的细节。tool_execution_startprepareToolCall() 之前发出,因此它表示“运行时开始处理这条工具调用”,不是“工具主体已经执行”。未知工具、参数错误、策略阻断同样会出现 start,紧接一个带 isError: true 的 end。

输出被截断时,连合法 JSON 也不能信

当 assistant message 的 stopReasonlength,Pi 不走普通 executeToolCalls()。原因写在源码里:流式 tool arguments 的 salvage parser 可能把被截断的片段修成一段能解析、甚至能通过 schema 的 JSON,但关键字段内容已经少了一截。此时逐个验证没有意义,整条 assistant message 里的所有 tool calls 都必须失败。

failToolCallsFromTruncatedMessage() 仍为每条调用发 start、end 和 tool-result message,只是结果文本说明 output token limit,isError 为 true,batch 的 terminate 为 false。这样 transcript 保持工具调用与结果配对,下一次模型请求也能看到失败原因并重新发出完整参数。

这个分支和普通 schema validation 的证据强度不同。普通 validation 能证明当前对象满足字段约束;length 告诉运行时生成过程已经不完整。Pi 选择相信生成终止原因,而不是相信 salvage 以后碰巧合法的对象。

正常预检的五道门

未被截断的工具调用进入 prepareToolCall()

  1. 在当前 context.tools 中按 name 查找;不存在就立即返回错误。
  2. 若工具提供 prepareArguments,先把原始 arguments 转成兼容形状。
  3. validateToolArguments() 克隆参数、做类型转换或 JSON Schema coercion,再检查 schema。
  4. 若配置了 beforeToolCall,把 assistant message、原始 toolCall、已校验 args 和当前 context 交给 hook;hook 可以 { block: true, reason }
  5. hook 前后检查 abort signal;已经中止就返回 Operation aborted
flowchart TD
  accTitle: 工具调用的预检决策树
  accDescr: length 截断先整体拒绝;正常调用再查工具、准备并校验参数、执行 before hook 与中止检查,只有 prepared 结果能进入 execute
  TC["assistant toolCall"] --> LEN{"message stopReason = length?"}
  LEN -->|yes| FAILALL["每条调用生成 error ToolResult"]
  LEN -->|no| FIND{"tool name exists?"}
  FIND -->|no| ERR["immediate error"]
  FIND -->|yes| PREP["prepareArguments"]
  PREP --> VALID["clone + coerce + schema check"]
  VALID -->|throw| ERR
  VALID --> HOOK["beforeToolCall"]
  HOOK -->|block / throw| ERR
  HOOK --> ABORT{"signal aborted?"}
  ABORT -->|yes| ERR
  ABORT -->|no| READY["PreparedToolCall"]
  READY --> EXEC["tool.execute"]
  ERR --> RESULT["error ToolResult"]
  FAILALL --> RESULT

prepareArguments 的用途是兼容,不是绕过 schema。比如工具的新 schema 需要 edits[],旧模型仍可能给 oldText / newText;工具可以先把旧形状提升到数组,再由统一 validator 检查。prepareArguments 自己抛错,也会被 prepareToolCall() 的 catch 收成错误结果。

真正的 schema 校验位于 pi-ai。它先 structuredClone(toolCall.arguments),因此正常情况下不会把 coercion 直接写回 assistant message;随后执行 TypeBox convert,对非 TypeBox schema 再走 JSON Schema coercion,最后检查 validator 并生成带字段路径和原始 arguments 的错误文本。

beforeToolCall 是策略门,也有自己的责任边界

beforeToolCall 收到的是已经验证过的 args。它适合做需要上下文的策略判断,例如拒绝危险路径、要求外部批准,或者根据本轮信息阻断某个调用。返回 block 不会抛出 run failure;循环产生错误 ToolResult,让模型有机会换一种做法。

不过 v0.83.0 没有在 hook 之后再次 validation。传给 hook 的 args 是一个可变对象,hook 若原地把字符串字段改成数字,随后 execute() 会收到这个改动。仓库测试明确固定了这一行为。扩展作者应把 args 视为只读;若确实要重写,必须自己维持工具 schema,而不能假设运行时会二次兜底。

还要区分 block 与 abort。block 是某条工具调用的策略结果;abort 属于整场 active run 的信号。预检阶段看到 signal 已中止,会为当前调用生成 error,但批次循环也会在完成这条 immediate outcome 后停止准备后续调用。工具主体与 hooks 拿到同一个 signal,却仍需自行响应;JavaScript 的 AbortSignal 不会强制终止任意 Promise。

用仓库现成测试校准两个边界

只看源码可用下面的命令。固定 checkout 已安装依赖时,再运行两个定向测试;它们使用 mock stream,不访问网络,也不读取 API key。

repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
git -C "$repo" show v0.83.0:packages/agent/src/agent-loop.ts |
  nl -ba | sed -n '374,405p;586,664p'

# 可选:需要在该 checkout 完成 npm install
npm --prefix "$repo/packages/agent" test -- \
  test/agent-loop.test.ts \
  -t 'length-truncated|prepare tool arguments'

预期一个用例证明被截断调用从未进入 execute(),另一个证明 prepareArguments 的输出经过校验后才执行。它们没有覆盖所有 extension policy,也不证明某个业务 hook 安全;本章只锁定通用 Agent loop 的预检合同。

通过预检以后,工具才真正开始执行。默认的 parallel 也比字面意思多一层时序:预检保持顺序,允许的主体并发,完成事件按真实完成时间出现,写给模型的 ToolResult 却恢复原始调用顺序。下一章专门拆这组看似矛盾的顺序。