toolExecution.ts 解析

目标

这页回答:

单个 tool_use 到底是怎样被执行、校验、过权限、运行工具实现、再包装成 tool_result 的?

文件:

  • src/services/tools/toolExecution.ts

一句话结论

runToolUse() 是“单工具执行协议”的核心入口。

它不是简单地 tool.call() 一下,而是会完整经过:

  1. 找工具定义
  2. 校验 schema
  3. 校验输入值
  4. 预跑 permission / hook 链
  5. 真正执行 tool.call()
  6. 处理结果、hooks、error、telemetry
  7. 输出标准化 tool_result

也就是说:

tool.call() 只是中间一步,runToolUse() 才是完整工具运行时。


核心入口:runToolUse()

它的输入是:

  • toolUse: ToolUseBlock
  • assistantMessage
  • canUseTool
  • toolUseContext

它先做什么?

1. 先找工具

先在当前可用工具池里找:

findToolByName(toolUseContext.options.tools, toolName)

如果没找到,还会尝试:

  • getAllBaseTools() 里看是不是老 alias / deprecated tool name

这说明系统考虑了:

  • 老 transcript 重放
  • 工具改名后的兼容性

第二步:进入 streamedCheckPermissionsAndCallTool()

这是一个很重要的设计。

它不是直接返回最终结果,而是包成一个 AsyncIterable,因为工具执行过程中可能会不断吐:

  • progress message
  • 最终 message

所以系统用 Stream<MessageUpdateLazy>() 把:

  • progress 通道
  • 结果通道

揉成同一条异步流。

这和 StreamingToolExecutor 的设计天然契合。


第三步:真正主逻辑在 checkPermissionsAndCallTool()

这个函数非常肥,但逻辑是清楚的。


1. 输入 schema 校验

先做:

tool.inputSchema.safeParse(input)

如果失败:

  • 返回错误型 tool_result
  • 记录 telemetry
  • 对 deferred tool 还会补 schema-not-sent hint

这个点很值钱

说明模型吐出来的参数并不可信。 系统默认认为:

  • 模型可能把数组写成字符串
  • 可能字段类型不对
  • 可能 tool schema 压根没进 prompt

所以运行时必须兜底。


2. 业务级输入校验

即使 schema 过了,还会跑:

tool.validateInput?.(...)

这和 Zod schema 不同。

区别

  • schema 校验:结构对不对
  • validateInput:业务语义对不对

比如:

  • 路径是否合法
  • 参数组合是否冲突
  • 当前上下文是否允许这样调用

3. Permission / Hook 链

在真正调工具前,会经过一长段权限与 hook 流程:

  • runPreToolUseHooks(...)
  • resolveHookPermissionDecision(...)
  • canUseTool(...)
  • 可能执行 executePermissionDeniedHooks(...)

关键意义

这说明工具执行前不只是“问一嘴用户允不允许”,而是有一套完整决策链:

  • hook 可以改输入
  • hook 可以阻止继续
  • hook 可以给额外上下文
  • permission system 可以 allow / deny / ask
  • classifier 也可能参与

这就是一个成熟 agent runtime 的味道了: 工具执行前有独立治理层。


4. 真正执行 tool.call()

前面一堆都通过以后,才会到:

const result = await tool.call(...)

调用时还会给工具一个扩展后的 context:

  • toolUseId
  • userModified
  • progress callback

这说明工具实现本身可以:

  • 上报进度
  • 依赖 toolUseId 做关联
  • 感知用户是否在 permission 阶段修改了输入

5. 结果不是直接裸返回,而是再包装

执行完 tool.call() 后,不会直接把 result 交给主循环。

它会先:

A. 生成标准 tool_result block

const mappedToolResultBlock = tool.mapToolResultToToolResultBlockParam(...)

B. 过 result storage / size budget 逻辑

processPreMappedToolResultBlock(...)
processToolResultBlock(...)

C. 拼成 user message

最终用:

createUserMessage({ content: [tool_result, ...] })

送回主循环。

这说明什么

工具自己的返回值和最终进入 transcript / 再喂给模型的 tool_result不是一回事

中间还有一个很重要的“serialization / storage / shaping”阶段。


6. PostToolUse hooks 也会参与收尾

在工具成功后还会跑:

  • runPostToolUseHooks(...)
  • 失败时跑 runPostToolUseFailureHooks(...)

这意味着 hook 不只存在于“执行前”,也存在于:

  • 执行后结果修饰
  • 错误后补充处理

有些 hook 还能:

  • 更新 MCP output
  • 追加 attachment
  • 生成 stop summary

所以工具执行协议是三段式:

PreToolUse → tool.call → PostToolUse / PostToolUseFailure

7. Error path 也是一等路径

如果工具抛错:

  • 不会让整个系统原地爆炸
  • 会进入统一 error formatting
  • 记录 telemetry
  • 生成错误型 tool_result
  • 跑 failure hooks

这个设计很重要。

因为在 agent loop 里,工具失败本身就是模型要消费的信息, 不是单纯进程级异常。


和 AgentTool 的关系

AgentTool 作为一种工具,也完全走这条协议。

也就是说,agent 调用在运行时会依次经过:

  1. runToolUse() 找到 AgentTool
  2. schema / validate / permission / hooks
  3. 执行 AgentTool.call()
  4. 返回 completed / async_launched / remote_launched 等结果
  5. 再统一包装成 tool_result

所以 AgentTool 再复杂,也没跳出统一工具运行时模型。


当前结论

toolExecution.ts 真正定义了:

一个工具在这个系统里,什么叫“被正确执行一次”。

它把:

  • 参数校验
  • 权限治理
  • hook 插桩
  • 执行
  • 结果序列化
  • 错误处理
  • telemetry

全部捆成了一条统一协议。

这也是为什么 AgentTool 能优雅地作为普通工具接入,而不是特殊内核逻辑。