Day 07 / 共 20 天 · 第 2 周 核心引擎 · 加深版

工具系统 Tool 接口

Agent 的"手脚"。这一页贴 Tool.tsapi.ts 的真实源码逐段讲:一个工具必须实现什么、buildTool 的安全默认值、以及"发给模型的说明用 prompt() 不是 description()"这个反直觉点。昨天(Day 06)读透了循环这颗"大脑",今天讲它指挥的"手脚",Day 08 再拆几把具体工具,Day 09 讲工具动手前的"签字"。

📍 你在整门课的位置 · 第 2 周 核心引擎
D06 QueryEngine D07 工具接口 D08 内置工具 D09 权限 D10 上下文/Token
💡 用一个类比先兜住今天(本日世界观:一个"工具箱") 工具系统就是实习生的工具箱:里面 60+ 把工具(读文件、跑命令、搜代码…)。每把工具都得配齐三样:一张规格卡(schema + 说明书)告诉模型"我叫啥、要递给我什么参数、什么时候用我";一道签字关(权限)——拿电钻(改文件、跑命令)前得先让你签字确认;以及真正干活的手柄(call)。今天贯穿一个安全原则:fail-closed——一把工具没主动贴"我很安全"的标签,就一律当"危险的、要小心串行用"来对待。宁可慢,绝不出事。
L01

工具解决哪 3 件事

Day 01 说 Agent = 模型 + 工具 + 循环。工具就是"模型能调用去改变世界的能力"——读文件、写文件、跑命令、搜代码、上网。模型自己只会吐文字,是"工具"让它能真正动手。

回忆 Day 03/06:模型在回复里吐一个 tool_use 块("我要调 Read,参数是这个路径")→ 系统执行对应工具 → 把结果作为 tool_result 回给模型。所以"工具"在代码里要解决三件事:

工具要解决的 3 件事 ① 定义/schema:怎么告诉模型"有这个工具、它叫什么、要传什么参数"。
② 权限:怎么判断"这次调用能不能执行"(读文件放行、删库要问)。
③ 执行:怎么真去干活、并返回结果。
📝 举个例子:一次工具调用的"三件事"串起来 模型吐出 { 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

L02

两层接口:协议层 vs 宿主层(最容易懵)

工具系统有两层定义,先搞清,否则读代码会晕:

① 协议层 CoreTool(宿主无关)

packages/agent-tools/src/types.ts

"纯类型 + 零运行时依赖"的最小内核接口。只约定工具的本质:name、inputSchema、call、权限。不关心 React/终端 UI。

② 宿主层 Tool(Claude Code 实际用)

src/Tool.ts

在协议层之上,加了一大堆 UI 渲染方法(怎么在终端里画这个工具的调用/结果)。宿主工具"结构上满足"协议层接口。

为什么分两层?(用大白话) packages/agent-tools(协议层)零依赖,谁都能用——子代理、SDK、测试。src/Tool.ts(宿主层)才绑定终端 UI。好比"USB 标准"(协议)和"某台电脑上的 USB 口"(宿主):标准定了插头长什么样,具体电脑再实现怎么点亮指示灯。你写业务逻辑只关心协议层;写终端渲染才碰宿主层。Day 12 会看到 MCP 外部工具也是实现这个协议层,所以能和内置工具无缝并列。
L03

权限三态: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(弃权)这一态? 因为大部分工具不想自己写复杂的权限逻辑。它返回 passthrough = "我不表态,你按通用规则判吧"——然后 Day 09 的权限系统用统一的模式/规则来决定。只有少数工具(如 Read 的"工作目录内放行"、Bash 的 AST 命令解析)才自己给明确的 allow/deny。三态让"工具自定义"和"统一裁决"能共存。
L04

一个工具要实现什么

宿主层 Toolsrc/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
zod 是什么?(重要) 一个 TypeScript 的"运行时数据校验"库。你用 z.object({ file_path: z.string() }) 声明"输入长这样",它一处声明、两处受益:① 运行时校验模型传来的参数合不合法(乱传就报错);② 自动转成 JSON Schema 发给模型当"参数说明书"。所以工具作者只写一份 schema,校验和文档全有了——这是本工具系统的基石。
L05

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 的函数"。
fail-closed(默认保守)——这是安全设计的精髓:默认值是 isConcurrencySafe: false(不可并发)、isReadOnly: false(会写)。也就是说,一个工具只有主动声明 isReadOnly: () => true 才会被当只读、才能并发跑。忘记声明的后果是"被当成危险的写工具、串行执行"——慢一点,但绝不出事。反过来若默认"只读+可并发",忘声明的写工具就会被误当只读并发跑坏。不确定时选更安全的默认,这就是 fail-closed。所有 60+ 工具都通过 buildTool({...}) 导出。
fail-closed:没主动贴"安全"标签,就当危险处理 一把工具 它有没有主动写 isReadOnly:()=>true ? ✔ 写了 → 当只读可并发(快,绿色通道,上限10) ✘ 没写 → 默认当会写串行执行(慢一点,但绝不写冲突)
图注:默认往"危险"一侧靠——工具作者忘了声明只读,最坏就是被保守地串行执行(慢),而不是被误当只读并发跑坏文件。
L06

注册与排序:从全部候选到给模型用

工具从"全部候选"到"这次能给模型用的",经过几道过滤(src/tools.ts):

1

getAllBaseTools()tools.ts:218):返回当前环境所有可能的工具(受环境变量/feature 门控)

2

getTools(permissionContext)tools.ts:304):去掉被整工具 deny 的、被禁用的(isEnabled()===false

3

assembleToolPooltools.ts:378):内置工具 + MCP 工具合并、按名排序、去重(内置优先)

为什么第 3 步要"按名排序"?(很关键的性能细节) 工具定义会被放进发给模型的 prompt。而 Anthropic 有 prompt 缓存——完全相同的 prompt 前缀能命中缓存、省钱省时间。如果工具顺序每次都变,prompt 字节就变,缓存永远命中不了。排序保证工具列表稳定,缓存才稳定。这种"为了缓存命中而刻意保持顺序稳定"的细节,在本项目随处可见。

还有个提前过滤 filterToolsByDenyRulestools.ts:295):在把工具给模型之前就剔除被整工具 deny 的(含 MCP mcp__server 前缀级 deny)——被禁的工具模型根本看不到,而不是"看到了但调用时被拒"。

L07

暴露给模型:prompt() ≠ description()(真实代码)

工具怎么变成 Anthropic API 的 tool 定义?靠 toolToAPISchemasrc/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()

L08

执行管线全景:一次工具调用过哪些关卡

模型说"调 Edit"后,到真执行、返回结果,中间过一条流水线(src/services/tools/toolExecution.ts)。唯一的 tool.call() 调用点在 :1256,前后被一堆关卡包着:

1

找工具:findToolByName(支持别名,如老 KillShell→TaskStop)

2

zod schema 校验:657):参数不合法 → InputValidationError

3

validateInput:725):工具自定义的业务校验

4

backfill 可观察输入:826):把 file_path 展开成绝对路径给 hook/权限看(防绕过)

5

PreToolUse hooks:842):可拦截/改输入/放行或拒绝(Day 14)

6

权限检查:走 canUseTool(Day 09)

7

★ tool.call() 真执行:1256)——唯一执行点

8

结果映射:1367)+ 超大结果落盘:1478,按 maxResultSizeChars)

9

PostToolUse hooks:1558):可改写结果/追加消息(Day 14)

并发分区(回扣 L05):一批工具调用会被 partitionToolCallstoolOrchestration.ts:106)分成"连续的只读工具"(并发跑,上限 10)和"写工具"(串行跑),依据就是 L05 讲的 isConcurrencySafe。所以模型一次要读 5 个文件 → 并发;要改 3 个文件 → 串行(避免写冲突)。fail-closed 的默认值(默认不可并发)在这里直接影响性能与安全——一个漏声明只读的工具会被安全地串行执行。
L09

今日小结 + 动手

🧠 今天你应该能回答

  • 工具解决哪三件事?(定义/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"
明天预告 · Day 08:接口讲清了,Day 08 打开 packages/builtin-tools 精读几个真实工具——Read(只读 + 去重巧思)、Edit(写 + safetyCheck)、Bash(tree-sitter AST 拆命令判权限)、Agent(子代理 + 权限下放),看它们各自怎么填这套接口。

← Day 06 QueryEngine Day 08 · 内置工具精读 →