# 一个工具的完整契约 · 两个 trait 一个 struct

> 进 grok 那近 15 万行的工具系统。这一章先立地基：在 grok 里「一个工具」到底是什么——一个类型上挂两个 trait（运行时契约 Tool + grok 元数据 ToolMetadata），它怎么声明 schema、怎么校验参数与配置、怎么以流的形式执行。

- 作者：David（道雾轩）
- 专栏：Grok Build 源码解读（https://daiw.org/manual/grok-build.md）
- 最后更新：2026-09-15
- 原文：https://daiw.org/manual/grok-build/tool-contract
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

# 一个工具的完整契约 · 两个 trait 一个 struct

Part 2 是全书最大的一部——工具系统 `xai-grok-tools` 如今已近 15 万行。但别慌，它有清晰的骨架。这一章先回答最基本的问题：**在 grok 里，「一个工具」是什么？**

## 一个工具 = 两个 trait 挂在一个 struct 上

grok 把「工具」拆成两个正交的 trait，一个类型同时实现它俩：

- **`Tool`**(通用运行时契约，`common/xai-tool-runtime/src/tool.rs`):`type Args`(参数)、`type Output`(输出)、`execute()`(流式执行)/`run()`(阻塞执行)。这是「任何工具运行时都认」的契约。
- **`ToolMetadata`**(grok 专属元数据，`types/tool_metadata.rs`):`kind()`、`tool_namespace()`、`description_template()`。这是「grok 这个 harness 额外要的」信息。

这个拆分很关键：`Tool` 是可跨 harness 复用的纯契约，`ToolMetadata` 才把它接进 grok 的世界。

## ToolKind 与 ToolNamespace：工具的两个坐标

每个工具有两个分类坐标：

- **`ToolKind`**(37 个变体，近来新增了 `Workflow`、`Feedback` 等)：`Read`、`Edit`、`Search`、`Execute`、`Plan`、`WebSearch`……**语义类别**。[system prompt](https://daiw.org/manual/grok-build/system-prompt)里 `${{ tools.by_kind.read }}` 引的就是它。`ToolKind` 的 JSON schema 故意是**开放字符串**(不是封闭枚举)+ `#[serde(other)] Other`——这样加新 kind 不会让老消费者崩。
- **`ToolNamespace`**:`GrokBuild` / `GrokBuildConcise` / `GrokBuildHashline` / `Codex` / `OpenCode` / `MCP`——工具**来自哪套**。同一个 kind(比如 Edit)在不同 namespace 下有不同实现(见[三种编辑格式](https://daiw.org/manual/grok-build/edit-formats)与[和别家 agent 互通](https://daiw.org/manual/grok-build/agent-interop))。

## schema：从类型自动生成

工具怎么告诉模型「我要什么参数」？靠 Rust 的类型系统：`T::Args` 上 `#[derive(JsonSchema)]`(schemars)，注册时 `generate_schema_cached::<T::Args>()`(按类型缓存的 `generate_schema`)自动生成 JSON schema。**参数结构即文档**——你改 `Args` struct，模型看到的 schema 跟着变，不用手写两份。

模型是个「不太靠谱的调用方」，所以派发时先把 JSON 反序列化进类型化的 `T::Args`(`ToolDyn::execute` 里的 `serde_json::from_value`)——类型不对、缺字段，直接变成一个 `invalid_arguments` 错误回给模型。

还有一层贴心：**宽容反序列化**(`schema.rs`)——模型爱把数字写成字符串、把整数写成浮点，grok 的 `deserialize_lenient_*` 把这些都容错掉，而不是直接拒绝。

**两段校验**(`types/params_validation.rs`)管的则是另一类参数——客户端下发给每个工具的**配置**(存成 `Params<P>`)：

1. **结构校验**：`serde_path_to_error` 把 JSON 反序列化进配置类型——类型不对、缺字段，这一步就报错(还能报出具体字段路径)。
2. **语义校验**：再调该类型的 `validate_params_value` 做业务规则校验。

## 执行：流是第一等公民

工具怎么跑？`Tool::execute` 返回一个 **`ToolStream<Output>`**，不变式是 `[Progress*, Terminal]`——零或多个进度事件，最后**恰好一个**终止结果(`Result<Output, ToolError>`)。阻塞式工具只要实现 `run()`，就自动得到一个「单终止项」的流。

为什么工具的原语是「流」而不是「函数」？因为跑一条 shell 命令、读一个大文件、调一次外部 API，都可能要**边跑边报进度**。把流当原语，进度上报就不是特例而是常态。

<Callout type="info">
  输出侧 `ToolOutput` 是个 `#[serde(tag="type")]` 的大枚举，每个内置工具一个变体，配 `derive_more::From`——编译期就保证「每种输出都被处理」。它有两个方法：`is_error()`(给遥测分类)和 `to_prompt_format()`(转成喂回模型的文本)。**同一个结果，一份给机器(JSON)、一份给模型(文本)**，是贯穿工具系统的分野。
</Callout>

## 和《从 LLM 到 Coding Agent》对照

对照那本的 [tool-abstraction](https://daiw.org/manual/llm-to-agent/tool-abstraction)：最小实现里，一个工具常是「一个函数 + 一份手写 JSON schema」。grok 的增量是把它做成**类型驱动**(schema 从 `Args` 自动生成)、**双 trait**(通用契约 + harness 元数据)、**流式**(进度是常态)、**两段校验 + 宽容输入**的完整契约。下一章看这些工具怎么被注册、排序、派发。

---

上一篇：[打断与防失控](https://daiw.org/manual/grok-build/interjection-doom-loop) · 下一篇：[工具注册与分发](https://daiw.org/manual/grok-build/tool-registry)
