工具调用与工具系统构建

这份文档只回答一个问题:

这个仓库里,模型可调用的“工具系统”是怎么被构建出来的?

结论先说:

它不是简单地把若干函数暴露给模型,而是把工具做成了一套完整的平台层,包含:

  • 统一工具抽象
  • 静态注册与按环境裁剪
  • 内建工具和 MCP 工具的统一合并
  • 工具权限与 Hook 管线
  • 并发/串行执行编排
  • tool_use -> tool_result 的消息回写
  • 运行中动态刷新工具集合

可以把它理解为:

Tool 定义层 -> Tool Pool 装配层 -> Query 主循环 -> Tool Execution 管线 -> Transcript/UI 回写


1. 统一抽象:先把“工具”定义成一种平台对象

核心类型在 src/Tool.ts。

这里没有把工具定义成“只有一个 call() 的函数”,而是定义成一个完整的 Tool 对象。一个工具至少包含这些部分:

  • name
  • inputSchema / inputJSONSchema
  • call()
  • prompt()
  • description()
  • checkPermissions()
  • validateInput()
  • mapToolResultToToolResultBlockParam()
  • 各种 UI 渲染方法
  • 并发、安全、只读、破坏性等元信息

这说明系统把“工具”看成一个跨层对象,而不是单纯执行逻辑。它同时服务于:

  1. 给模型暴露能力
  2. 在本地做输入校验
  3. 做权限判定
  4. 做执行
  5. 做 transcript / UI 渲染
  6. 做 telemetry

1.1 buildTool() 的作用

src/Tool.ts 里的 buildTool() 很关键。

它给工具提供一组默认行为,例如:

  • 默认 isEnabled() 为 true
  • 默认 isConcurrencySafe() 为 false
  • 默认 isReadOnly() 为 false
  • 默认 isDestructive() 为 false
  • 默认 checkPermissions() 为放行

这带来的效果是:

  • 每个工具只需要声明自己的特殊部分
  • 平台层总能拿到一个结构完整的 Tool 对象
  • 调用方不需要到处写 tool.xxx?.() ?? default

也就是说,Tool 在这里已经是一个内部 DSL。

1.2 ToolUseContext 是工具运行时上下文

ToolUseContext 也是 src/Tool.ts 的核心。

它把工具执行需要的运行时能力集中到一起,包括:

  • 当前可用工具列表
  • commands / mcpClients / mcpResources
  • getAppState() / setAppState()
  • abortController
  • 消息列表 messages
  • UI 相关接口
  • 文件状态缓存
  • 当前 agent / query 链路信息

所以工具并不是在一个“纯函数环境”里运行,而是在一个完整的 agent runtime 里运行。


2. 工具如何注册:src/tools.ts 是注册中心

工具系统的第一层装配在 src/tools.ts。

2.1 getAllBaseTools():静态工具总表

getAllBaseTools() 返回系统内建工具全集,例如:

  • BashTool
  • FileReadTool
  • FileEditTool
  • FileWriteTool
  • NotebookEditTool
  • WebFetchTool
  • WebSearchTool
  • AgentTool
  • Task*
  • ListMcpResourcesTool
  • ReadMcpResourceTool
  • ToolSearchTool

但这个“全集”仍然会受到环境与 feature flag 影响,例如:

  • 某些工具只在 USER_TYPE === 'ant' 下启用
  • 某些工具依赖特定 feature
  • 某些工具依赖运行环境,例如 PowerShell、Worktree、LSP

所以这里做的不是死注册,而是“按构建与运行环境生成基础工具集”。

2.2 getTools():基于权限上下文过滤

getTools(permissionContext) 会在基础工具集上继续做过滤:

  • 简化模式只保留少量工具
  • REPL 模式下隐藏被 REPL 包装掉的原始工具
  • 删除被 deny rule 明确禁止的工具
  • 删除当前 isEnabled() 为 false 的工具

重点是:

这里不是等模型调用后再拒绝,而是先把不该看到的工具从工具池里拿掉。

这意味着安全边界的一部分是在“能力暴露阶段”完成的。

2.3 filterToolsByDenyRules():权限规则会提前改写能力边界

filterToolsByDenyRules() 用权限上下文过滤工具。

它不仅能过滤内建工具,也能过滤 MCP 工具;而且支持按 MCP server 前缀整体屏蔽。

这点非常重要:

权限系统不是只在执行时生效,而是会直接改变“模型当前看见什么工具”。


3. 工具池如何装配:内建工具和 MCP 工具被统一合并

注册完内建工具后,还要装配出“当前这一轮真正可用的工具池”。

3.1 assembleToolPool()

src/tools.ts 的 assembleToolPool(permissionContext, mcpTools) 是核心入口。

它做三件事:

  1. 取出当前允许的内建工具
  2. 对 MCP 工具应用同样的 deny 规则
  3. 把两者合并并按名字排序、去重

这里的排序不是为了美观,而是为了 prompt cache 稳定。

代码里专门保持:

  • 内建工具作为连续前缀
  • MCP 工具作为后缀

这样新增或删除某个 MCP 工具时,不会无谓地打碎整个系统 prompt 的缓存键。

3.2 mergeAndFilterTools()

src/utils/toolPool.ts 的 mergeAndFilterTools() 会继续做一层合并:

  • 合并初始工具、组装后的工具、动态工具
  • 再次去重
  • 必要时应用 coordinator mode 的工具白名单

所以最终“给模型看的工具列表”不是单点来源,而是多路合流后的结果。

3.3 cli/print.ts 的 buildAllTools()

在 CLI 主入口里,buildAllTools() 把这些来源真正汇总到一起:

  • 启动时已有工具
  • SDK MCP 工具
  • 动态接入的 MCP 工具
  • synthetic output tool

这说明工具池是运行时可变的,而不是进程启动后固定不变。


4. MCP 如何接入:外部工具被包装成内部 Tool

MCP 是这套工具系统最重要的扩展入口,核心在 src/services/mcp/client.ts。

4.1 fetchToolsForClient()

这个函数会向 MCP server 发 tools/list 请求,然后把远端返回的工具描述转换成内部 Tool 对象。

转换时复用了 src/tools/MCPTool/MCPTool.ts 这个模板工具,再覆盖关键字段:

  • name
  • mcpInfo
  • description()
  • prompt()
  • inputJSONSchema
  • checkPermissions()
  • call()
  • 只读/破坏性/openWorld 等提示信息

所以 MCP 工具虽然来自外部协议,但进入系统后会被“编译”为内部统一工具对象。

4.2 为什么这样设计很重要

这样做之后,MCP 工具和内建工具就能共享同一套基础设施:

  • 同一个工具池
  • 同一套权限机制
  • 同一条执行管线
  • 同一种 transcript 表达
  • 同一种 UI 渲染模型

从平台视角看,MCP 不是外挂,而是一级公民。

4.3 延迟加载工具:shouldDefer / ToolSearch

Tool 抽象里支持:

  • shouldDefer
  • alwaysLoad

这说明系统支持“不是一开始把全部 schema 都发给模型”,而是:

  • 先暴露一部分
  • 其余工具通过 ToolSearchTool 延迟发现

toolExecution.ts 里甚至有专门逻辑,当某个 deferred tool 的 schema 没被发送导致参数校验失败时,会提示模型先用 ToolSearch 再重试。

这套机制本质上是在解决:

  • 工具多
  • schema 大
  • prompt 成本高

这三个问题。


5. 工具如何进入主循环:query.ts 负责模型-工具闭环

工具系统真正运转起来是在 src/query.ts。

主循环大致是:

  1. 把当前工具列表传给模型
  2. 流式接收模型输出
  3. 收集其中的 tool_use
  4. 执行工具
  5. 把结果变成 tool_result
  6. 把新消息拼回上下文继续下一轮

5.1 工具是跟模型流式输出耦合的

query.ts 在流式接收 assistant 响应时,会从消息块里提取 tool_use。

也就是说,工具调用不是“模型输出完一整段文本后再解析命令”,而是协议层就存在正式的 tool_use block。

5.2 主循环把当前工具集直接传给模型

在 deps.callModel(...) 时,会显式传入:

  • tools: toolUseContext.options.tools

这说明工具系统不是另起一套 side channel,而是作为当前回合采样参数的一部分送入模型。

5.3 工具执行后再递归进入下一轮

当这一轮 assistant 产出 tool_use 后,query.ts 会执行工具,然后把:

  • assistant 消息
  • tool_result 消息
  • 额外 attachment

一起拼成新的 messages

再递归进入下一轮 query。

所以 agent 的“会调用工具”能力,本质上就是这个递归闭环。


6. 工具如何被编排执行:串行与并发是平台层统一控制的

具体的批量执行调度在 src/services/tools/toolOrchestration.ts。

6.1 runTools() 不是简单 for 循环

它先用 partitionToolCalls() 把当前这批工具分成两类:

  • 并发安全工具批次
  • 非并发安全工具批次

判断依据不是写死的,而是每个工具自己的 isConcurrencySafe(input)。

6.2 并发安全工具可以批量并发

对于 read-only / concurrency-safe 的工具,会走 runToolsConcurrently()。

这意味着像搜索、读取类工具可以并行跑,提高吞吐。

6.3 非并发安全工具必须串行

写文件、修改状态、可能互相影响的工具,会走 runToolsSerially()。

这保证了上下文修改不会乱序。

6.4 上下文修改不是立刻生效,而是按批次回放

工具返回值允许带 contextModifier。

对于并发批次,这些 modifier 不会在结果返回瞬间就直接改上下文,而是先排队,等这一批跑完后再按顺序应用。

这个细节说明作者很清楚:

“并发执行”和“上下文一致性”是两个不同问题。


7. 真正执行一条工具调用:toolExecution.ts 是总管线

最核心的执行文件是 src/services/tools/toolExecution.ts。

runToolUse() 和 checkPermissionsAndCallTool() 组成了完整执行链。

7.1 执行链路

一条工具调用大致会经过这些阶段:

  1. 按名字找到 Tool 对象
  2. 用 inputSchema.safeParse() 做结构校验
  3. 用 validateInput() 做语义校验
  4. 跑 PreToolUse hooks
  5. 解析权限决策
  6. 真正执行 tool.call()
  7. 跑 PostToolUse hooks
  8. 生成 tool_result
  9. 产出消息并回写上下文

这说明工具对象本身只负责“自己的业务逻辑”,真正的平台行为全在统一执行层里。

7.2 输入校验分成两层

这里有两道关:

  • inputSchema.safeParse():结构是否正确
  • validateInput():值是否合法

这样能把“模型参数格式错了”和“参数含义不合法”分开处理。

7.3 权限不直接散落在工具实现里

权限决策通过:

  • tool.checkPermissions()
  • resolveHookPermissionDecision(...)
  • canUseTool(...)

共同完成。

也就是说,权限系统是“平台统一裁决 + 工具自定义补充”的组合,不是每个工具自己乱做一套。

7.4 Hook 是执行管线的一级结构

工具执行前后都有 hook:

  • runPreToolUseHooks()
  • runPostToolUseHooks()
  • runPostToolUseFailureHooks()

Hook 可以做很多事:

  • 改输入
  • 阻止继续执行
  • 注入额外上下文
  • 修改 MCP 输出
  • 失败时追加附加消息

这说明工具系统已经不是“调用函数”,而是“可拦截、可扩展的执行管线”。

7.5 tool.call() 的返回值不只是结果数据

ToolResult<T> 不止有 data,还可以带:

  • newMessages
  • contextModifier
  • mcpMeta

这意味着工具不仅能返回结果,还能:

  • 主动往 transcript 注入消息
  • 修改后续回合的上下文
  • 给 MCP / SDK 保留结构化元数据

所以工具被设计成“会影响对话状态的执行单元”。


8. 工具结果如何回写:统一转成 tool_result 消息

8.1 每个工具负责把业务结果映射成协议块

通过 mapToolResultToToolResultBlockParam(),工具把自己的输出转成标准的 tool_result 块。

这一步很关键,因为它把“内部业务结果”映射回“模型协议结果”。

8.2 平台层再统一处理大结果与持久化

执行层不会直接把结果原样塞回 transcript,而是会经过:

  • processPreMappedToolResultBlock()
  • processToolResultBlock()

这些逻辑会处理:

  • 大结果落盘
  • 截断
  • 结果替换
  • 与 tool_use_id 的绑定

所以 transcript 里的 tool_result 并不一定等于工具原始输出。

8.3 tool_use_id 是主键

整套系统围绕 tool_use_id 串联:

  • progress
  • permission
  • hook
  • tool_result
  • transcript 恢复

这使得系统可以稳定处理:

  • 并发工具
  • 中断
  • fallback
  • resume
  • orphan result 修复

9. 流式工具执行:工具甚至可以在模型还没说完时启动

query.ts 会根据 gate 决定是否创建 StreamingToolExecutor。

如果开启,它就不再等 assistant 整轮输出结束后才统一执行工具,而是在 streaming 过程中一边接收 tool_use,一边立刻尝试调度执行。

这使工具执行从“批处理”变成了“增量调度”:

  • 模型还在继续生成后续内容
  • 一部分工具已经开始执行
  • 一部分 progress / tool_result 已经提前进入 transcript

9.1 入口:它是在 query.ts 的 streaming 主循环旁边启动的

StreamingToolExecutor 的创建发生在 query.ts 的 query setup 阶段。

随后主循环一边消费 deps.callModel(...) 的 streaming 输出,一边检查 assistant message 里有没有新的 tool_use block。

一旦发现:

  • 先把这些 block 推入 toolUseBlocks
  • 再调用 streamingToolExecutor.addTool(toolBlock, message)

也就是说,它不是在模型输出完成后“回头扫一遍工具”,而是和模型 streaming 并行工作。

9.2 它本质上是一个按顺序回放结果的工具队列

src/services/tools/StreamingToolExecutor.ts 里维护了一组 TrackedTool,每个工具都有一套状态机:

  • queued
  • executing
  • completed
  • yielded

这几个状态的含义可以理解为:

  • queued:已经在 transcript 中出现,但还没开始执行
  • executing:已经启动,并正在消费 runToolUse() 的输出
  • completed:工具执行结束,最终结果已经缓存
  • yielded:结果已经正式回灌给 query 主循环,不应再重复输出

因此它的关键职责并不只是“尽快启动工具”,还包括:

  • 记录工具出现顺序
  • 缓存已经完成但尚未回放的结果
  • 保证最终输出顺序稳定

9.3 并发规则:并不是所有工具都可以一起跑

addTool(...) 不会盲目启动工具。

它会先:

  • 根据工具定义找到对应 Tool
  • 用 inputSchema.safeParse(...) 校验输入
  • 调用工具自己的 isConcurrencySafe(...)

只有在工具被判定为 concurrency-safe 时,它才允许和其他 concurrency-safe 工具并发执行。

对应规则可以概括成:

  • 当前没有正在执行的工具时,任何工具都可以启动
  • 当前正在执行的工具如果全部是 concurrency-safe,那么新的 concurrency-safe 工具也能加入并发执行
  • 只要遇到非 concurrency-safe 工具,它就必须独占执行

这和传统 runTools(...) 的“先按批次切组,再串行/并行执行”是同一个设计意图,只是 StreamingToolExecutor 把这套调度提前到了 streaming 阶段。

9.4 它没有重写单个工具执行,而是复用了 runToolUse()

StreamingToolExecutor 负责的是调度、顺序和取消传播。

真正执行单个工具时,它仍然调用 src/services/tools/toolExecution.ts 里的 runToolUse(...)。

所以单个工具内部仍然走同一条执行管线:

  • 工具查找
  • 参数校验
  • 权限检查
  • hook
  • 真正调用
  • 结果封装为 message / tool_result

这点很重要,因为它说明“流式工具执行”不是另一套独立工具框架,而是在原有工具执行管线上加了一层更激进的运行时编排。

9.5 结果如何提前进入 transcript

runToolUse() 自身就是 async generator,因此工具在执行过程中可以不断产出 update。

StreamingToolExecutor 对这些 update 做了两层处理:

  • progress message 不放进最终结果数组,而是先进入 pendingProgress
  • 非 progress 的 message 作为最终结果缓存到 tool.results

随后 query.ts 在 streaming 主循环内部会不断调用 getCompletedResults()。

这个方法会:

  • 优先吐出所有待发送的 progress
  • 再按工具原始接收顺序吐出已经完成的结果
  • 如果遇到一个仍在执行中的非 concurrency-safe 工具,就停止继续向后扫描

这样就实现了一个非常关键的性质:

  • 工具可以提早开始执行
  • progress 可以尽早显示给 UI
  • 最终 tool_result 又不会因为并发完成时间不同而打乱 transcript 顺序

换句话说,系统追求的是“执行尽量早,结果尽量稳”。

9.6 getRemainingResults() 负责在流结束后把尾巴收干净

模型 streaming 结束时,工具不一定已经全部跑完。

因此在工具执行分支里,query.ts 会把后续消费入口切到:

  • streamingToolExecutor.getRemainingResults()

这个 async generator 会反复:

  • 尝试继续推进队列
  • 先吐出已完成结果
  • 如果还有执行中的工具但当前没有新结果,就等待“某个工具完成”或“新的 progress 到来”

所以它承担的是 drain 作用:把 streaming 阶段没来得及回放完的工具输出全部补齐。

这一步非常关键,因为 transcript 对 API 来说必须满足一个基本约束:

  • 已经出现的 tool_use
  • 最终必须有对应的 tool_result

9.7 它对中断、错误和 fallback 有专门处理

流式工具执行最复杂的地方,不是“怎么更快”,而是“怎么在异常情况下不把 transcript 搞坏”。

StreamingToolExecutor 专门处理了几类取消语义。

第一类是用户中断。

如果 query 级别的 abortController 已经中断,它会根据工具的 interruptBehavior() 决定:

  • 这个工具是否应该取消
  • 取消后要不要生成 synthetic error / reject message

第二类是 sibling error。

当前实现只让 Bash 工具的错误级联取消兄弟工具。

原因也很现实:

  • Bash 工具经常存在隐含依赖链
  • 一个前置命令失败后,后面的并发命令继续执行通常没有意义
  • 但 Read、WebFetch、Grep 这类工具彼此往往是独立的,不应该因为一个失败就全部中止

第三类是 streaming fallback / model fallback。

一旦 streaming attempt 被判定为失败并切换到 fallback 路径,旧 attempt 中已经启动但尚未完成的工具结果就可能变成“孤儿结果”。

为了解决这个问题,query.ts 会:

  • tombstone 已经产出的 orphaned assistant messages
  • 调用 streamingToolExecutor.discard()
  • 重建一个全新的 StreamingToolExecutor

这样旧 attempt 的 tool_result 就不会带着旧 tool_use_id 混进新 attempt 的 transcript。

这套处理还和 Claude API 的 streaming fallback 风险绑定在一起:如果不显式丢弃旧执行器,中途 fallback 很容易导致“同一个工具被执行两次”。

9.8 它和传统 runTools(...) 的差别到底在哪里

如果把两条路径对比起来,可以更清楚地看出 StreamingToolExecutor 的定位。

runTools(...) 的特点是:

  • 先收集完整批次的 tool_use
  • 再按批次划分并发安全工具和非并发安全工具
  • 最后统一执行并产出结果

StreamingToolExecutor 的特点是:

  • tool_use 一出现就立刻入队
  • 能启动的工具立刻启动
  • 已完成的结果尽早回灌
  • 剩余结果在流结束后再 drain 完成

所以它改变的不是单个工具的执行语义,而是:

  • 启动时机
  • 调度时机
  • 取消传播方式
  • transcript 回灌方式

这是一个 runtime orchestration 层的优化,而不是 Tool API 层的重写。

9.9 当前实现的一个限制:并发工具的 context modifier 还没有真正支持

代码里有一条很重要的注释:

  • 当前并不真正支持 concurrency-safe 工具的 contextModifier

实际行为是:

  • 非 concurrency-safe 工具执行完后,contextModifier 会顺序应用回 toolUseContext
  • concurrency-safe 工具即使产生了 modifier,目前也不会像串行工具那样被安全地合并回上下文

这说明当前系统对“并发工具修改运行时上下文”仍然保持保守态度。

因此,StreamingToolExecutor 今天已经完整解决了:

  • 提前执行
  • 顺序回放
  • 中断与 fallback

但还没有把“并发上下文变更”这个问题彻底做完。

9.10 为什么说它是 agent runtime 级别的能力

从设计上看,StreamingToolExecutor 不是一个简单的“并发优化器”。

它实际上把以下几件事绑在了一起:

  • assistant streaming
  • tool queueing
  • progress UI
  • transcript 一致性
  • 中断传播
  • fallback 安全

所以它体现出来的并不是“工具调用加快了”,而是整个 agent runtime 已经从同步回合式执行,演化成了一个能处理流式事件、并发状态和异常恢复的系统。


10. 这套工具系统的真实分层

如果抽象成分层,可以写成:

10.1 定义层

  • src/Tool.ts
  • src/tools/*

职责:

  • 定义 Tool 接口
  • 定义具体工具
  • 规定 schema、权限、渲染、结果映射

10.2 注册与装配层

  • src/tools.ts
  • src/utils/toolPool.ts
  • src/cli/print.ts

职责:

  • 注册内建工具
  • 合并 MCP 工具
  • 按模式、权限、feature 过滤
  • 生成当前回合最终工具池

10.3 扩展接入层

  • src/services/mcp/client.ts
  • src/tools/MCPTool/MCPTool.ts

职责:

  • 从 MCP server 拉取工具描述
  • 包装成内部 Tool
  • 接入同一套平台能力

10.4 执行编排层

  • src/services/tools/toolOrchestration.ts
  • src/services/tools/StreamingToolExecutor.ts

职责:

  • 分批
  • 并发/串行调度
  • 顺序回放结果

10.5 执行管线层

  • src/services/tools/toolExecution.ts
  • src/services/tools/toolHooks.ts

职责:

  • 校验
  • 权限
  • Hook
  • 真实调用
  • 结果格式化
  • 错误处理

10.6 主循环层

  • src/query.ts

职责:

  • 把工具集交给模型
  • 接收 tool_use
  • 执行工具
  • 递归推进 agent 回合

11. 为什么说这不是“工具调用”,而是“工具平台”

从代码结构看,这套系统已经具备平台特征:

  • 工具有统一协议
  • 工具有统一注册中心
  • 工具有统一执行管线
  • 工具有统一权限与 hook 扩展点
  • 外部 MCP 工具可以无缝接入
  • 工具结果能影响上下文与后续回合
  • 支持延迟加载、并发、流式执行、恢复与重放

所以更准确地说:

这个仓库不是“实现了一批工具”,而是“实现了一个 agent tool runtime”。


12. 一句话总结

这个项目构建工具系统的方式是:

先用 Tool 抽象统一工具定义,再由 src/tools.ts 和 MCP 客户端把工具组装成当前可见的 tool pool,随后在 query.ts 的主循环里把工具交给模型、解析 tool_use,最后通过 toolOrchestration.ts 和 toolExecution.ts 走完整的校验、权限、hook、执行、结果回写链路,形成一个持续递归的模型-工具闭环。