Tool 抽象

Tool 抽象:一个接口统一所有工具

Claude Code 里 Read 读磁盘、Bash 开子进程、Task 派生子代理、SendMessage 跨 agent 通信,能力天差地别,却全部收敛到 src/Tool.ts 里同一个 Tool<Input, Output, P> 类型。
模型只认识”工具”这一个概念,新增一个工具 = 实现这个接口 + 走 buildTool 导出,主循环、并发调度、权限、渲染、遥测全部自动接住。
内置工具 40 个(src/tools/ 下 40 个目录,全部走 buildTool 导出),全走这一个抽象。
注册表在 src/tools.tsgetAllBaseTools():默认构建装配 21 个,feature flag 与环境变量全开约 54 个条目(含 ant-only 与 test-only),源码注释 Tool.ts:786 按 60+ tools 计。

说明:Claude Code 是闭源商业实现,下面只引签名级片段(每段不超过 3 行),但所有架构描述都按本地 2.1.88 反混淆源码逐一核对过。

Tool 接口:能力、安全、描述、渲染四类成员

代码块TS · 43 行收起展开
// 基于 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 给常被省略的方法兜底,默认值的方向是这套设计的关键:

代码块TS · 26 行收起展开
// 同上 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 块,由编排层分批、执行层逐个走完生命周期:

代码块TS · 31 行收起展开
// 基于 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.tsqueryLoop 开始:模型流式返回的 assistant 消息带着 tool_use 块,收集齐后交给 runTools(toolOrchestration.ts)。
runTools 先用 partitionToolCalls 分批:

  1. 查出 Tool 对象;
  2. safeParse 输入;
  3. isConcurrencySafe(input);
  4. 连续 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 再重试。

校验通过后串四道闸门:

  1. inputSchema.safeParse(类型层)
  2. tool.validateInput(语义层)
  3. runPreToolUseHooks(用户 hook 可 allow/deny/改输入)
  4. 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_DEFAULTScheckPermissions 默认直接 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 主循环

延伸阅读