本章目标:深入理解 Tool System——Harness 中连接模型与真实世界的桥梁。掌握工具的本质、Schema 设计、发现/注册/调度/执行的完整链路,以及错误处理、超时、权限、组合等关键机制。
7.1 Tool 的本质
Tool(工具)的本质,用一句话概括:
工具是模型可以请求调用的、由 Harness 实际执行的、带结构化接口的外部函数。
这句话里有三个关键词,每个都值得展开:
- “模型可以请求调用”:模型不是”自动调用”工具,而是”请求”调用。模型输出一个工具调用(Tool Calling)请求——工具名 + 参数——运行时框架(Harness)决定是否执行、如何执行。
- “由 Harness 实际执行”:模型本身不执行工具,它只生成一串描述”我想调用什么”的 token。真正读写文件、执行命令的,是 Harness 里的函数实现。这个”模型提议、系统执行”的分离,是 Agent 安全架构的基石。
- “带结构化接口”:工具通过 JSON Schema 定义接口,模型根据这个接口生成结构化的参数。接口的清晰度,直接决定模型使用工具的正确率。
一个更本质的理解:工具是模型能力的”边界”。模型能做多少事,完全取决于 Harness 给它提供了多少工具。一个没有 Bash 工具的 Coding Agent,无论如何也无法执行命令。所以,设计工具,本质上是设计 Agent 的能力边界。
这个”边界”理解有一个重要推论:工具的取舍,就是能力的取舍。你给了 Agent 一个”删除文件”的工具,就给了它”删除文件”的能力,也给了它”误删文件”的风险。所以工具设计不仅是”加能力”,更是”做权衡”——每加一个工具,都要问”这个能力值得对应的风险吗?”。
7.2 Tool Schema
Tool Schema(工具模式)是工具对模型暴露的接口契约,通常包含三个部分:
- name(名称):工具的唯一标识,模型用它来指定调用哪个工具。
- description(描述):用自然语言说明工具做什么、何时用、有何限制。这是模型”决定是否调用”的依据。
- input_schema(参数模式):JSON Schema 定义参数的结构,模型据此生成参数。
一个完整的工具定义示例:
{
name: "Read",
description: "读取指定路径的文件内容。返回文件的完整文本。用于查看代码、配置、文档。",
input_schema: {
type: "object",
properties: {
file_path: { type: "string", description: "要读取的文件的绝对路径" },
offset: { type: "number", description: "从第几行开始读(1 起,可选)" },
limit: { type: "number", description: "最多读多少行(可选)" }
},
required: ["file_path"]
}
}
这里有一个接口的定义形态与接口的传输形态的区别。JSON Schema 是模型看到的形态——请求的 tools 字段里就是它。但在 Claude Code 源码里,工具的作者并不直接写 JSON Schema:inputSchema 是一个 Zod 模式,JSON Schema 只在发往模型 API 的边界上由框架转换出来。让工具作者写类型安全的东西,让框架去做不讨好的序列化——这是”接口与实现分离”在 Schema 层的又一次体现(7.4 还会看到同一个思想的另一面)。
Schema 设计的质量,直接决定模型使用工具的正确率。一个 description 模糊的工具(如”做某事”),模型必然频繁误用;一个参数描述清晰的工具,模型几乎不会用错。
Schema 设计的黄金法则:把工具的 description 和参数 description 当成”写给一个聪明但完全不了解你系统的新同事看的 API 文档”来写。
几个具体的 Schema 设计要点:
- description 要回答三个问题:这个工具做什么?什么时候该用?有什么限制或副作用?
- 参数描述要避免歧义:单位(秒还是毫秒)、时区、枚举值、默认值,都要说清楚。
- required 要精确:必填参数标 required,可选参数给默认值说明。
- 避免”万能工具”:一个参数巨多、职责模糊的”万能工具”,不如几个职责单一的小工具好用。
7.3 Tool Discovery
Tool Discovery(工具发现)回答的问题是:模型怎么知道有哪些工具可用?
答案很简单:把工具定义注入上下文。在每次请求模型前,Harness 把所有可用工具的名称、描述、参数 Schema 作为请求的一部分发给模型(通常放在 System Prompt 或专门的 tools 字段里)。模型”读到”这些定义后,就知道自己有什么”手和脚”。
但这里有一个重要的工程权衡:工具越多,注入上下文的 token 越多。
假设你有 100 个工具,每个工具的定义平均 200 token,那么每次请求光工具定义就消耗 2 万 token。这对于上下文是巨大的负担。
解决方案是按需发现(Lazy Discovery),常见有几种做法:
- 分组注入:把工具按类别分组,只注入当前可能用到的组。
- 动态检索:维护一个工具索引,根据当前任务的关键词,检索并注入最相关的工具定义。
- 渐进发现:先注入一份精简的”工具目录”,当模型表示需要某类工具时,再注入完整定义。
Claude Code 采用的是混合策略,且实现得比想象中更”吝啬”:常用核心工具(Read/Write/Edit/Bash 等)始终注入完整定义;大量”长尾”工具(各种 MCP 工具、专用工具)被标记为延迟(deferred)——它们在上下文里只以名称出现,连一句描述都没有,模型必须先调用 ToolSearch 工具检索,才能拿到它的参数 Schema,也才能调用它。第三种做法(分组注入)在这里更多体现为按需注入:部分工具只在特定功能被启用时才出现,例如 Worktree 相关工具仅在 worktree 场景下注入。
ToolSearch 的查询支持三种形态,本身就说明了”检索什么样的工具定义”是个需要设计的接口:
"select:Read,Edit,Grep" 按名精确取
"notebook jupyter" 关键词检索,返回最匹配的若干项
"+slack send" 名称必须含 slack,其余词参与排序
值得琢磨的是那份”目录”为什么只有名字、没有描述。给每个工具配一句简介,看起来对模型更友好,但那是要按工具数量线性付费的 token——几百个 MCP 工具的简介加起来就是一笔固定开销,而绝大多数工具在一次会话里根本不会被用到。Claude Code 的取舍是:目录只保证”模型知道自己有什么”,不保证”模型理解它”——理解的成本留给真正需要它的那一次 ToolSearch。源码里还留有一条旁证:曾试过为每个工具加一段检索提示(searchHint),但 A/B 试验判断无收益后停止了——在该版本中,检索主要靠工具名称本身,这也反过来要求工具命名必须”自解释”。
延迟的动机也值得说清楚:MCP 工具默认延迟,首要原因不是省 token,而是”工作流相关”——一个接入 Slack 的 MCP 服务器,对正在改代码的 Agent 毫无用处,它的 schema 出现在每一轮请求里既是浪费也是干扰。所以 MCP 还留了一个反选开关:服务器可以声明 alwaysLoad,强制自己的工具出现在初始 prompt 里。
这个”核心常驻 + 长尾按需”的策略,是解决”工具多 vs 上下文有限”矛盾的成熟方案。
7.4 Tool Registry
Tool Registry(工具注册表)是工具系统的”总目录”——集中管理所有工具的元信息(名称、描述、Schema、实现函数的引用)。
一个简单的 Tool Registry 实现:
interface ToolDefinition {
name: string;
description: string;
inputSchema: Record<string, unknown>;
execute: (input: unknown) => Promise<ToolResult>; // 实现函数
}
class ToolRegistry {
private tools = new Map<string, ToolDefinition>();
register(tool: ToolDefinition) {
this.tools.set(tool.name, tool);
}
get(name: string): ToolDefinition | undefined {
return this.tools.get(name);
}
list(): ToolDefinition[] {
return [...this.tools.values()];
}
// 生成注入上下文的工具定义(不含实现函数)
// 内部统一 camelCase,仅在发给模型 API 时转成 input_schema
getSchemas() {
return this.list().map(({ name, description, inputSchema }) => ({
name,
description,
input_schema: inputSchema, // API 边界:camelCase → snake_case
}));
}
}Registry 的价值在于集中管理 + 统一注入:所有工具在一个地方注册,统一生成注入上下文的 Schema,统一查找执行函数。这让工具系统”可扩展”——新增一个工具,只需 register 一次。
在 Claude Code 中,这一步还额外承担授权职责:getTools(permissionContext) 会按 deny 规则在生成阶段就裁掉被禁用的工具。被裁掉的工具对模型不存在,而不是”存在但调用时被拒”——两种做法对模型行为的影响完全不同:前者让模型根本不会去尝试,后者会诱导模型反复试探。更进一步的裁剪发生在执行上下文维度(7.12)。
注意 getSchemas() 的一个细节:它只返回”给模型看的接口”,不含”实现函数”。这个”接口与实现分离”是工具注册表的核心设计——模型只需要知道”接口”,实现是 Harness 自己的事。
Claude Code 的工具注册表在这三点上更进一步:
- 不是所有工具都静态注册。 内置工具是静态的,MCP 工具则来自运行时状态,在每次组装请求时才按当前连接的服务器过滤注入(
src/tools.ts)。服务器断开,工具就消失——注册表是”组装出来的”,而非一张写死的表。 - 权限元数据随工具携带。 每个工具自带
isReadOnly/isDestructive等声明(7.10),框架不必读实现就能做授权与调度判断。 - 工具集按上下文裁剪。 同一个注册表,喂给子智能体与喂给主线程的内容并不相同(7.12)。
7.5 Tool Dispatcher
Tool Dispatcher(工具调度器)回答的问题是:模型请求调用的工具,怎么路由到正确的实现?
Dispatcher 的核心逻辑其实很简单——根据工具名查找实现:
async function dispatchToolCall(toolCall: ToolCall, registry: ToolRegistry) {
const tool = registry.get(toolCall.name);
if (!tool) {
return { is_error: true, content: `未知工具: ${toolCall.name}` };
}
return tool.execute(toolCall.input);
}
但真实世界的 Dispatcher 远不止查找这么简单,它还要处理:
- 参数校验:执行前用 Schema 校验参数,参数错误时返回清晰的错误而非让执行函数崩溃。
- 权限检查:执行前检查权限,未授权的工具调用被拦截。
- 并发调度:多个工具调用可以并行执行(独立的)或串行执行(有依赖的)。
- 超时控制:为工具执行设置超时。
Claude Code 的 runTools(src/services/tools/toolOrchestration.ts)实现了这些。它的调度判据不是调用之间的逻辑依赖,而是每次调用自身的 isConcurrencySafe(input) 谓词:
- 安全批:连续的并发安全调用被攒成一批,用
runToolsConcurrently并行执行,并发上限默认为 10(可用环境变量CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY调整)。 - 不安全批:非并发安全的调用单独成批,用
runToolsSerially串行执行。
这个”按谓词而非按依赖”的选择,是一条重要的架构判断:并发真正的风险不是”逻辑依赖”,而是”资源冲突”。只有当一次调用对系统状态只读时,才谈得上安全并发——这是保守的充分条件,而不是精确的依赖分析。谓词本身还被 try/catch 包裹,抛异常时保守地判为”不安全”。
更值得注意的是,这个谓词是按每次调用计算的,而不是按工具固定:
| 工具 | isConcurrencySafe 实现 | 效果 |
|---|---|---|
Read / Grep / Glob / WebFetch / WebSearch | 恒返回 true | 只读,可并行 |
Edit / Write | 未定义,走 buildTool 默认值 false | 恒串行 |
Bash | 返回 this.isReadOnly(input) | 逐条命令动态判定 |
Read 永远只读,所以永远可以并行;Edit / Write 永远会改文件,所以永远串行;而 Bash 是按命令内容判定的——ls、git status 这类只读命令可以和别的只读工具并行,rm、git push 则必须独占。把”这次调用安全吗”做成一个逐调用的谓词,而不是给工具贴一个静态标签,是工具调度层最简明的正确性设计。
权限检查与执行路径:每次调用都经由 CanUseToolFn 检查后才执行(checkPermissionsAndCallTool,src/services/tools/toolExecution.ts)。
Dispatcher 是工具系统的”指挥中心”——它把”模型的意图”精确地路由到”正确的执行”,并在路由过程中完成校验、权限、并发、超时等所有”控制”动作。
7.6 Tool Executor
Tool Executor(工具执行器)是工具的实际实现——真正去读文件、执行命令、查数据库的那段代码。
Executor 的设计要点:
- 单一职责:每个 Executor 只做一件事,做好一件事。
- 清晰的返回结构:统一的结果结构(内容 + 错误标记 + 可选的附加信息),便于上层统一处理——包括把结果交给模型、把错误标记出来、更新会话状态。
- 副作用隔离:有副作用的操作(写文件、执行命令)必须走权限检查,不能绕过。
- 资源管理:正确管理文件句柄、网络连接、子进程等资源的创建和释放。
一个 Read 工具的 Executor 示例:
async function executeReadFile(
input: { file_path: string }
): Promise<{ content: string; is_error: boolean }> {
try {
const content = await fs.readFile(input.file_path, "utf-8");
return { content, is_error: false };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { content: `读取文件失败: ${message}`, is_error: true };
}
}
注意这个 Executor 的一个关键设计:它不抛异常,而是把错误作为返回值。这样上层循环就不会因为一个工具失败而崩溃,而是把错误作为”结果”交给模型,让模型决定下一步(第 3 章的 Tool Error 处理)。
这个”不抛异常”的设计是 Agent 工具系统与普通函数库的关键区别:普通函数库抛异常让调用方处理,Agent 工具系统把错误转化为结果让模型处理。
值得注意的是,这条约定并不依赖每个 Executor 自觉遵守:执行器之上还有一层兜底 try/catch(src/services/tools/toolExecution.ts),把漏出的异常统一转换成 is_error: true 的工具结果。架构不变量要由框架强制,而不是靠约定——这才是它可靠的原因。真实源码里这两层是并存的:Executor 尽力把可预期的失败(文件不存在、命令非零退出)转成结果,框架兜住不可预期的崩溃。
7.7 Tool Result
Tool Result(工具结果)是 Executor 返回、最终呈现给模型的内容,也是模型继续推理的依据。它的设计直接影响 Agent 的决策质量。
工具结果的最佳实践:
- 结构化:返回 JSON 而非自由文本,便于模型解析。
- 精炼:返回”有用的信息”,而非”全部信息”。搜索工具返回”3 个关键匹配及上下文”,而非”300 个匹配的全部内容”。
- 带错误语义:用
is_error明确标记失败,让模型知道”这是错误,不是正常结果”。这里要区分两层结构:Executor 交给 Harness 的是一个内部结果对象(含数据、附带消息、上下文修改器等字段);Harness 再把它转换为模型真正看到的tool_result内容块(content+is_error)。只有内容进上下文,控制字段不进。工具结果的”模型可见面”是被 Harness 精心裁剪过的——模型看到什么、看不到什么,本身就是一个设计决策。 - 大小可控:结果的尺寸管理是上下文侧的责任(第 6.6 节讲了截断、摘要与外部卸载四条策略),但工具有义务配合——例如 Claude Code 要求每个工具声明
maxResultSizeChars,超过阈值的结果由框架落盘,上下文中只留预览与文件路径。工具侧的配合点很朴素:声明清楚”我这个工具的正常输出有多大”。
一个常被忽视的细节:工具结果的”可读性”。模型要”读”这些结果来推理,如果结果格式混乱、噪声大,模型的理解就会出错。因此,工具结果的格式设计,本质上是”给模型看的输出设计”。
一个具体的设计问题:工具结果要不要”自我描述”? 比如,一个搜索工具返回 {"matches": [...], "count": 42, "truncated": true},还是返回一段带上下文的自然语言描述?答案是:结构化数据 + 必要的上下文。纯数据(count: 42)缺乏上下文——它没说是否被截断、是否已按相关性排序;纯文本难以解析。两者的结合最好。
7.8 Tool Error
工具执行失败是常态,不是例外。文件不存在、命令报错、网络超时、权限不足……都是常见失败。
工具错误的处理策略(第 3 章已提及,这里深化):
- 错误作为结果返回(而非抛出异常):让模型看到错误、理解错误、决定对策。这是 Agent Self-correction(自我纠正)能力的基础。
- 错误信息要”可操作”:好的错误信息告诉模型”发生了什么、为什么、怎么办”。比如”文件不存在:
src/foo.ts。可能路径拼写错误,建议用 Glob 工具查找正确路径”,而不是冷冰冰的”Error: ENOENT”。 - 错误分类:区分”可重试错误”(网络超时)和”不可重试错误”(文件不存在,重试也白费),让模型和系统做出不同应对。
“可操作的错误信息”值得展开。它是”Self-correction”能否成功的决定性因素。如果错误信息只说”失败了”,模型只能瞎猜原因;如果错误信息说”文件 src/foo.ts 不存在,建议用 Glob 查正确路径”,模型就能精准地走下一步。好的错误信息,是在”教”模型怎么修。
7.9 Tool Timeout
工具执行可能”卡死”——一个命令挂起不返回、一个网络请求无限等待。如果没有超时控制,整个 Agent 会被一个工具卡死。
超时设计要点:
- 分类超时:不同工具设不同超时。读文件应该秒级返回,执行测试可能分钟级,编译大型项目可能更久。
- 超时后的处理:超时不是简单地”杀掉”——要返回一个清晰的超时错误给模型,让模型决定是重试、换策略还是放弃。
- 可中断性:对于长命令(如运行一个服务器),需要支持”超时但进程继续在后台跑”或”超时并强制终止”两种模式。
第 1 条”分类超时”在 Claude Code 里有具体数字(以 2026-03-31 版本为准):Bash 命令的默认超时是 2 分钟,上限 10 分钟,都可用环境变量覆盖。这个”默认 2 分钟”是刻意保守的——它假定绝大多数命令都该很快返回,真需要更久的命令必须由调用方(也就是模型)自己声明 timeout 参数。把”这次要等多久”交给发起方判断,比让框架用一个统一的长超时更精确,也避免了”一个卡住的命令拖住整个 Agent”。
Claude Code 对长命令还有专门处理:它支持 run_in_background 参数让命令在后台运行,Agent 可以先做别的事,完成后会收到通知。更值得注意的是,系统还会在命令超时后自动把它转入后台——模型并没有主动要求,框架就替它做了这个决定,因为”杀死一个可能还在正常工作的长任务”往往是更糟的选择。
“后台执行 + 稍后检查”是一个重要的模式:它突破了”工具调用是同步阻塞”的假设。对于”启动一个开发服务器”这类”永不返回”的命令,同步等待永远超时;而后台执行让 Agent 能”启动服务器 → 做别的事 → 再回来验证”。
7.10 Tool Permission
工具权限是 Tool System 与 Permission 系统(第 11 章)的交汇点。核心思想:不是所有工具都能随便调用,危险工具需要权限控制。
四类工具(只读 / 写 / 执行 / 网络)的风险分级及其对应的审批强度,在第 11 章展开。这里只强调 Tool System 侧的那件事——检查点落在流水线的哪个位置:
模型请求工具 → Dispatcher 路由 → 【权限检查】 → Executor 执行 → Result 返回
权限检查位于 Dispatcher 与 Executor 之间,是”模型的意图”变成”真实副作用”的唯一闸门。它必须在 Executor 之前,且 Executor 不能绕过它——这是第 11 章所有权限策略得以生效的前提。位置错了,再完备的策略也只是文档。
顺带一提,工具身上还不止这一种标记:isReadOnly(只读)、isDestructive(不可逆)、isConcurrencySafe(可并发,7.5)等等。它们的共同点是——把”这个工具会不会惹麻烦”从执行代码里提取成声明式元数据,让框架可以在不读实现的情况下做出授权与调度决策。这是工具系统能被”治理”的原因。
7.11 Tool Composition
Tool Composition(工具组合)是”把简单工具组合成复杂能力”的思想。单个工具的能力有限,但组合起来可以完成复杂任务。
组合的两种形式:
- 模型驱动的组合:模型在循环中依次调用多个工具,完成一个复合任务(如”先 Glob 找文件 → 再 Read 读内容 → 再 Edit 修改 → 再 Bash 测试”)。这是 Agent Loop 天然支持的组合。
- 系统定义的高阶工具:Harness 把一组常见操作封装成”复合工具”。Claude Code 里有两种典型形态:
Skill把一段预置指令封装成可调用的能力;ToolSearch更特殊——用工具去发现工具,把”检索工具目录 + 按需装载 Schema”本身变成一次工具调用(7.3)。
组合的价值在于抽象层次的提升:模型不必每次都”手写”一系列低级操作,而是可以直接调用一个高阶工具完成整件事。这既提高效率,也降低出错率。
而要论组合的最高形态,还是”用 Agent 当工具”(Agent as a Tool)——把”委派一个子智能体”封装成一个工具调用。Claude Code 中承担这个角色的工具现在叫 Agent(早期版本名为 Task,为兼容旧的权限规则与已恢复会话而保留为别名)。这模糊了”工具”和”Agent”的边界,是多智能体架构的入口(第 30 章 Subagent Architecture 系统展开)。
7.12 Built-in Tools
Built-in Tools(内置工具)是一个 Coding Agent Harness 必备的核心工具集。Claude Code 源码 src/tools/ 下共 40 个工具目录,本节按四个层级介绍其中最关键的部分。
先明确一件事:源码目录名不等于模型侧的工具名。src/tools/FileReadTool/ 里导出的工具叫 Read,src/tools/WebSearchTool/ 导出的是 WebSearch。下文一律使用模型侧工具名——那才是模型实际看到、并在调用时使用的名字:
- 基础(读、写、搜、跑):
Read、Write、Edit、Glob、Grep、Bash - 任务与委派:
TodoWrite、Agent、AskUserQuestion - 发现与扩展:
ToolSearch、Skill、NotebookEdit、WebFetch、WebSearch、LSP - 协作与模式:
SendMessage、TaskCreate、TaskStop、EnterPlanMode
下面对每个工具给出四件事:类别与实现位置、输入 Schema、作用、值得注意的设计亮点。Schema 均取自源码原文(2026-03-31 版本),只保留模型可见的字段。
7.12.1 基础六件套
这六个工具覆盖 Coding Agent 的核心需求:读、写、搜、跑。它们同时也是 Part IV 的 Mini Claude Code 要实现的目标清单。
Read|文件读取|src/tools/FileReadTool/
{
file_path: string, // 要读取的文件的绝对路径(required)
offset?: number, // 从第几行开始读(1 起,可选)
limit?: number, // 最多读多少行(可选)
pages?: string, // PDF 专用取页范围,如 "1-5"、"3"、"10-20"
}作用:读取文件内容。file_path 必须是绝对路径。
亮点:
- 它不是一个”纯文本读取器”。除文本外,还支持图片、PDF、Jupyter Notebook——返回结构是一个按
type区分的联合类型(文本 / 图片 base64 / PDF / Notebook)。模型”看”的能力和”读”的能力,在同一个工具里统一了。 offset/limit是”逃生舱”,不是常规分页。它们的 describe 写着 “Only provide if the file is too large to read at once”——工具在自己的说明里主动劝退无谓的分页调用。告诉模型”什么时候不该用这个参数”,和告诉它”这个参数是什么”同样重要。- 它是唯一一个把结果大小上限设为
Infinity的工具。这看起来反直觉——”无限”怎么会是安全设计?源码注释给出了理由:Read 的输出如果落盘,就会产生”Read 文件 → 落盘 → 再 Read 读回来”的循环,所以它必须自己限制自己(靠行数上限),而不是交给通用的落盘机制。 - “文件未变化”提示:重复读取同一个未修改的文件时,工具返回一句”文件自上次读取以来未变化,请参考此前的结果”,而不是把内容再发一遍。这是对上下文的主动节省。
Write|文件写入|src/tools/FileWriteTool/
{
file_path: string, // 要写入的文件的绝对路径(required,必须绝对,不能相对)
content: string, // 写入的内容(required)
}作用:写入文件,文件不存在则创建。
亮点:
- 两个字段,没有第三个。连”是否覆盖”这样的参数都不存在——覆盖是默认行为,框架用别的方式(权限与校验)来兜底,而不是靠加参数。
- 它的输出 Schema 比输入丰富得多:返回
type: 'create' | 'update'、structuredPatch(差异补丁)、originalFile、gitDiff。这些不进模型上下文,而是供给 UI 展示、检查点与撤销。输入极简、输出详尽,是工具 schema 设计的一个常见模式:对模型的要求越少越好,给框架的信息越多越好。 - 因为它是”整体重写”,所以改文件时更推荐
Edit(见下)。
Edit|精确编辑|src/tools/FileEditTool/
{
file_path: string, // 要修改的文件的绝对路径(required)
old_string: string, // 将被替换的原文(required)
new_string: string, // 替换后的新文本(required,必须与 old_string 不同)
replace_all?: boolean, // 是否替换全部匹配项(默认 false)
}作用:在文件中做精确的字符串替换。
亮点:
new_string的 describe 里写死了校验规则:”must be different from old_string”。Schema 的描述本身就在承担”防止无意义调用”的职责——模型读到这句话,就不太会提交一个没有变化的编辑。replace_all默认false,这是安全默认值:默认只改一处,改动范围可预期。- 它能容忍”引号风格差异”:当文件里是弯引号而模型给出直引号时,工具会做归一化匹配(
utils.ts里有专门处理)。这是把模型常见的”手误”在工具层吸收掉,而不是让模型付出一次失败重试的代价。 - 精确编辑优于整体重写:
Edit比Write更高效(只传改动部分)、更安全(误伤范围小)、更省 token。以Edit为主、Write为辅,是 Coding Agent 成熟度的标志。
Glob|文件搜索|src/tools/GlobTool/
{
pattern: string, // glob 模式,如 "**/*.ts"(required)
path?: string, // 搜索的目录,省略则用当前工作目录
}作用:按文件名模式查找文件。
亮点:
path的 describe 里写了一段异常具体的叮嘱:”IMPORTANT: Omit this field to use the default directory. DO NOT enterundefinedornull— simply omit it”。如果不是真的观察到模型反复写"undefined"字符串,不会有这句提示。工具描述是被真实失败案例塑形的,这句话本身就是一份工程记录。- 结果有硬上限(100 个文件),并返回
truncated标记——把”结果被截断了”这一事实显式告诉模型,让模型自己决定是否缩小范围,而不是让它以为看到的就是全部。
Grep|内容搜索|src/tools/GrepTool/
{
pattern: string, // 正则表达式(required)
path?: string, // 搜索路径
glob?: string, // 按 glob 过滤文件,如 "*.{ts,tsx}"
output_mode?: 'content' | 'files_with_matches' | 'count', // 默认 files_with_matches
'-B'?: number, // 匹配行之前的上下文行数(仅 content 模式)
'-A'?: number, // 匹配行之后的上下文行数
'-C'?: number, // 上下文行数(-A/-B 的别名)
context?: number,
'-n'?: boolean, // 显示行号
'-i'?: boolean, // 忽略大小写
type?: string, // 按文件类型过滤
head_limit?: number, // 结果数量上限
offset?: number, // 结果偏移
multiline?: boolean, // 是否允许多行匹配
}作用:在文件内容中按正则搜索。
亮点:
- 参数名直接用 ripgrep 的命令行旗标(
-A/-B/-C/-n/-i)——让模型把已有的 rg 知识直接迁移过来,这比重新发明一套语义化命名更省描述、更少歧义。 - 默认模式是
files_with_matches而非content。默认值的选择就体现了上下文意识:先给文件名,模型再决定打开哪个——两步走比一次性倾倒所有匹配行更省上下文。 head_limit/offset提供分页能力,multiline让跨行模式成为可能。这两个参数的存在说明:搜索工具的结果必须可裁剪,否则一个宽泛的正则就能吃掉整个上下文预算。
Bash|命令执行|src/tools/BashTool/
{
command: string, // 要执行的命令(required)
description?: string, // 用主动语态清晰描述这条命令做什么
timeout?: number, // 超时毫秒数(上限 10 分钟)
run_in_background?: boolean, // 后台运行,完成时通知
dangerouslyDisableSandbox?: boolean, // 关闭沙箱执行(字段名自带警告)
}
作用:执行 shell 命令。这是能力最强、也最危险的工具。
亮点(这个工具的 schema 值得逐条读):
description是一个”给非技术读者看的说明”字段,它的 describe 用了大量篇幅给出正反例,并明确禁止使用”complex”、”risk”这类词——因为这个词会出现在权限提示里给用户看。ls要写成 “List files in current directory”,而不是 “Run complex shell command”。工具 schema 里混着一条写作规范,因为这里是人机交互的界面。_simulatedSedEdit字段被显式地从模型可见的 schema 里剔除(.omit())。源码注释解释了原因:这个字段由权限组件在用户批准 sed 编辑预览后填充,如果暴露给模型,模型就能”用一个无害命令 + 一次任意文件写入”来绕过权限检查与沙箱。这是本书里”Schema 即安全边界”最直白的一个例子。sleep被列入不允许自动转后台的命令——”Sleep should run in foreground unless explicitly backgrounded by user”。这类细节无法从架构图推出来,只能从被真实行为教育过的源码里读到。run_in_background字段本身是条件注入的:后台任务被禁用时,这个字段会从 schema 里直接移除(.omit())。模型看不到它,就不会去尝试它——能力门控做在 schema 层,而不是靠提示词劝阻。dangerouslyDisableSandbox暴露了一处真实张力。这个字段名自带警告,而工具的提示词在它周围立了一套相当细致的规矩(受allowUnsandboxedCommands门控):- 默认:始终在沙箱内执行,不要设置这个字段;
- 例外:仅当出现沙箱限制导致失败的证据时——提示词列了一张清单(”Operation not permitted” 错误、特定路径访问被拒、对非白名单主机的连接失败等)——才重试;
- 它特意提醒模型排除误判:命令失败的原因很多(文件不存在、参数错误、网络问题),不要一股脑归咎于沙箱;
- 并且逐条判定:即使刚用过这个开关,下一条命令仍然要默认回到沙箱内。
7.12.2 任务与委派
TodoWrite|任务清单|src/tools/TodoWriteTool/
{
todos: Array<{
content: string, // 任务描述(不能为空)
status: 'pending' | 'in_progress' | 'completed',
activeForm: string, // 进行中时的表述,不能为空
}>,
}作用:维护结构化任务清单。
亮点:
- 每次提交整份清单,而不是增量修补——没有
add/remove/update之类的命令式字段。这使操作幂等,也天然可恢复:模型不需要记住”我上次改到哪了”,框架拿到的永远是完整状态。 activeForm强制模型从”过程视角”描述任务:不是”读取配置”,而是”正在读取配置”。这个字段只服务于 UI——让用户看到 Agent 此刻在做什么。一个纯粹为人而存在、模型自身并不需要的字段。- 输出里的
verificationNudgeNeeded:当所有任务都标记完成、而清单里却没有一项与”验证”相关时,这个标记会被置位,框架据此自动提醒模型”你是不是该跑一下测试”。“过早停止”这个 Agent 的经典失效模式,在这里被做成了一个可检测的信号。 - 它的输出同时返回
oldTodos与newTodos——框架据此能计算出”本次变更了什么”,用于 UI 与状态追踪。
Agent|子智能体|src/tools/AgentTool/
{
description: string, // 任务的简短描述(3-5 个词)(required)
prompt: string, // 交给子智能体执行的任务(required)
subagent_type?: string, // 子智能体类型
model?: 'sonnet' | 'opus' | 'haiku', // 模型覆盖
run_in_background?: boolean, // 后台运行,完成时通知
name?: string, // 给子智能体命名,使其可被 SendMessage 寻址
team_name?: string, // 团队上下文
mode?: string, // 权限模式,如 "plan"
isolation?: 'worktree' | 'remote', // 隔离运行:临时 git worktree / 远程环境
cwd?: string, // 运行目录
}作用:委派一个子智能体执行任务,返回其结论。这是”用 Agent 当工具”(7.11)的具体实现,是多智能体架构的入口。
亮点:
- 它把”上下文隔离”做成了一个参数:
isolation: "worktree"会创建一个临时 git worktree,让子智能体在仓库的独立副本上工作。这是”并行 Agent 不互相踩踏”的工程解法——不靠约定,靠文件系统层面的隔离。 - schema 级的能力门控:
run_in_background、cwd等字段会依据特性开关与环境变量被.omit()掉。源码注释同时说明了为什么用.omit()而不是条件展开——后者会破坏 Zod 的类型推断。这类”实现细节的坑”往往决定了架构能否保持类型安全。 subagent_type决定了子智能体拿到哪些工具:探索型、验证型、通用型各有自己的工具集与模型。这正是 7.12.5 要讲的”工具集随上下文裁剪”的入口。run_in_background让委派变成非阻塞:主线程可以继续做事,子智能体完成时收到通知。这是把”并发”从工具调用层(7.5)提升到了智能体层。
AskUserQuestion|交互提问|src/tools/AskUserQuestionTool/
{
questions: Array<{
question: string, // 完整的问题,以问号结尾
header: string, // 极短标签(如 "Auth method"),显示为选项组标题
options: Array<{
label: string, // 选项显示文本(1-5 个词)
description: string, // 该选项的含义或后果
preview?: string, // 聚焦时展示的预览内容(如代码片段、界面草图)
}>, // 必须 2-4 个选项
multiSelect: boolean, // 是否允许多选(默认 false)
}>, // 1-4 个问题
}作用:向用户提问,获取澄清或让用户在方案间做选择。
亮点:
- 它的 Schema 就是一份 UI 规格。
header的长度上限、options的数量上限(2-4)、questions的数量上限(1-4),全部编码在 Schema 里——模型不需要”知道”该问几个问题,它根本问不出第五个。 - 没有 “Other” 选项,因为”其他”是自动提供的——describe 明确告诉模型”不要自己造一个 Other 选项”。把不变的部分从模型手里拿走,是减少调用错误最有效的手段之一。
preview字段把”选项比较”升级成了可视化比较:模型可以给出代码片段、ASCII 布局草图、配置示例,让用户在看得见结果的情况下选择。这是”人机协作”里少见的、把交互质量做进 schema 的例子。- Schema 层还带了一条业务规则校验:问题之间必须互不重复(一个
.refine()检查)。校验逻辑可以住在 Schema 里,而不必等执行时才报错。 - 最后,它证明了”人”可以被建模成一种工具:当任务信息不足时,模型不是瞎猜,而是调用这个工具向用户澄清。这是”人机协作”在工具层面的体现。
7.12.3 工具发现与扩展
ToolSearch|工具发现|src/tools/ToolSearchTool/
{
query: string, // "select:<tool_name>" 直接选取,或关键词检索
max_results?: number, // 返回上限(默认 5)
}作用:检索被延迟(deferred)的工具,把它们的完整 Schema 装载进上下文,使它们变得可调用。
亮点:这是一个”用工具发现工具”的元工具——7.3 已详细讨论。这里只强调它的存在意义:工具集合本身成了可以被查询的对象。当工具数量大到无法一次性注入上下文时,”发现工具的能力”就必须先成为一种工具。
Skill|技能调用|src/tools/SkillTool/
{
skill: string, // 技能名,如 "commit"、"review-pr"、"pdf"
args?: string, // 传给技能的参数
}作用:调用一个预定义的技能。Skill 把”一段预置的指令 + 工具许可”封装成一个原子能力——模型调用的不是一个函数,而是一套工作流。
亮点:它的输出 Schema 里带着 allowedTools——技能不仅能”告诉模型怎么做”,还能声明”这段时间里你可以用哪些工具”。这是”能力封装”走得更远的一步:连权限范围都可随技能切换。
NotebookEdit|Notebook 编辑|src/tools/NotebookEditTool/
{
notebook_path: string, // Notebook 文件的绝对路径(required)
cell_id?: string, // 目标单元格 ID(插入时表示插在它之后)
new_source: string, // 单元格的新内容(required)
cell_type?: 'code' | 'markdown',
edit_mode?: 'replace' | 'insert' | 'delete', // 默认 replace
}作用:编辑 Jupyter Notebook 的单元格。
亮点:它没有复用 Write / Edit。Notebook 是 JSON 结构而非纯文本,用字符串替换去改它既脆弱又容易破坏格式——一个通用的字符串编辑器,解决不了一个结构化编辑器的问题。这是”工具粒度应当匹配数据粒度”的例证。
WebFetch|网页抓取|src/tools/WebFetchTool/
{
url: string, // 要抓取内容的 URL(required)
prompt: string, // 对抓取到的内容运行的提示词(required)
}作用:抓取网页,并用指定提示词对内容做处理,返回处理结果。
亮点:它抓取的内容不直接进上下文——模型看到的是”应用 prompt 之后的结果”,而不是原始 HTML。把”取回”和”理解”合并成一次调用,避免一页网页的噪声(导航、脚注、脚本)污染上下文。这也是一个天然的注入防线:原始文本不落地,间接提示词注入的载体就少了一条通路(第 11.13 节)。
WebSearch|网络搜索|src/tools/WebSearchTool/
{
query: string, // 搜索关键词(至少 2 个字符)(required)
allowed_domains?: string[], // 只在这些域名中搜索
blocked_domains?: string[], // 排除这些域名
}作用:执行网络搜索。
亮点:allowed_domains / blocked_domains 让模型自己就能把搜索范围收窄。这对第 11 章的网络权限是一种补充:权限系统控制”能不能上网”,而这两个参数控制”往哪儿看”——把范围控制做成工具的能力,而不只是外部的限制。
7.12.4 协作与模式
这一层工具存在的理由是:Agent 不只需要和世界打交道,还要和自己、和用户、和其他 Agent 打交道。它们中的多数在第 30 章展开,这里看它们的 Schema 各自透露了什么。
LSP|代码智能|src/tools/LSPTool/
{
operation: 'goToDefinition' | 'findReferences' | 'hover' | 'documentSymbol'
| 'workspaceSymbol' | 'goToImplementation'
| 'prepareCallHierarchy' | 'incomingCalls' | 'outgoingCalls', // required
filePath: string, // 文件路径(required)
line: number, // 行号(1 起,与编辑器显示一致)(required)
character: number, // 字符偏移(1 起,与编辑器显示一致)(required)
}作用:通过 Language Server Protocol 获取代码智能信息——跳转定义、查找引用、悬停类型、调出调用层级。
亮点:line / character 的 describe 都特意写明”1 起,与编辑器显示一致“。源码里其实存在两套 Schema——模型侧是一张扁平表,内部还另有一份按 operation 区分的可辨识联合(用于给出更精确的校验错误)——给模型看的和给自己校验用的,可以是两份不同的 Schema。这与 7.2 讲的”用 Zod 定义、发送时才转 JSON Schema”是同一个思路的延伸:接口的每一层受众,都可以有自己的形态。
TaskCreate|任务创建|src/tools/TaskCreateTool/
{
subject: string, // 任务标题(required)
description: string, // 需要做什么(required)
activeForm?: string, // 进行中时在 spinner 里显示的表述,如 "Running tests"
metadata?: object, // 任意附加元数据
}作用:在任务清单中创建一项可被其他 Agent 看见的任务。
亮点:它和 TodoWrite 长得像,但服务的是完全不同的问题。TodoWrite 管的是单个 Agent 自己的执行进度(给人看的);TaskCreate 管的是多智能体协作中的可寻址工作单元——任务能被认领、被查询、被停止。同样是”任务”,一个是私有的执行状态,一个是共享的协调对象,两者不可互相替代。这也解释了它们的可见性为何不同:TaskCreate 只对”进程内队友”开放(在 IN_PROCESS_TEAMMATE_ALLOWED_TOOLS 白名单里),而 TodoWrite 对异步子智能体同样开放——各自跟着自己服务的那件事走(7.12.5)。
TaskStop|停止任务|src/tools/TaskStopTool/
{
task_id?: string, // 要停止的后台任务 ID
shell_id?: string, // 已废弃:请改用 task_id(为兼容旧工具 KillShell 而保留)
}作用:按 ID 停止一个正在运行的后台任务。
亮点:Schema 与运行时的分工在这里体现得很清楚。两个字段在 Schema 里都是可选的,因为工具要向后兼容一个叫 KillShell 的旧工具;但 validateInput 会在运行时强制要求二者至少提供一个,否则报错。“能不能通过校验”由运行时说了算,Schema 只负责描述形状——这是把兼容性的负担从模型(它只需要知道 task_id)转移到框架内部的一个干净做法。
SendMessage|消息发送|src/tools/SendMessageTool/
{
to: string, // 接收方:队友名,或 "*" 广播(required)
message: string | { // 消息内容:纯文本,或结构化消息(required)
type: 'shutdown_request' | 'shutdown_response' | 'plan_approval_response',
... // 各类型自带 request_id / approve / reason 等字段
},
summary?: string, // 5-10 个词的摘要,用于 UI 预览
}作用:向另一个 Agent(或整个团队)发送消息。
亮点:
to字段的描述文本是条件注入的:启用多端点拓扑时,描述会变成”队友名 /*广播 /uds:<socket-path>本地对等体 /bridge:<session-id>远程对等体”。同一份 Schema 在不同部署形态下呈现不同的描述——字段集合不变,但模型对它的理解随环境而变。message是一个联合类型:纯文本,或结构化消息。而结构化消息的三个变体——shutdown 请求、shutdown 响应、计划批准响应——把”协作礼仪”编码成了协议。给队友发一条消息”和”请队友关闭自己”、”批准队友的计划”是三类不同性质的事,让它们共用message: string会丢失全部语义。这是工具 Schema 承载”协议”而非仅”参数”的例子。
EnterPlanMode|模式切换|src/tools/EnterPlanModeTool/
{
// No parameters needed —— 空 Schema
}作用:请求进入计划模式(Plan Mode)——只读探索、先出方案、待用户批准后再动手。
亮点:
- 一个没有参数的工具也是工具。
z.strictObject({}),源码里的注释只有一句”No parameters needed”。工具存在的意义,有时就是”一个需要被记录、被审批的状态转换”,而不是”一次数据处理”。 - 它的启停规则体现了一处对称性设计:在
--channels模式下ExitPlanMode需要终端对话框而被禁用,于是EnterPlanMode也被一并禁用——源码注释写道,”这样计划模式就不会成为一个模型能进、却出不来的陷阱”。能力的开启与关闭必须成对考虑:只禁用出口而不禁用入口,等于制造一个陷阱。
同层还有任务家族的 TaskGet / TaskList / TaskUpdate / TaskOutput,团队家族的 TeamCreate / TeamDelete,以及模式与隔离类的 ExitPlanMode / EnterWorktree / ExitWorktree。它们的设计思想与上述几条一致,此处不再逐一展开(第 30 章系统讲解多智能体架构)。
源码范围说明(重要):本节介绍的工具都有完整实现文件。但该版本源码树中还有一批只有引用、没有实现目录的工具——它们在 src/tools.ts 里被引用,却不存在于 src/tools/ 下,内部逻辑待验证。据实测(src/tools.ts 全文比对 src/tools/ 目录),这批工具共 12 个,且触发机制分两类,不应混为一谈:
- 11 个由
feature()门控 +require()动态加载(引用散布在src/tools.ts:107–134):MonitorTool、SendUserFileTool、PushNotificationTool、SubscribePRTool、OverflowTestTool、CtxInspectTool、TerminalCaptureTool、WebBrowserTool、SnipTool、ListPeersTool、WorkflowTool。 - 1 个由环境变量门控的静态 import:
TungstenTool——它在src/tools.ts:60被静态导入,在:215处由process.env.USER_TYPE === 'ant'决定是否进入工具集。它不是feature()动态加载的那一类。
例如 SendMessage 的提示词反复提到的 ListPeers,就属于第一类。这正是”泄露源码能看到什么”的边界:40 个有实现目录的工具 + 12 个只有引用的工具(第 17 章)。
顺带说明一个容易踩的坑:src/tools/ 下实际有 42 个子目录,但其中 shared/(共享工具模块)与 testing/(测试辅助)不是工具,扣除后才是 40 个——统计工具数量时不能直接数目录。
7.12.5 从工具集看到的三个规律
回顾这些主要的工具,有几条规律值得单独拎出来:
- 能力边界是双层的。一层是”提供了哪些工具”,另一层是”在哪个执行上下文里提供哪些工具”。同一个 Harness,主线程、子智能体、异步智能体、协作队友拿到的工具集并不相同。先说”拿不到什么”。子智能体的禁用清单(
ALL_AGENT_DISALLOWED_TOOLS,src/constants/tools.ts:36-46)包含TaskOutput、ExitPlanMode、EnterPlanMode、TaskStop,以及Agent与AskUserQuestion。前几项的理由在源码注释里写得很直白——TaskOutput是”防止递归”,ExitPlanMode是因为”计划模式是主线程抽象”。后两项则值得注意:Agent只在非内部构建下被禁(源码注释:允许它可”启用嵌套智能体”),而AskUserQuestion的禁用理由源码未直接说明——架构推论:子智能体若反过来向用户提问,就会绕开主线程的人机交互通道,因此把它从子智能体手里拿走是合理的。再说”拿到什么”。协调者模式(coordinator mode)下工具集被收窄到四个:Agent、TaskStop、SendMessage、SyntheticOutput(COORDINATOR_MODE_ALLOWED_TOOLS,src/constants/tools.ts:107-112)。注意第四个SyntheticOutput很容易被漏掉——“协调者只做调度、不产出内容”是一个很自然的直觉,但源码并没有采纳它:协调者仍然需要一个输出通道。 - 门控做在 Schema 层,而不是提示词层。后台任务被禁用时,
run_in_background直接从 schema 里消失;_simulatedSedEdit被.omit()掉以防绕过权限。模型看不到的参数,比”被劝阻的参数”可靠得多——这是本章最重要的工程结论之一。 - Schema 不只是接口契约,还是三样东西的载体:给权限提示用的
description写作规范(Bash)、给用户界面用的字段约束(AskUserQuestion的选项数量)、给安全边界用的字段裁剪(Bash的.omit())。把工具 schema 只当成”参数说明”,就浪费了它一半的用途。
本章小结
本章深入了 Tool System 的完整链路,核心要点:
- 工具的本质:模型可请求调用、Harness 实际执行、带结构化接口的外部函数。工具定义了 Agent 的能力边界。
- 完整链路:Schema(接口契约)→ Discovery(注入上下文)→ Registry(集中注册)→ Dispatcher(路由调度)→ Executor(实际执行)→ Result(结果返回)。
- 健壮性三件套:错误处理(错误作为结果返回)、超时控制(分类超时)、权限检查(危险工具需审批)。
- 声明式元数据:工具用
isReadOnly/isConcurrencySafe/isDestructive等谓词把自己的副作用讲清楚,框架据此做出并发与授权决策——并发安全是按每次调用判定的,不是给工具贴的静态标签。 - 设计哲学:Schema 是给模型的 API 文档;结果是给模型的输出设计;权限是 Agent 安全的关键防线。
工具是 Agent 的”手和脚”。但工具要靠模型自己想到去用——而有一类能力,我们希望人一敲就能触发,不必等模型判断。下一章我们看 Command(斜杠命令)系统:它是把一段操作固化成可复用入口的机制,也是”能力的三种扩展形态”中的第一种。
更多内容:
《AI Agent 架构设计与工程实践指南》——从 LLM、Agent、Harness 到 Mini Claude Code – 编程开物