第 4 章 · 01 SPEC §3.2 Tool interface 与 Registry 本节摘要:本节精读 SPEC §3.2 的 Tool 抽象。Tool 是一个五方法 interface( / / / / ),与 Provider 同构但多了"参数 JSON Schema"与"只读标记"两个 LLM 工具特有概念。注册表分两层:进程全局 map(由 在 填充)+ 每次运行 实例(由 enabled 内置 + 插件工具组装,Agent 只看 )。
本节摘要:本节精读 SPEC §3.2 的 Tool 抽象。Tool 是一个五方法 interface(
Name/Description/Schema/Execute/ReadOnly),与 Provider 同构但多了"参数 JSON Schema"与"只读标记"两个 LLM 工具特有概念。注册表分两层:进程全局builtinsmap(由tool.RegisterBuiltin(t)在init()填充)+ 每次运行*Registry实例(由 enabled 内置 + 插件工具组装,Agent 只看*Registry)。本节讲清这两层的关系、Add时 schema 一次性规范化(保证每轮Schemas()字节稳定以保 prefix cache)、docs/TOOL_CONTRACT.md工具契约文档与代码契约测试的双重锁定、工具参数用santhosh-tekuri/jsonschema/v6校验、以及"工具 schema 变化会破坏 prefix cache"这条关键约束。
内容来源:原项目源码
internal/tool/tool.go(Tool interface + Registry + RegisterBuiltin)、internal/tool/builtin/*.go(各工具 init 注册)、docs/SPEC.md§3.2、docs/TOOL_CONTRACT.md。
⚠️ 注意:工具 schema 进入系统提示前缀,因此schema 变化会破坏 prefix cache。这就是为什么
Registry.Add时要一次性CanonicalizeSchema,之后每轮Schemas()都复用规范化结果,绝不在热路径重新 marshal。
阅读完本节,你应当能够:
builtins map 与每次运行 *Registry 实例。Registry.Add 为何要 CanonicalizeSchema。Schemas() 为什么按 name 排序输出。TOOL_CONTRACT.md 与 TestBuiltinToolContractDocumentation 测试如何双重锁定工具表面。SPEC §3.2 给出 Tool interface(docs/SPEC.md:110-116,对应 internal/tool/tool.go:19-33):
type Tool interface { Name() string Description() string Schema() json.RawMessage // JSON Schema for parameters Execute(ctx context.Context, args json.RawMessage) (string, error) ReadOnly() bool }
五个方法:
Name() string:工具名(如 "bash"、"read_file"),模型在 tool call 里引用。Description() string:工具描述,进入系统提示,影响模型何时调用它。bash 工具的 Description 还会根据当前 shell(bash 还是 PowerShell)动态生成不同文本(internal/tool/builtin/bash.go:103-120),这是少数会变的描述。Schema() json.RawMessage:参数的 JSON Schema,模型据此生成合法参数。Execute(ctx, args) (string, error):执行工具,返回结果文本喂回模型。SPEC §3.2(docs/SPEC.md:126-127):Execute 自己解析 raw JSON args;错误返回而非致命——Agent 把错误喂回模型,让模型自我纠正。ReadOnly() bool:工具是否有可观察的副作用。Agent 只在 batch 里所有调用都 ReadOnly 时才并行化;混合 batch 保持顺序,保 write/read 顺序。bash 与插件工具必须返回 false,因为副作用无法从参数静态推断。与 Provider 比,Tool 多了 Schema(参数契约)、Description(模型可见说明)、ReadOnly(并行调度依据)——这是 LLM 工具调用的特有需求。Tool 没有 Factory——因为工具是无状态的(每次调用独立),不需要从 Config 构造;直接 RegisterBuiltin(t) 注册实例即可。
SPEC §3.2(docs/SPEC.md:118-122)描述了两层结构:
Built-in tools self-register into a process-global builtin set via
init()(tool.RegisterBuiltin(t));tool.Builtins()lists them. A runtime*Registryis assembled per run: enabled built-ins (filtered by config) plus plugin-provided tools. The agent only sees the*Registry.
两层:
第一层:进程全局 builtins(internal/tool/tool.go:237-267):
var builtins = map[string]Tool{} func RegisterBuiltin(t Tool) { name := t.Name() if _, dup := builtins[name]; dup { panic("tool: duplicate built-in " + name) } builtins[name] = t } func Builtins() []Tool { ... } // 按 name 排序返回 func LookupBuiltin(name string) (Tool, bool) { ... }
与 Provider 的 Register 同构:全局 map、init() 调用、重复注册 panic。区别是没有 Factory——直接注册 Tool 实例。
第二层:每次运行 *Registry(internal/tool/tool.go:271-303):
type Registry struct { mu sync.RWMutex tools map[string]Tool order []string canon map[string]json.RawMessage suspended map[string]bool } func (r *Registry) Add(t Tool) { r.mu.Lock() defer r.mu.Unlock() name := t.Name() // ... suspended 前缀检查 ... if _, ok := r.tools[name]; !ok { r.order = append(r.order, name) } r.tools[name] = t r.canon[name] = provider.CanonicalizeSchema(t.Schema()) // 关键:一次性规范化 }
*Registry 是每次运行新建的:把 config 里 enabled 的内置工具(过滤后的 Builtins())加上插件提供的工具(MCP 适配器)全部 Add 进去。Agent 只看 *Registry,看不到全局 builtins。
为什么分两层?因为:
init() 填充)。SuspendPrefix 移除其命名空间,重连时 ResumePrefix)。Registry.Add 里这一行是 prefix cache 友好的核心(internal/tool/tool.go:302):
r.canon[name] = provider.CanonicalizeSchema(t.Schema())
工具 schema 注册时就一次性规范化(排序 key、统一格式),之后存进 canon map。每轮 Schemas() 直接复用 canon 里的字节,绝不在热路径重新 marshal。
为什么这么重要?SPEC §3.2(docs/SPEC.md:123-125):
Tool schemas are canonicalized on registry insertion. The built-in contract is documented in
TOOL_CONTRACT.mdand backed by tests that compare the documented surface against the same canonical schema path.
工具 schema 进入系统提示前缀(模型每轮都看到全部工具定义)。如果 schema 字节每轮抖动(比如 map key 顺序随机),prefix cache 就会反复 miss——这是 Reasonix 最不能容忍的事。规范化的本质是把"语义相同但字节不同"的 schema 归一化成唯一字节序列,保证 prefix 稳定。
Schemas() 输出按 name 排序(internal/tool/tool.go:523-544):
func (r *Registry) Schemas() []provider.ToolSchema { r.mu.RLock() defer r.mu.RUnlock() names := make([]string, len(r.order)) copy(names, r.order) sort.Strings(names) // 按 name 排序 out := make([]provider.ToolSchema, 0, len(names)) for _, name := range names { t := r.tools[name] if t == nil { continue } out = append(out, provider.ToolSchema{ Name: t.Name(), Description: t.Description(), Parameters: r.canon[name], // 复用规范化结果 }) } return out }
排序的目的也是 prefix 稳定——工具顺序固定,前缀字节才稳定。
💡 契约要点:工具 schema 是 prefix cache 敏感路径。三道保险:①
Add时CanonicalizeSchema;② 存进canon复用;③Schemas()按 name 排序。改工具 schema 是高影响动作,PR 必须带Cache-impact:元数据(见 REASONIX.md)。
internal/tool/builtin/ 下每个工具文件都有一个 init() 调用 tool.RegisterBuiltin。完整清单(从源码 grep):
| 文件 | init 注册 | 工具名 | 只读 |
|---|---|---|---|
bash.go:34 |
bash{} |
bash |
false |
bgjobs.go:24-26 |
bashOutput{}/killShell{}/waitJob{} |
bash_output/kill_shell/wait |
true/false/true |
codeindex.go:20 |
codeIndex{} |
code_index |
true |
completestep.go:16 |
completeStep{} |
complete_step |
true |
glob.go:21 |
globTool{} |
glob |
true |
grep.go:64 |
grepTool{} |
grep |
true |
ls.go:14 |
listDir{} |
ls |
true |
readfile.go:27 |
readFile{} |
read_file |
true |
editfile.go:11 |
editFile{} |
edit_file |
false |
multiedit.go:11 |
multiEdit{} |
multi_edit |
false |
writefile.go:14 |
writeFile{} |
write_file |
false |
movefile.go:16 |
moveFile{} |
move_file |
false |
delete_range.go:14 |
deleteRange{} |
delete_range |
false |
delete_symbol.go:18 |
deleteSymbol{} |
delete_symbol |
false |
notebookedit.go:14 |
notebookEdit{} |
notebook_edit |
false |
todo.go:13 |
todoWrite{} |
todo_write |
true |
webfetch.go:26 |
webFetch{} |
web_fetch |
true |
updategoal.go:12 |
updateGoal{} |
update_goal |
true |
规律:读类工具(read_file/grep/glob/ls/code_index/web_fetch/bash_output/wait)都是 true;写类工具(write_file/edit_file/multi_edit/move_file/delete_*/notebook_edit/bash/kill_shell)都是 false。bash 必须false,因为命令副作用无法从参数静态推断(一句 rm -rf 与一句 ls 参数结构类似)。
这些工具的具体实现是后续两节(bash 第 2 节、codeindex 第 3 节)的主题,以及第 5 章 Agent 会话用工具时再展开。
docs/TOOL_CONTRACT.md 是工具表面契约文档,顶部有张表列出每个工具的名字、只读标记、描述。例如 bash 行:
|
bash| false | Execute a command in the shell and return combined stdout/stderr. Use for builds, tests, git, package managers, etc. ... |
关键在文档末尾(docs/TOOL_CONTRACT.md Schema Snapshot 段):
The exact canonical schemas are intentionally tested in code rather than copied by hand here. Run:
go test ./internal/tool -run TestBuiltinToolContractDocumentationThe test checks that every registered built-in tool has a documented name, read-only flag, description row, and canonical schema generated by
tool.BuiltinContractEntries.
这是双重锁定:文档表 + 代码测试。改工具表面(改名、改只读、改描述、改 schema)必须同步改文档,否则 TestBuiltinToolContractDocumentation 失败。这把"工具表面稳定"从口头约定变成 CI 强制——又是 SPEC "Change the contract first, then the code" 的具体兑现。
工具描述进系统提示,影响模型调用决策,也影响 prefix cache(描述字节稳定才 cache 命中)。所以改工具描述同样是高影响动作。
工具参数用 santhosh-tekuri/jsonschema/v6 校验(满足 SPEC §1.3 纯 Go 轻量依赖)。校验发生在两处:
CanonicalizeSchema 顺带做结构校验,保证 schema 本身合法。Execute 自己解析 raw JSON args。SPEC §3.2(docs/SPEC.md:126-127):
Executeparses raw JSON args itself. Errors are returned, not fatal — the agent feeds them back so the model can self-correct.
工具自己解析参数,错误返回而非致命——Agent 把错误文本喂回模型,模型下一轮会修正参数重试。这种"错误可恢复"设计让工具调用更鲁棒。
jsonschema 校验的好处:模型生成的参数若违反 schema(类型错、缺必填、枚举越界),工具层能给出精确错误信息(哪个字段、什么问题),模型据此修正——比"参数错误"这种模糊提示有效得多。
Name/Description/Schema/Execute/ReadOnly 五方法;无 Factory(工具无状态,直接注册实例)。builtins(init 填充,RegisterBuiltin 重复 panic)+ 每次运行 *Registry(enabled 内置 + 插件,Agent 只看这个)。Registry.Add 一次性规范化 schema 存进 canon,Schemas() 复用——保证 prefix cache 友好。TestBuiltinToolContractDocumentation 测试;改工具表面必须同步改文档。santhosh-tekuri/jsonschema/v6(纯 Go);工具 Execute 自己解析 args,错误返回喂回模型自我纠正。下一节,我们精读 bash 工具——它不是直接 system 调用,而是用
mvdan.cc/shv3 做 POSIX shell 语法树分析,这是 Reasonix 安全与可控执行的关键设计。