手撕 Claude Code-3:Tool System 工具调用、权限与动态披露

1 阅读9分钟

第 3 章:Tool System — 工具调用、权限与动态披露

源码位置:src/Tool.tssrc/services/tools/src/tools/(56 个工具实现)


3.1 什么是工具系统?

Claude Code 的工具系统是 "行动能力" 的实现层。Claude 通过文本表达"我要执行某个操作",工具系统负责:

  1. 解析 Claude 的 tool_use
  2. 权限检查:是否允许执行?
  3. 执行工具,获得结果
  4. 把结果返回给 Claude(以 tool_result 形式)

3.2 工具定义结构

每个工具通过 buildTool() 工厂函数构建,遵循统一接口:

// src/Tool.ts
export type ToolDef<TInput, TOutput> = {
  // ── 必需字段 ──────────────────────────────────────
  name: string                    // 工具名称(如 'Bash', 'Read')
  maxResultSizeChars: number      // 结果最大字符数(超出则持久化到磁盘)
  
  description(): Promise<string>  // 工具描述(发送给 API 的简短说明)
  prompt(): Promise<string>       // 详细使用说明(注入 system prompt)
  inputSchema: ZodSchema          // 输入 Schema(Zod 定义,懒加载)
  call(input, context): Promise<TOutput>  // 执行逻辑
  
  // ── 可选字段 ──────────────────────────────────────
  searchHint?: string             // ToolSearch 关键字匹配描述
  strict?: boolean                // 严格 JSON Schema 模式
  outputSchema?: ZodSchema        // 输出 Schema
  
  isEnabled?(): boolean           // 是否启用(默认 true,可按运行时条件禁用)
  isReadOnly?(): boolean          // 是否只读(用于 plan 模式过滤)
  isDestructive?(): boolean       // 是否不可逆操作(影响权限警告)
  isConcurrencySafe?(): boolean   // 是否可与其他工具并发(默认 false)

  shouldDefer?: boolean           // 是否延迟披露(Tool Search 机制)
  alwaysLoad?: boolean            // 即使 shouldDefer=true 也强制加载(优先级最高)
  
  checkPermissions?(input): Promise<PermissionResult>  // 权限检查(默认 allow)
  
  interruptBehavior?(): 'cancel' | 'block'
  // 用户输入新消息时的行为:
  //   'cancel' — 中止当前工具执行
  //   'block'  — 继续执行完再处理新消息
  
  userFacingName?(): string       // 显示给用户的名称(空字符串 = 不显示)
  renderToolUseMessage?(): React.ReactNode | null  // UI 渲染
}

示例:TodoWriteTool 的定义

源码位置:src/tools/TodoWriteTool/TodoWriteTool.ts:31

export const TodoWriteTool = buildTool({
  name: TODO_WRITE_TOOL_NAME,
  searchHint: 'manage the session task checklist',
  maxResultSizeChars: 100_000,
  strict: true,
  
  async description() { return DESCRIPTION },
  async prompt() { return PROMPT },
  
  get inputSchema() { return inputSchema() },    // 懒加载 Schema
  get outputSchema() { return outputSchema() },
  
  userFacingName() { return '' },  // 空字符串 = 不在 UI 显示工具名
  
  shouldDefer: true,    // 延迟披露给 Claude
  
  isEnabled() { return !isTodoV2Enabled() },  // 仅在 v1 模式下启用
  
  async checkPermissions(input) {
    return { behavior: 'allow', updatedInput: input }  // 无需权限确认
  },
})

3.3 工具执行编排

源码位置:src/services/tools/toolOrchestration.ts

Claude 返回的 assistant 消息可能包含多个 tool_use 块,runTools() 负责编排它们的执行。

并发策略:按 isConcurrencySafe 分批

工具不是全部并发执行,而是根据 isConcurrencySafe() 分批处理(src/services/tools/toolOrchestration.ts:26):

假设 Claude 返回 4 个工具调用:[Read, Bash, Edit, Read]
其中 Edit.isConcurrencySafe() = false,其余 = true

分批结果:
  批次 1: [Read, Bash]   并发执行
  批次 2: [Edit]         独占执行(等待批次1全部完成)
  批次 3: [Read]         并发执行(等待批次2完成)

规则:连续的 safe 工具合并为一批并发;任何 unsafe 工具单独成一批串行执行。

并发上限(src/services/tools/toolOrchestration.ts:8):

function getMaxToolUseConcurrency(): number {
  return parseInt(process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY || '', 10) || 10
}
// 默认最多 10 个工具并发,可通过环境变量调整

StreamingToolExecutor

源码位置:src/services/tools/StreamingToolExecutor.ts

处理流式工具调用(工具边接收 Claude 输出边执行)的核心类,管理并发约束队列:

// 并发执行条件检查(StreamingToolExecutor.ts)
private canExecuteTool(isConcurrencySafe: boolean): boolean {
  const executingTools = this.tools.filter(t => t.status === 'executing')
  return (
    executingTools.length === 0 ||
    (isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
  )
}

Sibling Abort Controller:每个 StreamingToolExecutor 实例持有一个子 AbortController,当并发工具之一出错时,立即终止其他 sibling 工具(不终止整个 query turn):

// Child of toolUseContext.abortController.
// Fires when a Bash tool errors so sibling subprocesses die immediately
// instead of running to completion. Aborting this does NOT abort the parent.
private siblingAbortController: AbortController

工具执行完整流程

Claude 返回 tool_use 块
      │
      ▼
findToolByName()          ← 查找工具实现
      │
      ▼
backfillObservableInput() ← 工具注入派生字段(不影响 call() 原始输入)
      │
      ▼
PreToolUse hooks          ← 可修改输入、提供权限决策、注入上下文
      │
      ▼
权限检查(checkPermissions + canUseTool)
      ├─ allow → 继续
      ├─ deny  → 返回错误 tool_result,跳过执行
      └─ ask   → 暂停等待用户确认
      │
      ▼
call(input, toolUseContext)  ← 执行工具
      │
      ▼
applyToolResultBudget()   ← 结果大小限制(见 3.6)
      │
      ▼
PostToolUse hooks          ← 可修改输出
      │
      ▼
返回 tool_result 给 Claude

3.4 权限系统

工具执行前必须通过权限检查,这是 Claude Code 安全模型的核心。

权限检查函数

// src/hooks/useCanUseTool.tsx
export type CanUseToolFn = (
  toolName: string,
  input: unknown,
  context: ToolPermissionContext,
) => Promise<PermissionResult>

权限结果类型

// src/utils/permissions/PermissionResult.ts
type PermissionResult =
  | { behavior: 'allow'; updatedInput?: unknown }      // 允许(可修改输入)
  | { behavior: 'deny'; message: string }              // 拒绝(附原因)
  | { behavior: 'ask'; prompt: string }                // 需要用户确认

权限模式

源码位置:src/types/permissions.ts

模式说明
default标准权限检查,需用户确认危险操作
acceptEdits自动接受文件编辑操作
bypassPermissions绕过所有权限检查(自动化/CI 模式)
dontAsk拒绝所有权限请求(只读保护模式)
plan计划模式,允许读但拒绝写操作
auto自动分类器模式(需 TRANSCRIPT_CLASSIFIER feature gate)

注意:文档旧版本列出的 bubble 模式不存在于源码中。

Hook 可以修改权限

PreToolUse Hook 能够:

  • 批准或拒绝工具调用
  • 修改工具输入参数
  • 添加额外上下文

源码位置:src/types/hooks.ts:72

z.object({
  hookEventName: z.literal('PreToolUse'),
  permissionDecision: permissionBehaviorSchema().optional(),  // 批准/拒绝
  permissionDecisionReason: z.string().optional(),
  updatedInput: z.record(z.string(), z.unknown()).optional(),  // 修改输入
  additionalContext: z.string().optional(),                    // 注入上下文
})

3.5 工具动态披露(Deferred Tool Disclosure)

这是 Claude Code 一个精妙的优化设计。并非所有工具都在每次 API 调用时都包含在 tools 参数中

问题背景

Claude Code 有 56+ 个工具。如果每次 API 调用都传递所有工具的完整 Schema,会:

  • 消耗大量 token(工具描述本身很长)
  • 降低 Claude 的"专注度"(工具太多容易混淆)

解决方案:shouldDefer 标志

// 在工具定义中
shouldDefer: true   // 这个工具不直接披露给 Claude

标记了 shouldDefer: true 的工具不会出现在每次 API 请求的 tools 数组中。

ToolSearch 机制

Claude 要使用延迟工具时,先调用 ToolSearch 工具搜索它:

Claude 想用某个功能(比如 TodoWrite)
        │
        ▼
Claude 调用 ToolSearch("manage task checklist")
        │
        ▼
ToolSearch 返回匹配的工具名和描述
        │
        ▼
Claude 现在知道了工具名,可以调用它
// src/utils/toolSearch.ts
export function isToolSearchEnabled(): boolean { ... }
export function isDeferredToolsDeltaEnabled(): boolean { ... }

// API 响应处理:提取已发现的工具名
export function extractDiscoveredToolNames(response): string[] { ... }

extractDiscoveredToolNames:动态工具加载

ToolSearch 工具被调用后,返回的结果中包含 tool_reference blocks。extractDiscoveredToolNames() 扫描消息历史,提取所有已经被发现的工具名称(src/utils/toolSearch.ts:545):

export function extractDiscoveredToolNames(messages: Message[]): Set<string> {
  const discoveredTools = new Set<string>()
  for (const msg of messages) {
    // compact boundary 恢复之前发现的工具
    if (msg.type === 'system' && msg.subtype === 'compact_boundary') {
      const carried = msg.compactMetadata?.preCompactDiscoveredTools
      if (carried) for (const name of carried) discoveredTools.add(name)
    }
    // tool_result 中的 tool_reference blocks
    for (const block of ...) {
      if (isToolReferenceWithName(block)) discoveredTools.add(block.tool_name)
    }
  }
  return discoveredTools
}

作用:只有已发现的工具才会包含在后续 API 请求的 tools 数组中。这解决了 MCP 工具数量过多的问题——100 个 MCP 工具不会全部在对话开始时就传给模型。

compact 发生时,preCompactDiscoveredTools 被写入 compact boundary message,确保压缩后已发现的工具不丢失。

附件中的延迟工具提示

// src/utils/attachments.ts
getDeferredToolsDeltaAttachment(toolUseContext)
// 返回一个消息,告诉 Claude:
// "还有以下工具可以通过 ToolSearch 发现:[工具列表摘要]"

动态披露流程图

API 请求构建阶段:
┌─────────────────────────────────────────┐
│  全部工具列表(56+)                      │
│                                         │
│  直接披露(tools 参数):                  │
│  ├─ Bash                                │
│  ├─ FileRead                            │
│  ├─ FileEdit                            │
│  ├─ Agent                               │
│  ├─ Skill                               │
│  ├─ ToolSearch                          │
│  └─ ...(未标 shouldDefer 的工具)        │
│                                         │
│  延迟披露(附件消息提示):                │
│  ├─ TodoWrite    (shouldDefer: true)    │
│  ├─ TaskCreate   (shouldDefer: true)    │
│  ├─ EnterWorktree (shouldDefer: true)   │
│  └─ ...                                 │
└─────────────────────────────────────────┘

3.6 工具结果大小限制与持久化

工具结果可能非常大(Bash 输出、文件读取等)。Claude Code 有三层预算机制(src/utils/toolResultStorage.ts):

第一层:单工具声明上限

每个工具通过 maxResultSizeChars 声明自己的上限:

  • TodoWriteTool.maxResultSizeChars = 100_000
  • ReadTool.maxResultSizeChars = Infinity(Read 工具自带 maxTokens,不走持久化)
  • 默认上限为 50,000 字符

GrowthBook feature flag tengu_satin_quoll 可按工具名动态覆盖阈值。最终阈值:Math.min(声明值, 50k默认值)

第二层:单结果持久化

超过阈值的结果被写入磁盘(src/utils/toolResultStorage.ts:272),原始内容替换为引用字符串:

<persisted-output>
Output too large (26KB). Full output saved to: /path/to/session/tool-results/xxx.txt

Preview (first 2KB):
[前 2000 字节的内容预览]
</persisted-output>

Claude 看到的是这个引用,可以在需要时通过 Read 工具读取完整内容。

第三层:每条消息的聚合预算

单条消息中所有工具结果的总大小上限为 200,000 字符。多个工具并发时,超出部分按比例压缩。


3.7 工具类型分类

分类工具名说明
文件操作FileRead, FileEdit, FileWrite, Glob, Grep读写搜索文件
ShellBash执行 Shell 命令
网络WebSearch, WebFetch搜索和获取网页
代理Agent, SendMessage创建子代理、发消息
任务TodoWrite, TaskCreate, TaskUpdate, TaskStop, TaskGet, TaskList任务管理
技能Skill, ToolSearch调用技能、搜索工具
团队TeamCreate, TeamDelete创建代理团队
WorktreeEnterWorktree, ExitWorktreeGit Worktree 管理
规划EnterPlanMode, ExitPlanMode计划模式
CronCronCreate, CronDelete, CronList定时任务
记忆NotebookEdit, ReadMcpResource笔记、MCP 资源
特殊Sleep, AskUserQuestion, RemoteTrigger休眠、提问、远程触发

3.8 工具上下文(ToolUseContext)

源码位置:src/Tool.ts:158

所有工具的 call(input, toolUseContext) 都接收这个上下文对象:

type ToolUseContext = {
  // ── 配置 ──────────────────────────────────────
  options: {
    commands: Command[]
    tools: Tools              // 当前可用工具列表(注意:在 options 中)
    mainLoopModel: string
    mcpClients: MCPServerConnection[]
    // ...
  }
  
  // ── 状态读写 ────────────────────────────────
  getAppState(): AppState     // 读取全局状态(权限上下文、任务列表等)
  setAppState(f): void        // 原子性更新状态(注意:不是 updateAppState)
  readFileState: FileStateCache  // 文件读取缓存(LRU)
  
  // ── 取消控制 ────────────────────────────────
  abortController: AbortController  // 用于中止整个 query
  
  // ── 会话标识 ────────────────────────────────
  agentId?: AgentId           // 子代理才有;主线程通过 getSessionId() 获取会话 ID
  // 注意:没有 sessionId 字段,需调用 getSessionId() 函数
  
  // ── Prompt Cache 优化 ───────────────────────
  renderedSystemPrompt?: string  // 父代理已渲染的 system prompt 字节(fork 场景)
  queryTracking: { chainId, depth }  // 查询链追踪(analytics)
  
  // ── 权限 ────────────────────────────────────
  // 注意:canUseTool 和 permissionMode 不在 ToolUseContext 中
  // 权限模式通过 toolUseContext.getAppState().toolPermissionContext.mode 获取
  
  // ── UI 回调(可选,仅 REPL 模式有) ──────────
  setToolJSX?: SetToolJSXFn
  appendSystemMessage?: (msg) => void
  sendOSNotification?: (opts) => void
}

常见误区

  • permissionMode 不是 ToolUseContext 的直接字段,需通过 getAppState().toolPermissionContext.mode 读取
  • canUseToolrunTools() 的参数,不在 ToolUseContext
  • sessionId 字段不存在,需调用模块级 getSessionId() 函数

小结

工具执行完整流程:

Claude 返回 tool_use
     │
     ▼
findToolByName()      ← 查找工具实现
     │
     ▼
isEnabled()?          ← 工具是否在当前模式下启用?
     │
     ▼
checkPermissions()    ← 权限检查(可被 PreToolUse Hook 干预)
     │
     ├─ allow → call()  → 执行工具
     ├─ deny  → 返回错误 tool_result
     └─ ask   → 暂停,等待用户确认
               │
               ▼
          applyToolResultBudget()   ← 结果大小限制
               │
               ▼
          返回 tool_result 给 Claude
概念源码位置
工具定义接口src/Tool.ts
工具注册列表src/tools.ts
工具执行编排(含分批并发策略)src/services/tools/toolOrchestration.ts
流式工具执行(含 sibling abort)src/services/tools/StreamingToolExecutor.ts
权限检查函数src/hooks/useCanUseTool.tsx
权限模式定义src/types/permissions.ts
延迟工具披露 + 动态工具加载src/utils/toolSearch.ts
结果持久化(三层预算)src/utils/toolResultStorage.ts