工具调用与工具系统构建
这份文档只回答一个问题:
这个仓库里,模型可调用的“工具系统”是怎么被构建出来的?
结论先说:
它不是简单地把若干函数暴露给模型,而是把工具做成了一套完整的平台层,包含:
- 统一工具抽象
- 静态注册与按环境裁剪
- 内建工具和 MCP 工具的统一合并
- 工具权限与 Hook 管线
- 并发/串行执行编排
tool_use -> tool_result的消息回写- 运行中动态刷新工具集合
可以把它理解为:
Tool 定义层 -> Tool Pool 装配层 -> Query 主循环 -> Tool Execution 管线 -> Transcript/UI 回写
1. 统一抽象:先把“工具”定义成一种平台对象
核心类型在 src/Tool.ts。
这里没有把工具定义成“只有一个 call() 的函数”,而是定义成一个完整的 Tool 对象。一个工具至少包含这些部分:
nameinputSchema/inputJSONSchemacall()prompt()description()checkPermissions()validateInput()mapToolResultToToolResultBlockParam()- 各种 UI 渲染方法
- 并发、安全、只读、破坏性等元信息
这说明系统把“工具”看成一个跨层对象,而不是单纯执行逻辑。它同时服务于:
- 给模型暴露能力
- 在本地做输入校验
- 做权限判定
- 做执行
- 做 transcript / UI 渲染
- 做 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() 返回系统内建工具全集,例如:
BashToolFileReadToolFileEditToolFileWriteToolNotebookEditToolWebFetchToolWebSearchToolAgentToolTask*ListMcpResourcesToolReadMcpResourceToolToolSearchTool
但这个“全集”仍然会受到环境与 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) 是核心入口。
它做三件事:
- 取出当前允许的内建工具
- 对 MCP 工具应用同样的 deny 规则
- 把两者合并并按名字排序、去重
这里的排序不是为了美观,而是为了 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 这个模板工具,再覆盖关键字段:
namemcpInfodescription()prompt()inputJSONSchemacheckPermissions()call()- 只读/破坏性/openWorld 等提示信息
所以 MCP 工具虽然来自外部协议,但进入系统后会被“编译”为内部统一工具对象。
4.2 为什么这样设计很重要
这样做之后,MCP 工具和内建工具就能共享同一套基础设施:
- 同一个工具池
- 同一套权限机制
- 同一条执行管线
- 同一种 transcript 表达
- 同一种 UI 渲染模型
从平台视角看,MCP 不是外挂,而是一级公民。
4.3 延迟加载工具:shouldDefer / ToolSearch
Tool 抽象里支持:
shouldDeferalwaysLoad
这说明系统支持“不是一开始把全部 schema 都发给模型”,而是:
- 先暴露一部分
- 其余工具通过
ToolSearchTool延迟发现
toolExecution.ts 里甚至有专门逻辑,当某个 deferred tool 的 schema 没被发送导致参数校验失败时,会提示模型先用 ToolSearch 再重试。
这套机制本质上是在解决:
- 工具多
- schema 大
- prompt 成本高
这三个问题。
5. 工具如何进入主循环:query.ts 负责模型-工具闭环
工具系统真正运转起来是在 src/query.ts。
主循环大致是:
- 把当前工具列表传给模型
- 流式接收模型输出
- 收集其中的
tool_use - 执行工具
- 把结果变成
tool_result - 把新消息拼回上下文继续下一轮
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 执行链路
一条工具调用大致会经过这些阶段:
- 按名字找到 Tool 对象
- 用
inputSchema.safeParse()做结构校验 - 用
validateInput()做语义校验 - 跑
PreToolUsehooks - 解析权限决策
- 真正执行
tool.call() - 跑
PostToolUsehooks - 生成
tool_result - 产出消息并回写上下文
这说明工具对象本身只负责“自己的业务逻辑”,真正的平台行为全在统一执行层里。
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,还可以带:
newMessagescontextModifiermcpMeta
这意味着工具不仅能返回结果,还能:
- 主动往 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,每个工具都有一套状态机:
queuedexecutingcompletedyielded
这几个状态的含义可以理解为:
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 做了两层处理:
progressmessage 不放进最终结果数组,而是先进入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.tssrc/tools/*
职责:
- 定义 Tool 接口
- 定义具体工具
- 规定 schema、权限、渲染、结果映射
10.2 注册与装配层
src/tools.tssrc/utils/toolPool.tssrc/cli/print.ts
职责:
- 注册内建工具
- 合并 MCP 工具
- 按模式、权限、feature 过滤
- 生成当前回合最终工具池
10.3 扩展接入层
src/services/mcp/client.tssrc/tools/MCPTool/MCPTool.ts
职责:
- 从 MCP server 拉取工具描述
- 包装成内部 Tool
- 接入同一套平台能力
10.4 执行编排层
src/services/tools/toolOrchestration.tssrc/services/tools/StreamingToolExecutor.ts
职责:
- 分批
- 并发/串行调度
- 顺序回放结果
10.5 执行管线层
src/services/tools/toolExecution.tssrc/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、执行、结果回写链路,形成一个持续递归的模型-工具闭环。