第 6 章:工具系统——从 Schema 到副作用
模型看到的是 JSON Schema 和工具名,Rust 实现看到的是强类型参数,SessionActor 看到的是动态调用与进度事件。Grok Build 用多层抽象把三种视角接起来。
6.1 工具调用的六层
对应 crate:
| 层 | Crate |
|---|---|
| 规范工具/任务数据 | xai-tool-types |
| JSON-RPC wire contract | xai-tool-protocol |
| Tool trait 与动态运行时 | xai-tool-runtime |
| Grok Build 具体工具与注册 | xai-grok-tools |
| 会话级调用编排 | xai-grok-shell |
6.2 类型化 Tool
xai-tool-runtime/src/tool.rs 约 36 行定义 Tool trait。每个实现关联:
Args:可反序列化、可生成 schema 的参数;Output:可序列化、可渲染的结果;- 稳定 id、描述、capabilities;
run或可产生进度的execute。
这种设计让工具作者在实现内部使用 Rust 类型,而不是手工访问任意 JSON:
trait Tool {
type Args;
type Output;
fn id(&self) -> ToolId;
async fn execute(&self, args: Self::Args, ctx: ToolContext)
-> ToolStream<Self::Output>;
}2
3
4
5
6
7
8
这是概念化摘录,字段与签名以源码为准。
6.3 ToolDyn:动态世界的边界
模型直到运行时才决定调用哪个工具,因此 registry 不能保存异构的 Tool<Args, Output> 泛型集合。ToolDyn 在约 306 行完成 type erasure:
- 根据注册工具找到具体实现;
- 把 JSON arguments 反序列化为具体
Args; - 调用类型化工具;
- 把
Output序列化成统一值和模型可见文本; - 保留 display/chat completion 等不同表示。
这条边界很值得复用:
动态输入只在系统边缘存在;一进入实现就恢复强类型;输出离开实现时再统一。
6.4 流式结果不变量
ToolStream<T> 规定事件序列为:
[Progress, Progress, ...] + exactly one TerminalToolProgress 可包含文本、结构化 content 或 custom payload。Terminal 则明确成功/失败结果。
“恰好一个 terminal”很重要:
- UI 可知道何时关闭 spinner;
- SessionActor 可持久化一次最终结果;
- 取消路径也必须产生终态;
- 不会把最后一条 progress 误当完成。
6.5 Registry 不是一个 HashMap
xai-grok-tools/src/registry/types.rs 中 ToolRegistryBuilder 位于约 528 行。new 会登记多种 Grok Build、Codex、OpenCode、memory/search/use 等工具和 reminders。
Builder 随后依据 ToolServerConfig:
- 选择启用的工具子集;
- 重命名客户端可见工具名;
- 重命名参数;
- 选择 behavior version;
- 替换/渲染描述;
- 校验 requirements 与互斥组合;
- 注册或移除动态 MCP 工具。
finalize 产出不可变的 FinalizedToolset,其中包含:
- client name ↔ internal kind 映射;
- schema/definition;
- 类型化 dispatch;
- template renderer;
- tool resources;
- reminder 能力。
先 build 再 finalize,使配置错误在模型看到工具前暴露。
6.6 Registry 校验保护什么
校验至少覆盖:
- 配置引用未知工具;
- behavior version 不存在;
- client-visible name 冲突;
- 标准工具与 hashline/concise 变体不兼容;
- requirements 表达式不满足;
- 参数映射/描述模板不完整。
如果不做这一步,最糟糕的失败不是启动报错,而是模型依据错误 schema 产生调用,直到运行时才发现无法 dispatch。
6.7 ToolBridge 是会话侧门面
ToolBridge 包装 FinalizedToolset,向 SessionActor 提供:
- 当前工具 definitions;
- client name 与 internal kind 查询;
- prompt template 渲染;
- JSON 参数解析和调用;
- AGENTS/skills 相关状态;
- 动态 MCP 注册;
- 独立 terminal handle。
Terminal 被单独持有是一个取消安全细节:终端任务可能有自己的生命周期,不能因为替换普通工具集合就失去终止后台进程的能力。
6.8 Agent 定义如何裁剪工具
xai-grok-agent/src/config.rs 定义多套 agent/toolset preset,例如:
grok-build;grok-build-concise;grok-build-plan;codex;explore;plan;grok-computer。
典型 Grok Build 基础集合包含 bash、read、search/replace、list、grep、task、todo、scheduler、monitor、search/use、update_goal、workflow 等;workspace/context 再决定 write、plan、ask、web、image/video、memory、LSP 等能力。
Explore/Plan 不是仅靠提示词声称“只读”,它们实际得到不同工具集合或执行 gate。这比软指令更可信。
6.9 AgentBuilder:静态定义变成会话能力
Builder 组合:
- agent definition/persona;
- session cwd 与环境;
- allowed/disallowed tools;
- 任务依赖、goal/workflow gate;
- subagent 能力;
- model/effort 参数;
- MCP/plugin/skill 资源;
- prompt context。
最终:
- 构建并 finalize ToolBridge;
- seed persistence/规则/技能/gitignore 状态;
- 渲染 PromptContext;
- 得到可用于 session 的 Agent。
所以“某工具为什么没出现”需要沿 config → agent preset → builder gate → registry finalize → dynamic MCP 五层排查。
6.10 会话执行工具的真实顺序
acp_session_impl/tool_calls.rs 中的 execute_tool_calls 约 284 行。其策略不是“所有调用直接 join”:
阶段一:按模型顺序预检
每个调用先顺序完成:
- 注册 pending UI update;
- 确保 MCP 初始化状态;
- 规范化并解析 arguments;
- 查找 ToolBridge 中的工具;
- 应用 Plan mode gate;
- pre-tool Hook;
- 权限请求;
- plan approval interception;
- 形成
PreparedToolCall。
顺序预检让权限对话和拒绝语义保持可预测。
阶段二:执行已批准调用
批准的调用进入 FuturesUnordered,可以按实际完成顺序产生结果。这样读取多个独立文件或并发搜索不会被不必要串行化。
阶段三:合并完成与后处理
每个完成结果需要:
- 对回原 tool call slot/id;
- 发送 UI progress/terminal;
- 运行 post success/failure Hook;
- 提取技能提醒或 deferred follow-up;
- 持久化结果;
- drain interjection。
6.11 为什么“预检顺序、执行并发”
如果预检也完全并发:
- 用户可能同时看到多个权限框;
- 后一调用先获批并产生副作用,前一调用随后被拒;
- Hook 顺序难以解释;
- 一个拒绝后无法一致地取消后续调用。
如果执行完全串行:
- 多文件读取、搜索、独立 MCP 查询浪费延迟;
- 模型一次并行发出工具的价值消失。
Grok Build 选择了一个有意的折中:决策串行,副作用在已批准集合内并发。
6.12 同文件锁
多个工具可并发,并不代表它们可以同时改同一文件。执行层使用按路径锁:
- 不同文件可并行;
- 相同目标路径串行;
- 锁粒度比全 workspace mutex 更小;
- 结果仍按 tool call id 归属。
这无法替代文件版本/冲突检测,但能减少同一批工具调用内部的直接写竞争。
6.13 参数恢复边界
模型偶尔会输出拼接的多个 JSON object。解析路径会尝试寻找与当前工具 schema 匹配的对象,作为有限恢复。
恢复必须有边界:
- 只能在能唯一解释时继续;
- 不能把任意损坏 JSON 当成安全参数;
- 权限检查使用的必须是最终解析后的 canonical 参数;
- 展示、Hook 与实际执行应观察同一份参数。
6.14 拒绝为何变成 ToolResult
当权限或 Hook 拒绝调用,SessionActor通常构造一个模型可见结果,而不是让整个进程抛错:
model: call bash("dangerous ...")
system: permission rejected
conversation: ToolResult(call_id, rejection explanation)
model: choose a safer alternative or ask the user2
3
4
这保留 Agent 的自我修正能力。但在同一批中,拒绝/取消之后的未执行调用会转成 cancellation result,避免模型以为它们悄悄成功。
6.15 共享认证恢复
一批并发工具若同时发现认证过期,不应各自触发登录恢复。执行路径用共享 OnceCell/结果,让第一条恢复流程被其他调用复用。
这是并发系统中的 single-flight 模式:多个消费者等待同一个昂贵且有副作用的修复动作。
6.16 工具、Reminder 与 Hosted Tool 的区别
| 概念 | 谁执行 | 模型怎样看到 |
|---|---|---|
| 本地 Tool | Tool Runtime/Workspace | 普通 function tool |
| MCP Tool | 外部 MCP server,经 bridge | 动态 function tool |
| Hosted Tool | 模型供应商后端 | hosted_tools 与 backend events |
| Reminder | Session/Tool subsystem 注入 | synthetic context,不是可调用函数 |
把 reminder 注册在工具体系附近,是因为许多提醒由工具状态触发,但它本身不会被模型调用。
6.17 源码阅读卡
| 目的 | 文件 |
|---|---|
| Tool/ToolDyn/ToolStream | xai-tool-runtime/src/tool.rs |
| Tool registry/finalize | xai-grok-tools/src/registry/types.rs |
| Agent preset | xai-grok-agent/src/config.rs |
| AgentBuilder | xai-grok-agent/src/builder.rs |
| Session 预检与执行 | tool_calls.rs |
| Task/subagent 工具 | task/mod.rs |
| 工具 reminders | reminders/mod.rs |
6.18 本章结论
工具系统不是一个 function map,而是一条受控管线:
preset/config 选择能力,registry 校验并冻结 schema,ToolBridge 建立动态门面,ToolDyn 恢复强类型,SessionActor 顺序预检、并发执行,Workspace 承担真实副作用。
下一章继续沿副作用向下,拆解权限策略、Workspace 双模式、文件回退和 OS 沙箱。