Tool 抽象
Tool 抽象:一个接口统一所有工具
Claude Code 里 Read 读磁盘、Bash 开子进程、Task 派生子代理、SendMessage 跨 agent 通信,能力天差地别,却全部收敛到 src/Tool.ts 里同一个 Tool<Input, Output, P> 类型。
模型只认识”工具”这一个概念,新增一个工具 = 实现这个接口 + 走 buildTool 导出,主循环、并发调度、权限、渲染、遥测全部自动接住。
内置工具 40 个(src/tools/ 下 40 个目录,全部走 buildTool 导出),全走这一个抽象。
注册表在 src/tools.ts 的 getAllBaseTools():默认构建装配 21 个,feature flag 与环境变量全开约 54 个条目(含 ant-only 与 test-only),源码注释 Tool.ts:786 按 60+ tools 计。
说明:Claude Code 是闭源商业实现,下面只引签名级片段(每段不超过 3 行),但所有架构描述都按本地 2.1.88 反混淆源码逐一核对过。
Tool 接口:能力、安全、描述、渲染四类成员
代码块收起展开
// 基于 Claude Code 2.1.88 反混淆源码, src/Tool.ts(闭源,只引签名级片段)
export type Tool<
Input extends AnyObject = AnyObject, // AnyObject = z.ZodType<{[key: string]: unknown}>,入参必须是 zod 对象 schema
Output = unknown,
// ...(第三个泛型 P 是进度事件类型,如 BashProgress / AgentToolProgress)
> = {
readonly name: string
readonly inputSchema: Input // 一份 zod schema 三用:运行期 safeParse 校验、zodToJsonSchema 转给 API、z.infer<Input> 推静态类型
// ...
// ---- 真正干活 ----
call(
args: z.infer<Input>,
context: ToolUseContext, // 工具与整个会话的接口:messages、AppState 读写、abortController、文件读取缓存……
// ...(还有 canUseTool、parentMessage、onProgress? 共 5 个参数)
): Promise<ToolResult<Output>>
// ---- 安全闸门:外层调度与权限系统据此决策 ----
isConcurrencySafe(input: z.infer<Input>): boolean // 决定能否与相邻工具并发;按输入粒度判断,Bash 会解析具体命令
isReadOnly(input: z.infer<Input>): boolean // 注意:并发由上面那个决定,这个主要给权限 UI(只读展示轻量确认)和记忆提取用
isDestructive?(input: z.infer<Input>): boolean // 不可逆操作(删除/覆盖/发送)
interruptBehavior?(): 'cancel' | 'block' // 用户中途发新消息:砍掉工具,还是跑完让新消息等着(默认 block)
validateInput?(
// ...语义校验(如 Edit 要求文件先 Read 过),失败文本直接回给模型自纠
): Promise<ValidationResult>
checkPermissions(
// ...工具特有的权限逻辑(如 Bash 按子命令匹配规则);通用裁决在 utils/permissions/permissions.ts,见下
): Promise<PermissionResult>
// ---- 给模型看的文字:prompt() 生成 API 请求 tools 数组里的 description 字段(utils/api.ts),
// ---- description() 是给权限弹窗等 UI 用的一句话说明(hooks/useCanUseTool.tsx)——两者容易搞反
prompt(options: {
// ...
}): Promise<string>
searchHint?: string // 配合延迟加载(shouldDefer):schema 不随首轮请求下发,模型先用 ToolSearch 关键词搜到再加载
maxResultSizeChars: number // 结果超限就落盘、只回传路径+预览;Read 设为 Infinity——否则"读文件→落盘→再读"套娃
toAutoClassifierInput(input: z.infer<Input>): unknown // 压缩成一句摘要喂 auto 模式安全分类器;返回 '' 表示跳过
// ---- 渲染也长在接口上:终端 UI 是 React(Ink 渲染到 TTY),每个工具自带渲染方法 ----
renderToolUseMessage(
// ...还有 renderToolResultMessage? / renderToolUseProgressMessage? 等 8 个 render* 成员,外加 getActivityDescription? / userFacingNameBackgroundColor? 等 UI 辅助成员
): React.ReactNode
}ToolResult 说明工具不只是”输入→输出”,还能反向影响会话;
buildTool 给常被省略的方法兜底,默认值的方向是这套设计的关键:
代码块收起展开
// 同上 src/Tool.ts
export type ToolResult<T> = {
data: T
newMessages?: ( // 工具可以往对话追加消息(如 Task 把子代理产出注入主线)
// ...
)[]
contextModifier?: (context: ToolUseContext) => ToolUseContext // 工具还能改写后续工具的执行上下文
// ...
}
const TOOL_DEFAULTS = {
isEnabled: () => true,
isConcurrencySafe: (_input?: unknown) => false, // 拿不准就当不可并发
// ...
isReadOnly: (_input?: unknown) => false, // 拿不准就当有副作用——fail-closed,忘了声明的工具被保守对待而不是放松
toAutoClassifierInput: (_input?: unknown) => '', // 唯一往"松"倒的默认:跳过分类器,安全相关工具必须显式覆盖
// ...
}
export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
// ...
return {
...TOOL_DEFAULTS,
userFacingName: () => def.name,
// ...(最后 ...def 展开:def 自己写了的成员一律覆盖默认值。所有工具都走这里导出,默认策略只写一份)
} as BuiltTool<D>
}执行管线:从 tool_use 块到 tool_result
模型每轮吐出的 tool_use 块,由编排层分批、执行层逐个走完生命周期:
代码块收起展开
// 基于 Claude Code 2.1.88 反混淆源码, src/services/tools/toolOrchestration.ts
export async function* runTools(
// ...
): AsyncGenerator<MessageUpdate, void> // 整条管线是 AsyncGenerator:边执行边 yield 消息,UI 实时渲染
// ...partitionToolCalls 按 isConcurrencySafe 切批:
if (isConcurrencySafe && acc[acc.length - 1]?.isConcurrencySafe) {
acc[acc.length - 1]!.blocks.push(toolUse) // 只合并"相邻"的安全调用成一批,不做全局重排
} // else 分支另起一批(略)
// ...并发批走 all(generators, N),N 来自:
parseInt(process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY || '', 10) || 10
// ...谓词抛异常按 false 处理(Bash 判断要 shell-quote 解析命令,解析不了就当不安全)
// ---- src/services/tools/toolExecution.ts:单个调用的完整生命周期 ----
export async function* runToolUse(
// ...
): AsyncGenerator<MessageUpdateLazy, void>
// ...findToolByName 找不到(模型幻觉/旧别名)不抛异常,yield 一条 is_error 的 <tool_use_error> tool_result 回给模型
// Validate input types with zod (surprisingly, the model is not great at generating valid input)
const parsedInput = tool.inputSchema.safeParse(input) // 官方注释原话:模型生成合法参数的能力"出人意料地"不行
// ...类型过了才轮到语义校验:
const isValidCall = await tool.validateInput?.(parsedInput.data, context)
// ...再跑 PreToolUse hooks,然后进权限裁决(utils/permissions/permissions.ts 的 hasPermissionsToUseToolInner):
const denyRule = getDenyRuleForTool(appState.toolPermissionContext, tool) // 1a: deny 规则最先看,谁也压不过
// ...1b 整工具 ask 规则 → 1c 才问工具自己:
toolPermissionResult = await tool.checkPermissions(parsedInput, context)
// ...1g: .git/、.claude/、shell 配置等路径的 safetyCheck 连 bypassPermissions 模式都拦。全绿后:
const result = await tool.call(/* args, context, canUseTool, parentMessage, onProgress */)
// ...结果序列化成 API 格式(超过 maxResultSizeChars 的落盘换成路径+预览):
const mappedToolResultBlock = tool.mapToolResultToToolResultBlockParam(
// ...
)原理串讲
一次典型调用从 query.ts 的 queryLoop 开始:模型流式返回的 assistant 消息带着 tool_use 块,收集齐后交给 runTools(toolOrchestration.ts)。
runTools 先用 partitionToolCalls 分批:
- 查出 Tool 对象;
- safeParse 输入;
- 调
isConcurrencySafe(input); - 连续 true 合并成一批并发跑(上限默认 10),false 的单独成批串行。
第一处为什么:为什么只合并相邻的安全块、不把所有只读调用全局重排到一起?因为模型产出的顺序携带语义——“先 Edit 再 Read 验证”不能被重排成先读后写;相邻合并在不破坏顺序语义的前提下拿走了大部分并发收益。
contextModifier 同理讲究确定性:串行工具立即改上下文,并发批先排队、整批结束后按块顺序统一应用。
每个块进 runToolUse(toolExecution.ts)。
它的核心态度是:一切失败都不抛异常,而是包成 is_error: true 的 tool_result 还给模型:
- 未知工具名:回 “No such tool available”。
- zod 校验失败:回格式化的字段错误。
- validateInput 拒绝:回拒绝理由。
- 权限拒绝:回拒绝消息。
第二处为什么:为什么错误走 tool_result 而不是抛出去?因为模型是闭环的一部分,错误文本就是下一轮的提示词,模型读到就能改参数重试,循环不断。
做得最极致的是 buildSchemaNotSentHint:延迟加载的工具没下发 schema 时,模型会把数组参数写成字符串,裸的 zod 类型报错没有可操作性,这个函数在报错后面追加一句具体指令——先调 ToolSearch select:工具名 加载 schema 再重试。
校验通过后串四道闸门:
inputSchema.safeParse(类型层)tool.validateInput(语义层)runPreToolUseHooks(用户 hook 可 allow/deny/改输入)resolveHookPermissionDecision走 canUseTool → permissions.ts 的hasPermissionsToUseToolInner
规则优先级硬编码成 1a→1g:
- 1a deny 规则最先看,谁也压不过
- 1b 整工具 ask 规则
- 1c
tool.checkPermissions(工具特有逻辑,Bash 在这里把复合命令拆成子命令逐个对Bash(git *)这类规则) - 1d 工具实现自己判 deny(Bash 子命令 deny 从这里冒出来)
- 1e 工具声明必须用户确认,连 bypass 模式也照问
- 1f
tool.checkPermissions返回的内容级 ask 规则,优先级压过后面的 allow - 1g safetyCheck(敏感路径连 bypass 模式都拦)
第三处为什么:TOOL_DEFAULTS 里 checkPermissions 默认直接 allow,看着危险,其实只是工具在 1c 这一步表态”无意见”。
外层 1a/1b 的规则闸门和后续 ask 弹窗照常生效,真正的默认安全性由 isConcurrencySafe/isReadOnly 默认 false 这类 fail-closed 值兜底。
全绿后 tool.call 执行。
长任务的 onProgress 回调被 streamedCheckPermissionsAndCallTool 用一个 Stream 桥接成 async iterable(源码注释自嘲 “This is a bit of a hack”),让进度消息和最终结果在同一条流里按序到达 UI。
结果经 mapToolResultToToolResultBlockParam 转成 API 的 tool_result 块,queryLoop 把它作为 user 消息 push 进 toolResults,递归进入下一轮请求——模型看到结果,继续说话或再调工具,agent 循环就是这一圈。
设计取舍
- 一份 zod schema 三用(运行期校验 / 发给 API 的 JSON Schema / TS 静态类型),MCP 工具反着走:
inputJSONSchema直传、跳过 zod 转换。 - 渲染方法直接长在 Tool 接口上,换 UI 框架要动所有工具;换来”一个工具一个目录”的内聚,执行、权限、展示全在一处。
isReadOnly不等于isConcurrencySafe:并发判定是独立方法且按输入粒度(同是 Bash,命令不同结论不同),别拿”只读”推”可并发”。- 默认值 fail-closed:接口有 40 多个成员(2.1.88 里 47 个),
buildTool只兜底常缺的 7 个,漏写声明的工具被当危险工具保守对待,而不是反过来。 - 错误即提示词:管线里没有”抛给用户”的异常路径,一切失败都变成模型可读、可自纠的 tool_result 文本。
工具全景与逐个拆解见 02-内置工具全景,主循环怎么消费这条管线、上下文怎么在轮次间流转见 03-agent 主循环。