数据截至 (上游 commit 676a0a228882)
工具系统:目录组装、模糊编辑与 Shell 沙箱
30 秒导读: 这一章讲 agent 的「手脚」。模型只会输出一句话——「把文件里这段文本替换成那段」「跑这条命令」——真正把它精确、可靠、可回滚地落到磁盘和终端上,是
internal/tools/这一层的活。最难的三件事:① 模型给的「旧文本」几乎从不和文件一字不差(→ 模糊编辑);② 两次写之间文件可能被人改了(→ 乐观并发);③ 一条命令可能要密码、可能永不退出、可能删库(→ Shell 沙箱)。
本章上游链路见 核心回合循环;权限「允许/审查/拦截」的判定属 安全与权限;tool_search 背后的 MCP 延迟工具属 Provider 与扩展面,本章只把它当作工具组之一。
1. 这是什么(零基础也能懂)
一句话定义: 工具系统是 Whale 暴露给大模型的「一组可调用函数」,以及把模型返回的调用参数安全地执行到真实世界的那套机制。
解决什么问题: 大模型本身只会生成文本。想让它「读某个文件」「改第 42 行」「跑一次测试」,就得给它一批带 JSON schema 的工具;模型按 schema 填参数,Whale 负责校验参数、执行、并把结果再喂回模型。这一层就是模型和你电脑之间的「手」。
它能做什么(功能分组):
| 工具组 | 代表工具 | 干什 么 |
|---|---|---|
| 文件发现 | read_file list_dir load_skill | 读文件 / 列目录 / 载入本地 Skill |
| 搜索 | grep search_files | 按内容(正则)/ 按文件名找 |
| Web | web_search fetch web_fetch | 搜网、抓网页 |
| 请求输入 | request_user_input | 向用户提问并等待 |
| 文件改动 | edit multi_edit write | 改文件(本章重点) |
| Shell | shell_run shell_wait write_stdin shell_cancel | 跑命令 / 等待 / 喂 stdin / 取消 |
| 计划 | update_plan | 更新执行清单 |
| 待办 | todo_add todo_list … | 会话内待办 |
| MCP 检索 | tool_search | 按需激活延迟加载的 MCP 工具 |
用起来什么样: 模型不会「直接调 Go 函数」,它输出一段工具调用 JSON,例如让 edit 把一段代码换掉:
{
"name": "edit",
"input": {
"file_path": "internal/server/http.go",
"search": "port := 8080",
"replace": "port := cfg.Port"
}
}
Whale 收到后:校验参数 → 解析 file_path 是否在允许范围 → 读文件 → 在文件里找 search(找不到还会模糊匹配)→ 替换 → 原子写回 → 把「改了几处 + diff」返回给模型。
一句话直觉: 把工具层想成一个极度谨慎的秘书——模型说的话它照做,但每一步都先核对:这路径我能碰吗?你给我的原文和文件现在的内容对得上吗?这命令会不会卡住或要密码?对不上就不动手,并回一句「这里对不上,你再看看」。
本节不谈实现。目标:知道「工具系统是模型的手,且这只手非常防呆」。
2. 顶层全景(它大概怎么转)
2.1 两层结构:core 定契约,tools 出实现
Whale 把「工具是什么」和「工具怎么做」拆成两层:
internal/core—— 定义契约:一个工具最少长什么样(Tool接口)、注册表怎么存怎么派发(ToolRegistry)、参数怎么校验和修复(tool_input_repair.go)、结果怎么归一成统一信封(normalizeToolContent)。它不认识任何具体工具。internal/tools—— 出实现:Toolset持有工作区根路径、HTTP 客户端、任务表、文件锁等状态,把 30 多个内置工具装进一个个「目录」(catalog)。
2.2 一次工具调用的主线(高层,不进代码)
模型输出 tool call {name, input(JSON)}
│
▼
┌──────────────────────────────┐
│ core.ToolRegistry.Dispatch │ ① 按 name 找到工具 + 冻结的 ToolSpec
│ - validateToolInput │ ② 用 schema 校验参数(缺字段/多字段?)
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ tools.Toolset 的具体 fn │ ③ 真正干活:editFile / shellRun / …
│ - safePath 路径守卫 │ 先问「这路径能碰吗」
│ - 读文件 / 起进程 │
│ - commitFilePlans 原子落盘 │ 乐观并发校验后写
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ core.normalizeToolContent │ ④ 把结果套进统一信封 + 截断超长输出
└──────────────┬───────────────┘
▼
结果 ModelText 回到模型
「怎么读这张图」:从上到下是一次调用的时间顺序;①② 在 core 层,③ 在 tools 层,④ 又回 core 层收口。校验在前、执行居中、归一在后——工具作者只写 ③,前后由框架统一保证。
2.3 目录组装:Tools() 把 9 组拼成一份清单
Toolset.Tools() 只是把 9 个分组各自的 []core.Tool 顺序拼接起来——catalog.go:7-19 里一目了然:
真实代码 internal/tools/catalog.go:9-18(Tools):
tools = append(tools, b.fileDiscoveryTools()...)
tools = append(tools, b.searchTools()...)
tools = append(tools, b.webTools()...)
tools = append(tools, b.requestInputTools()...)
tools = append(tools, b.fileMutationTools()...)
tools = append(tools, b.shellTools()...)
tools = append(tools, b.planRuntimeTools()...)
tools = append(tools, b.todoRuntimeTools()...)
tools = append(tools, b.mcpSearchTools()...)
每个 xxxTools() 返回一批 toolFn(见 catalog_files.go、catalog_shell.go 等)。最后一组 mcpSearchTools() 只有在配置了延迟 MCP 目录时才非空(catalog_mcp.go:37-44,mcpSearchTools)——这是 06 章的话题。
部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
Toolset | 持有工作区状态,产出所有内置工具 | internal/tools/toolset.go:25 Toolset |
toolFn | 一个工具的具体定义(名字/schema/函数) | internal/tools/tool_fn.go:9 toolFn |
core.Tool | 工具的最小契约(只要 Name+Run) | internal/core/tool.go:8 Tool |
ToolRegistry | 存工具、冻结 spec、派发调用 | internal/core/tool_registry.go:19 ToolRegistry |
| 路径守卫 | 判断路径能否读/写 | toolset.go:296 safePath / :304 safeReadPath |
| 编辑引擎 | search/replace + 模糊匹配 | internal/tools/edit.go:147 resolveEditSearch |
| 落盘 | 乐观并发 + 原子写 | internal/tools/file_mutation.go:76 commitFilePlans |
| Shell | 进程会话管理 | internal/tools/shell.go:14 shellRun |
| 执行边界 | 每次 exec 前查策略 | internal/execboundary/ |
3. 核心机制(逐个,由浅入深)
3.1 一个工具是怎么「定义」的:鸭子类型 + 冻结 spec
要解决的小问题: core.Tool 接口故意只要求两个方法——Name() 和 Run()(core/tool.go:8-11)。可一个真实工具还需要描述、JSON schema、是否只读、需要什么能力……这些怎么带上?
思路: 用 Go 的可选接口(鸭子类型)。工具可以「顺便」实现 ToolDescriber、ToolParamSpec、ToolReadOnly 等接口;DescribeTool 逐个类型断言,实现了就取值,没实现就用默认。
真实代码 internal/core/tool.go:93-97(DescribeTool):
if d, ok := t.(ToolDescriber); ok {
if v := d.Description(); v != "" {
spec.Description = v
}
}
内置工具全用一个结构体 toolFn 一次性实现所有可选接口(tool_fn.go:20-40)。所以定义一个工具就是填一个 struct 字面量——名字、描述、parameters(JSON schema)、readOnly、capabilities、fn。看 read_file 的定义 catalog_files.go:20-36,或 edit 的定义 catalog_files.go:73-90,都是这个套路。
冻结(freeze): 注册时 ToolRegistry 调 DescribeTool 把每个工具的 spec 算好、存进 specs map(tool_registry.go:97-102,replaceToolsLocked)。之后对外发出的工具是 frozenSpecTool 包装(tool_registry.go:209),Name()/Parameters() 等一律读冻结快照而非每次重算——保证同一轮里工具面貌稳定、schema 不抖动。
派发: 模型发来调用 → DispatchWithProgress(tool_registry.go:309)按 name 找工具和 spec,先校验参数再执行:
真实代码 internal/core/tool_registry.go:332-340(DispatchWithProgress 内):
if hasSpec {
if err := validateToolInput(spec.Parameters, call.Input); err != nil {
return normalizeRegistryResult(ctx, call, ToolResult{
...
ModelText: invalidToolInputContent(call.Name, spec.Parameters, err),
Code: "invalid_input",
}, ...), nil
}
}
validateToolInput(tool_registry.go:734)做两件事:必填字段在不在、additionalProperties:false 时有没有多余字段。有个体贴的例外——description 字段永远放行(tool_registry.go:766,reservedDescriptionField),因为别的 harness 训出来的模型爱在每个 tool call 上挂个人读标签,为此拒绝调用太蠢。
3.2 参数「修复」:模型填错形状也能救
要解决的小问题: 模型经常把参数形状填错——该给数组给了字符串、该给布尔给了 "true"、可选字段填了 null、把路径写成 Markdown 链接 [x](http://x)