工具系统 Tool 接口
Agent 的"手脚"。这一页贴 Tool.ts、api.ts 的真实源码逐段讲:一个工具必须实现什么、buildTool 的安全默认值、以及"发给模型的说明用 prompt() 不是 description()"这个反直觉点。昨天(Day 06)读透了循环这颗"大脑",今天讲它指挥的"手脚",Day 08 再拆几把具体工具,Day 09 讲工具动手前的"签字"。
工具解决哪 3 件事
Day 01 说 Agent = 模型 + 工具 + 循环。工具就是"模型能调用去改变世界的能力"——读文件、写文件、跑命令、搜代码、上网。模型自己只会吐文字,是"工具"让它能真正动手。
回忆 Day 03/06:模型在回复里吐一个 tool_use 块("我要调 Read,参数是这个路径")→ 系统执行对应工具 → 把结果作为 tool_result 回给模型。所以"工具"在代码里要解决三件事:
② 权限:怎么判断"这次调用能不能执行"(读文件放行、删库要问)。
③ 执行:怎么真去干活、并返回结果。
{ name:"Read", input:{ file_path:"a.ts" } }(① 定义/schema让它知道有 Read、要传 file_path)→ 系统检查"读 a.ts 允许吗?"(② 权限:工作目录内 → 放行)
→ 真去读文件,返回内容(③ 执行)
→ 内容作为
tool_result 回喂给模型。本页就是把这三件事在源码里各自的落点讲清。今天就顺着这三件事读源码。核心文件:接口定义 src/Tool.ts(802 行)、工具实现在 packages/builtin-tools/(Day 08 精读)、暴露给模型的转换在 src/utils/api.ts。
两层接口:协议层 vs 宿主层(最容易懵)
工具系统有两层定义,先搞清,否则读代码会晕:
① 协议层 CoreTool(宿主无关)
"纯类型 + 零运行时依赖"的最小内核接口。只约定工具的本质:name、inputSchema、call、权限。不关心 React/终端 UI。
② 宿主层 Tool(Claude Code 实际用)
在协议层之上,加了一大堆 UI 渲染方法(怎么在终端里画这个工具的调用/结果)。宿主工具"结构上满足"协议层接口。
packages/agent-tools(协议层)零依赖,谁都能用——子代理、SDK、测试。src/Tool.ts(宿主层)才绑定终端 UI。好比"USB 标准"(协议)和"某台电脑上的 USB 口"(宿主):标准定了插头长什么样,具体电脑再实现怎么点亮指示灯。你写业务逻辑只关心协议层;写终端渲染才碰宿主层。Day 12 会看到 MCP 外部工具也是实现这个协议层,所以能和内置工具无缝并列。权限三态:allow / deny / passthrough(真实类型)
工具的权限检查返回三种可能之一(packages/agent-tools/src/types.ts:87,真实类型):
export type PermissionResult =
| { behavior: 'allow'; updatedInput: Record<string, unknown> } // 放行(可改写入参)
| { behavior: 'deny'; message: string } // 拒绝(附原因)
| { behavior: 'passthrough' } // 交给通用权限系统裁决
type X = A | B | C 是 TypeScript 的"联合类型"——意思是"X 的值要么是 A 形状、要么是 B、要么是 C"。这里:权限结果要么放行(allow,还能顺手改一下入参)、要么拒绝(deny,带上给模型看的原因)、要么弃权(passthrough,交给 Day 09 的通用权限系统统一裁决)。passthrough = "我不表态,你按通用规则判吧"——然后 Day 09 的权限系统用统一的模式/规则来决定。只有少数工具(如 Read 的"工作目录内放行"、Bash 的 AST 命令解析)才自己给明确的 allow/deny。三态让"工具自定义"和"统一裁决"能共存。一个工具要实现什么
宿主层 Tool(src/Tool.ts:372 起)的核心契约。对着 L01 的三件事看:
| 字段/方法 | 属于哪件事 | 作用 |
|---|---|---|
name | ①定义 | 工具名(模型看到的) |
inputSchema | ①定义 | 输入参数的 zod schema,管校验 + 转成给模型的 JSON Schema |
prompt(options) | ①定义 | ★ 发给模型的工具说明(不是 description!见 L07) |
description(input,opts) | ①定义 | 给 UI/权限弹窗看的简短描述 |
checkPermissions(input,ctx) | ②权限 | 返回 L03 的三态结果 |
isReadOnly / isConcurrencySafe / isDestructive | ②权限/调度 | 行为标志:能否并发、是否只读、是否破坏性 |
call(args, ctx, ...) | ③执行 | ★ 真正干活,返回 ToolResult |
maxResultSizeChars | ③执行 | 结果超这个字符数就落盘、只给模型预览(Day 08) |
mapToolResultToToolResultBlockParam | ③执行 | 把结果映射成 API 的 tool_result 块 |
z.object({ file_path: z.string() }) 声明"输入长这样",它一处声明、两处受益:① 运行时校验模型传来的参数合不合法(乱传就报错);② 自动转成 JSON Schema 发给模型当"参数说明书"。所以工具作者只写一份 schema,校验和文档全有了——这是本工具系统的基石。buildTool:安全默认值(真实代码)
你读工具代码会发现很多工具没写 checkPermissions/isReadOnly——因为它们都通过 buildTool() 导出,工厂帮它们填了"往安全一侧靠"的默认值。这是真实代码(Tool.ts:767):
const TOOL_DEFAULTS = {
isEnabled: () => true,
isConcurrencySafe: (_input?) => false, // ← 默认"不可并发"(保守)
isReadOnly: (_input?) => false, // ← 默认"会写"(保守)
isDestructive: (_input?) => false,
checkPermissions: (input, _ctx) =>
Promise.resolve({ behavior: 'allow', updatedInput: input }), // 默认交给通用权限系统
toAutoClassifierInput: (_input?) => '',
userFacingName: (_input?) => '',
}
export function buildTool<D>(def: D): BuiltTool<D> {
return {
...TOOL_DEFAULTS, // 先铺默认值
userFacingName: () => def.name,
...def, // ← 工具自己写的覆盖默认值
} as BuiltTool<D>
}
{ ...TOOL_DEFAULTS, ...def } 是"对象展开":先把默认值铺一遍,再用工具自己写的 def 覆盖同名字段。所以工具没写的字段 = 用默认;写了的 = 用自己的。() => false 是"箭头函数",等于"一个返回 false 的函数"。isConcurrencySafe: false(不可并发)、isReadOnly: false(会写)。也就是说,一个工具只有主动声明 isReadOnly: () => true 才会被当只读、才能并发跑。忘记声明的后果是"被当成危险的写工具、串行执行"——慢一点,但绝不出事。反过来若默认"只读+可并发",忘声明的写工具就会被误当只读并发跑坏。不确定时选更安全的默认,这就是 fail-closed。所有 60+ 工具都通过 buildTool({...}) 导出。注册与排序:从全部候选到给模型用
工具从"全部候选"到"这次能给模型用的",经过几道过滤(src/tools.ts):
getAllBaseTools()(tools.ts:218):返回当前环境所有可能的工具(受环境变量/feature 门控)
getTools(permissionContext)(tools.ts:304):去掉被整工具 deny 的、被禁用的(isEnabled()===false)
assembleToolPool(tools.ts:378):内置工具 + MCP 工具合并、按名排序、去重(内置优先)
还有个提前过滤 filterToolsByDenyRules(tools.ts:295):在把工具给模型之前就剔除被整工具 deny 的(含 MCP mcp__server 前缀级 deny)——被禁的工具模型根本看不到,而不是"看到了但调用时被拒"。
暴露给模型:prompt() ≠ description()(真实代码)
工具怎么变成 Anthropic API 的 tool 定义?靠 toolToAPISchema(src/utils/api.ts:119)。这是真实代码(api.ts:157):
// schema 二选一:MCP 工具用它原始的 JSON Schema,内置工具用 zod 转
let input_schema = (
'inputJSONSchema' in tool && tool.inputJSONSchema
? tool.inputJSONSchema
: zodToJsonSchema(tool.inputSchema) // ← zod → JSON Schema
)
base = {
name: tool.name,
description: await tool.prompt({ ... }), // ← ★ 用 prompt() 不是 description()!
input_schema,
}
⚠️ 反直觉但极重要:发给模型的工具"描述"字段(description),取自 tool.prompt(),不是 tool.description()!description() 是给界面/权限弹窗看的(简短);prompt() 才是给模型看的(详细的使用说明,教模型什么时候、怎么用这个工具)。想知道"模型看到的工具说明长啥样",去读工具的 prompt() 方法。
prompt() → 给 AI 看(详细、教它用);description() → 给人看(简短、UI 显示)。延迟加载工具:有些不常用的工具(Artifact/LSP/NotebookEdit)标了 shouldDefer: true,schema 不发给模型(省 prompt 空间),模型要用时先调 SearchExtraTools 发现、再 ExecuteExtraTool 调用。这就是你在系统提示里偶尔见到 "deferred tools" 的由来——工具太多、按需加载。👶 小白:一把工具为啥要写两份说明(prompt 和 description)?留一份不够吗?
👨🏫 老师:因为两份说明是给两个不同的读者看的。prompt() 是给模型看的"使用手册"——要写得详细,教它什么时候用、参数怎么填、有什么坑;description() 是给你(人)在终端权限弹窗里看的一句话摘要——越短越好。就像同一台设备,给工程师的是厚厚的技术手册,给用户的是包装盒上一行字。想知道"模型眼里这工具是什么",永远去读 prompt()。
执行管线全景:一次工具调用过哪些关卡
模型说"调 Edit"后,到真执行、返回结果,中间过一条流水线(src/services/tools/toolExecution.ts)。唯一的 tool.call() 调用点在 :1256,前后被一堆关卡包着:
找工具:findToolByName(支持别名,如老 KillShell→TaskStop)
zod schema 校验(:657):参数不合法 → InputValidationError
validateInput(:725):工具自定义的业务校验
backfill 可观察输入(:826):把 file_path 展开成绝对路径给 hook/权限看(防绕过)
PreToolUse hooks(:842):可拦截/改输入/放行或拒绝(Day 14)
权限检查:走 canUseTool(Day 09)
★ tool.call() 真执行(:1256)——唯一执行点
结果映射(:1367)+ 超大结果落盘(:1478,按 maxResultSizeChars)
PostToolUse hooks(:1558):可改写结果/追加消息(Day 14)
partitionToolCalls(toolOrchestration.ts:106)分成"连续的只读工具"(并发跑,上限 10)和"写工具"(串行跑),依据就是 L05 讲的 isConcurrencySafe。所以模型一次要读 5 个文件 → 并发;要改 3 个文件 → 串行(避免写冲突)。fail-closed 的默认值(默认不可并发)在这里直接影响性能与安全——一个漏声明只读的工具会被安全地串行执行。今日小结 + 动手
🧠 今天你应该能回答
- 工具解决哪三件事?(定义/schema、权限、执行)
- 协议层 vs 宿主层的区别(USB 标准 vs 具体 USB 口)?
- 权限三态 allow/deny/passthrough,passthrough 为什么存在?
- zod schema 一处声明、两处受益是哪两处?(校验 + 给模型的文档)
- buildTool 的 fail-closed 默认值是什么、为什么这么设?
- 注册第 3 步为什么按名排序?(prompt 缓存稳定)
- 发给模型的工具说明用 prompt() 还是 description()?(prompt!)
- 执行管线唯一的 call() 在哪?并发怎么分区?
✋ 动手:对着真实代码读一遍
# 1. 权限三态类型(L03)
sed -n '80,95p' packages/agent-tools/src/types.ts
# 2. 宿主 Tool 契约(L04,挑关键看)
sed -n '372,540p' src/Tool.ts | grep -n "prompt\|description\|checkPermissions\|isReadOnly\|call\|inputSchema" | head
# 3. buildTool 安全默认(L05)
sed -n '767,802p' src/Tool.ts
# 4. prompt() 而非 description()(L07,核心反直觉点)
sed -n '155,192p' src/utils/api.ts
# 5. 注册与排序
sed -n '218,400p' src/tools.ts | grep -n "getAllBaseTools\|assembleToolPool\|sort\|filterToolsByDenyRules"
packages/builtin-tools 精读几个真实工具——Read(只读 + 去重巧思)、Edit(写 + safetyCheck)、Bash(tree-sitter AST 拆命令判权限)、Agent(子代理 + 权限下放),看它们各自怎么填这套接口。