工具注册与分发 · 从 model 请求到工具执行
一堆异质的工具怎么装进一个集合、按什么顺序摆给模型、一次工具调用又怎么从名字走到执行。这一章拆 ToolBridge 与 FinalizedToolset:注册期的类型擦除、finalize 的定序、以及一次 call 的完整数据流。
工具注册与分发 · 从 model 请求到工具执行
上一章看了「一个工具是什么」。这一章看「一堆工具怎么组织、怎么被调到」。
ToolBridge:agent 和工具之间那道缝
主循环不直接碰工具,只通过 ToolBridge(bridge.rs)。它就是「agent ↔ 工具」的接缝,对上暴露:
tool_definitions()——给模型的工具清单(manifest);call(name, params, id)——派发一次工具调用;register_mcp_tools/unregister_*——运行时增删 MCP 工具;render_prompt(...)——用TemplateRenderer渲染 prompt。
ToolBridge 内部其实很瘦:一个 Arc<FinalizedToolset> + 一个摆在锁外的终端后端句柄(这样取消 kill_foreground_commands 能在 call() 持锁时照样工作)。
builder → finalize → 不可变集合
工具集的生命周期是「可变构建 → 定格」两段:
ToolRegistryBuilder:一个无序HashMap<String, ToolEntry>。注册register_with_params::<T, P>()时,把具体类型T/P擦除进一组 boxed 闭包(output_converter、validate_params、parse_input……)——这就是异质工具能装进同一个容器的关键 Rust 技巧:用闭包把泛型擦成同一类型。finalize(config, ctx):按ToolServerConfig.tools的顺序逐个定格成FinalizedTool,收进不可变的FinalizedToolset。
一个容易忽略但很重要的点:模型看到的工具顺序 = ToolServerConfig.tools 这个 Vec 的顺序。builder 的 HashMap 是无序的,顺序完全由客户端配置的 Vec 提供。工具排序会影响模型的选择倾向,所以这不是无所谓的细节。
SessionContext → Resources:依赖注入
finalize 时传入的 SessionContext 装着这个会话的一切具体依赖:终端后端、文件系统、cwd、记忆后端、各功能配置……它不被存下来,而是被转成一个 Resources——一个 HashMap<TypeId, Box<dyn Any>> 的类型化容器(依赖注入 + 运行时状态都放这)。
Resources 里用两个 newtype 区分:Params<T>(配置)与 State<T>(运行时状态,如 TodoState)——同一个 T 靠不同 TypeId 并存,只有它们会被序列化进 resources_state.json 持久化。
一次 call 的完整数据流
这是本章的核心。ToolBridge::call 最终走这条链(全在 registry/types.rs):
几个要点:
- 锁只持微秒:在
prepare_dispatch里就把要用的东西(转换器、反向参数映射)从读锁下克隆出来,.await之前放锁——绝不跨 await 持锁。 - 一份结果两个去向:
ToolRunResult(可转成ToolBridgeResult)把干净的结构化output(给 JSON/ACP/遥测)和追加了提醒的prompt_text(给模型)分开。 - 提醒是横切的:
finalize_output每次调用后跑所有Reminder(LSP 诊断、技能发现、任务完成),把结果包成<system-reminder>追加进 prompt_text(客户端声明不投递提醒时,这一步整个跳过)——这就是主循环里那些提醒的来源。 - 持久化:每次调用后把注册的
Params/State防抖写盘,所以 TodoState 这类东西能跨重启恢复。
和《从 LLM 到 Coding Agent》对照
对照 tool-use:最小实现里,工具派发常是 tools[name](args) 一句话。grok 的增量全在「真实产品的工具集是动态、异质、有状态、要排序、要持久化、还要横切提醒」这些事上。下一章看一个更深的问题:这套派发怎么做到「同一份契约,工具在同进程或跨进程都能跑」。
上一篇:一个工具的完整契约 · 下一篇:工具协议 · 进程内与跨进程