第四章:工具系统——Agent 的双手

Agent 循环是大脑,工具系统就是双手。LLM 只能"想",是工具让它能"做"。
本章深入 Tool.tstools.tstoolExecution.tsStreamingToolExecutor.ts 等核心文件,
拆解工具从定义、注册、发现、权限检查、执行到结果回传的完整生命周期。


目录


4.1 工具系统是什么?

在 Agent 架构中,LLM 本身只能生成文本。要让它真正与外部世界交互——读文件、执行命令、搜索代码——就需要工具(Tools)

flowchart LR LLM["LLM 思考
生成 tool_use 块"] --> ToolSys["工具系统"] ToolSys --> Read["Read
读文件"] ToolSys --> Edit["Edit
编辑文件"] ToolSys --> Bash["Bash
执行命令"] ToolSys --> Grep["Grep
搜索内容"] ToolSys --> Agent["Agent
启动子Agent"] ToolSys --> MCP["MCP 工具
外部扩展"] Read --> Result["工具结果
返回给 LLM"] Edit --> Result Bash --> Result Grep --> Result Agent --> Result MCP --> Result

Claude Code 的工具系统有三个关键设计原则:

原则 说明
工具即数据 每个工具不是一个"类",而是一个符合 Tool 接口的普通对象
安全默认 默认假设工具不可并发、会写入、需要权限检查(fail-closed)
统一接口 内置工具和 MCP 外部工具共用同一个 Tool 接口,对 LLM 一视同仁

4.2 核心文件关系图

flowchart TD subgraph Definition["定义层"] ToolTs["Tool.ts
★ Tool 类型定义
buildTool() 工厂"] ToolsTs["tools.ts
工具注册表
getAllBaseTools()"] end subgraph Implementation["实现层 (tools/)"] FR["FileReadTool/"] FE["FileEditTool/"] FW["FileWriteTool/"] BT["BashTool/
★ 最复杂 (640KB)"] GT["GrepTool/"] GL["GlobTool/"] AT["AgentTool/
子 Agent 生成"] WF["WebFetchTool/"] SK["SkillTool/"] More["... 40+ 工具"] end subgraph Execution["执行层 (services/tools/)"] TE["toolExecution.ts
runToolUse()
checkPermissionsAndCallTool()"] STE["StreamingToolExecutor.ts
★ 流式并发执行器"] TO["toolOrchestration.ts
备选批量执行器"] TH["toolHooks.ts
Pre/Post 工具钩子"] end subgraph External["外部扩展"] MCPTool["MCPTool/
MCP 工具模板"] MCPClient["services/mcp/client.ts
动态工具发现"] end ToolTs --> ToolsTs ToolsTs --> TE Implementation --> TE TE --> STE TE --> TO TH --> TE MCPClient --> MCPTool MCPTool --> ToolsTs

4.3 Tool 类型定义:一个工具长什么样?

Tool 类型定义在 src/Tool.ts:362,是一个泛型类型 Tool<Input, Output, P>,约 330 行。可以按职责分为四组:

核心身份

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/Tool.ts:362
export type Tool<Input, Output, P> = {
readonly name: string // 唯一标识,如 "Bash", "Read", "Edit"
aliases?: string[] // 向后兼容的别名
searchHint?: string // ToolSearch 关键词匹配提示
readonly inputSchema: Input // Zod schema,输入参数校验
readonly inputJSONSchema?: ToolInputJSONSchema // MCP 工具的 JSON Schema
outputSchema?: z.ZodType<unknown> // 输出 schema
maxResultSizeChars: number // 结果超过此大小则持久化到磁盘
readonly shouldDefer?: boolean // 是否延迟加载(需 ToolSearch 激活)
readonly alwaysLoad?: boolean // 永远不延迟加载
isMcp?: boolean // 是否为 MCP 工具
readonly strict?: boolean // 严格模式(API 更严格遵守 schema)
}

生命周期方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
// 执行工具
call(args, context, canUseTool, parentMessage, onProgress?): Promise<ToolResult<Output>>

// 生成描述(告诉 LLM 这个工具干什么)
description(input, options): Promise<string>

// 系统提示词中关于此工具的说明
prompt(options): Promise<string>

// 自定义输入校验
validateInput?(input, context): Promise<ValidationResult>

// 权限检查
checkPermissions(input, context): Promise<PermissionResult>
}

分类方法

1
2
3
4
5
6
7
8
{
isConcurrencySafe(input): boolean // 可以并行执行?
isEnabled(): boolean // 当前环境是否启用?
isReadOnly(input): boolean // 只读操作?
isDestructive?(input): boolean // 不可逆操作?
interruptBehavior?(): 'cancel' | 'block' // 用户中断时的行为
isSearchOrReadCommand?(input): { isSearch, isRead, isList } // UI 折叠分类
}

渲染方法

1
2
3
4
5
6
7
8
9
{
renderToolUseMessage(input, options): React.ReactNode // 工具调用时的 UI
renderToolResultMessage?(content, progress, options): React.ReactNode // 结果 UI
renderToolUseProgressMessage?(progress, options): React.ReactNode // 进度 UI
renderToolUseRejectedMessage?(input, options): React.ReactNode // 拒绝时 UI
renderToolUseErrorMessage?(result, options): React.ReactNode // 错误时 UI
renderGroupedToolUse?(toolUses, options): React.ReactNode | null // 分组渲染
mapToolResultToToolResultBlockParam(content, toolUseID): ToolResultBlockParam // 结果→API格式
}

设计洞察:一个 Tool 对象同时承担了"执行引擎"和"UI 组件"两重角色。这避免了把渲染逻辑分散到其他地方,但也让每个工具文件变得较大。


4.4 buildTool:安全默认值工厂

buildTool() 是所有工具的"出生证",定义在 src/Tool.ts:757。它接收一个 ToolDef(可以省略部分方法),补齐安全默认值后返回完整的 Tool 对象。

flowchart LR ToolDef["ToolDef
(可省略部分方法)"] --> BuildTool["buildTool()"] BuildTool --> CompleteTool["完整的 Tool 对象"] subgraph Defaults["安全默认值 (fail-closed)"] D1["isEnabled → true"] D2["isConcurrencySafe → false
(假设不安全)"] D3["isReadOnly → false
(假设会写入)"] D4["isDestructive → false"] D5["checkPermissions → allow
(交给通用权限系统)"] D6["toAutoClassifierInput → ''
(跳过分类器)"] D7["userFacingName → name"] end BuildTool -.-> Defaults

关键设计:fail-closed 默认值

1
2
3
4
5
6
7
8
9
10
// src/Tool.ts:757
const TOOL_DEFAULTS = {
isEnabled: () => true,
isConcurrencySafe: (_input?: unknown) => false, // ← 默认不可并发
isReadOnly: (_input?: unknown) => false, // ← 默认假设写入
isDestructive: (_input?: unknown) => false,
checkPermissions: (input) => Promise.resolve({ behavior: 'allow', updatedInput: input }),
toAutoClassifierInput: (_input?: unknown) => '',
userFacingName: (_input?: unknown) => '',
}

为什么选择 fail-closed?

  • 如果一个新工具忘记实现 isConcurrencySafe,它会被当作不可并发处理 → 串行执行,宁可慢不可错
  • 如果忘记实现 isReadOnly,它会被当作写入工具处理 → 需要权限确认
  • 这避免了"新工具上线后并发执行导致数据损坏"的风险

4.5 工具注册表:tools.ts

src/tools.ts 是工具的"户口本",约 390 行。它管理工具的注册、过滤和组装:

三层工具获取

flowchart TD GAB["getAllBaseTools()
所有可能的工具(40+个)
受 feature flag 和环境变量控制"] GT["getTools(permContext)
过滤后的可用工具
移除 deny 规则匹配的工具
移除 isEnabled()=false 的工具"] ATP["assembleToolPool(permContext, mcpTools)
最终工具池
合并内置工具 + MCP 工具
按名称去重(内置优先)
分区排序保证缓存稳定"] GAB --> GT --> ATP

getAllBaseTools():全量工具清单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/tools.ts:193
export function getAllBaseTools(): Tools {
return [
AgentTool,
TaskOutputTool,
BashTool,
// 当有嵌入式搜索工具时,不加载独立的 Glob/Grep
...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
FileReadTool,
FileEditTool,
FileWriteTool,
// ... 根据 feature flag 动态包含
...(isTodoV2Enabled() ? [TaskCreateTool, TaskGetTool, ...] : []),
...(isWorktreeModeEnabled() ? [EnterWorktreeTool, ExitWorktreeTool] : []),
// ... 更多条件加载
]
}

注意工具的加载方式:

加载方式 示例 原因
静态 import BashTool, FileReadTool 核心工具,始终需要
条件 require() REPLTool, SleepTool 通过 feature() 门控,构建时可裁剪
懒加载 getter getTeamCreateTool() 打破循环依赖
环境变量控制 ConfigTool (USER_TYPE === 'ant') 仅内部用户可用

assembleToolPool():合并 + 排序

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/tools.ts:345
export function assembleToolPool(permissionContext, mcpTools): Tools {
const builtInTools = getTools(permissionContext)
const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext)

// 分区排序:内置工具作为连续前缀,MCP 工具紧随其后
// 这保证了 LLM 提示词缓存的稳定性
const byName = (a, b) => a.name.localeCompare(b.name)
return uniqBy(
[...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
'name', // 同名时内置工具优先
)
}

为什么分区排序很重要? API 服务端会在内置工具和 MCP 工具之间放置缓存断点。如果排序不稳定(MCP 工具插入到内置工具中间),每次 MCP 工具变化都会导致所有缓存失效。


4.6 工具目录结构:微模块化

每个工具是 src/tools/ 下的一个独立目录:

简单工具:FileReadTool(5 个文件,~70KB)

1
2
3
4
5
6
tools/FileReadTool/
├── FileReadTool.ts # 工具定义、call() 实现
├── prompt.ts # 提示词文本常量
├── limits.ts # 读取行数限制等配置
├── imageProcessor.ts # 图片处理逻辑
└── UI.tsx # 渲染组件

复杂工具:BashTool(20 个文件,~640KB)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
tools/BashTool/
├── BashTool.tsx # 工具定义、call() 实现(160KB)
├── bashPermissions.ts # 权限判断(98KB)
├── bashSecurity.ts # 安全分析——命令注入检测(102KB)
├── readOnlyValidation.ts # 只读命令识别(68KB)
├── pathValidation.ts # 路径校验(43KB)
├── prompt.ts # 提示词文本(21KB)
├── UI.tsx # 渲染组件(25KB)
├── BashToolResultMessage.tsx # 结果渲染(19KB)
├── sedEditParser.ts # sed 命令解析
├── sedValidation.ts # sed 命令安全校验
├── shouldUseSandbox.ts # 沙箱判断
├── commandSemantics.ts # 命令语义分析
├── modeValidation.ts # 模式校验
├── bashCommandHelpers.ts # 命令辅助函数
├── destructiveCommandWarning.ts # 危险命令警告
├── commentLabel.ts # 注释标签
├── utils.ts # 工具函数
└── toolName.ts # 名称常量

对比启示:BashTool 的代码量是 FileReadTool 的 9 倍,因为它需要分析任意 Shell 命令的安全性。这说明"执行任意命令"的工具在 Agent 系统中是安全挑战最大的部分。


4.7 工具执行管线:从 LLM 输出到工具结果

当 LLM 返回一个 tool_use 块时,它经历以下 8 步处理:

flowchart TD LLM["LLM 返回 tool_use 块
{name, id, input}"] --> A A["① 查找工具
findToolByName()"] --> B B["② Zod Schema 校验
inputSchema.safeParse(input)"] --> C C["③ 自定义校验
tool.validateInput()"] --> D D["④ PreToolUse 钩子
runPreToolUseHooks()"] --> E E["⑤ 权限检查
checkPermissions → canUseTool"] --> F F["⑥ 执行工具
tool.call(args, context, ...)"] --> G G["⑦ PostToolUse 钩子
runPostToolUseHooks()"] --> H H["⑧ 结果映射
mapToolResultToToolResultBlockParam()"] B -- "校验失败" --> Err1["返回 InputValidationError"] C -- "校验失败" --> Err2["返回自定义错误"] D -- "钩子拒绝" --> Err3["返回钩子拒绝消息"] E -- "权限拒绝" --> Err4["返回权限拒绝消息"] style A fill:#e1f5fe style F fill:#fff3e0 style H fill:#e8f5e9

核心函数:checkPermissionsAndCallTool()

这是 src/services/tools/toolExecution.ts:599 中约 700 行的函数,是工具执行的"大脑":

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
async function checkPermissionsAndCallTool(
tool: Tool,
toolUseID: string,
input: { [key: string]: boolean | string | number },
toolUseContext: ToolUseContext,
canUseTool: CanUseToolFn,
assistantMessage: AssistantMessage,
// ... more params
): Promise<MessageUpdateLazy[]> {

// ① Zod schema 校验
const parsedInput = tool.inputSchema.safeParse(input)
if (!parsedInput.success) {
return [{ message: createErrorMessage('InputValidationError', ...) }]
}

// ② 自定义校验
const isValidCall = await tool.validateInput?.(parsedInput.data, toolUseContext)
if (isValidCall?.result === false) {
return [{ message: createErrorMessage(isValidCall.message) }]
}

// ③ 投机启动 Bash 安全分类器(并行预热)
if (tool.name === 'Bash') {
startSpeculativeClassifierCheck(parsedInput.data.command, ...)
}

// ④ 输入回填(给钩子和权限系统看的副本,不影响 call())
const observableInput = { ...processedInput }
tool.backfillObservableInput?.(observableInput)

// ⑤ PreToolUse 钩子
const hookResults = await runPreToolUseHooks(...)

// ⑥ 权限检查 → 交互式确认
const permissionResult = await resolveHookPermissionDecision(...)

// ⑦ 调用工具
const result = await tool.call(callInput, context, canUseTool, assistantMessage, onProgress)

// ⑧ PostToolUse 钩子
yield* runPostToolUseHooks(...)

// ⑨ 结果映射
return tool.mapToolResultToToolResultBlockParam(result.data, toolUseID)
}

一个精妙的细节:第 ③ 步的"投机启动 Bash 安全分类器"。当检测到是 Bash 工具调用时,立即在后台启动安全分类,这样它可以与钩子执行和权限对话框并行,而不是串行等待。


4.8 StreamingToolExecutor:边流式边执行

StreamingToolExecutorsrc/services/tools/StreamingToolExecutor.ts,531 行)是工具执行的主引擎。它的核心创新是:LLM 还在流式输出时,已完成解析的工具就开始执行了

状态机

stateDiagram-v2 [*] --> queued : addTool() queued --> executing : processQueue()
并发条件满足 executing --> completed : tool.call() 完成 completed --> yielded : getCompletedResults()
按原始顺序交付 queued --> completed : 已被中止
(sibling_error/
user_interrupted) executing --> completed : 执行中被中止 note right of queued : 等待并发条件 note right of executing : tool 正在运行 note right of completed : 结果已就绪 note right of yielded : 已交付给 query()

核心类结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/services/tools/StreamingToolExecutor.ts:40
export class StreamingToolExecutor {
private tools: TrackedTool[] = [] // 工具队列
private hasErrored = false // Bash 错误标记
private siblingAbortController: AbortController // 兄弟工具取消控制器
private discarded = false // 流式回退标记
private progressAvailableResolve?: () => void // 进度通知信号

// 添加工具到队列,立即尝试执行
addTool(block: ToolUseBlock, assistantMessage: AssistantMessage): void

// 非阻塞获取已完成的结果
*getCompletedResults(): Generator<MessageUpdate, void>

// 等待所有工具完成并交付结果
async *getRemainingResults(): AsyncGenerator<MessageUpdate, void>

// 丢弃所有待执行工具(流式回退时)
discard(): void
}

与 query.ts 的集成时序

sequenceDiagram participant Q as query.ts participant STE as StreamingToolExecutor participant API as LLM API (流式) participant T1 as Tool A (并发安全) participant T2 as Tool B (并发安全) participant T3 as Tool C (非并发) Q->>API: 发送请求 API-->>Q: stream: tool_use A (开始) Q->>STE: addTool(A) STE->>T1: 开始执行 A API-->>Q: stream: tool_use B (开始) Q->>STE: addTool(B) STE->>T2: 开始执行 B(与 A 并行) Q->>STE: getCompletedResults() Note over STE: A 未完成,先交付进度消息 API-->>Q: stream: tool_use C (开始) Q->>STE: addTool(C) Note over STE: C 不可并发,排队等待 T1-->>STE: A 完成 Q->>STE: getCompletedResults() STE-->>Q: A 的结果 API-->>Q: stream end Q->>STE: getRemainingResults() T2-->>STE: B 完成 STE-->>Q: B 的结果 STE->>T3: A/B 都完成,开始 C T3-->>STE: C 完成 STE-->>Q: C 的结果

关键设计:结果按原始顺序交付

即使 Tool B 比 Tool A 先完成,结果也会按 LLM 输出的顺序交付:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// src/services/tools/StreamingToolExecutor.ts:412
*getCompletedResults(): Generator<MessageUpdate, void> {
for (const tool of this.tools) {
// 进度消息立即交付,不受顺序约束
while (tool.pendingProgress.length > 0) {
yield { message: tool.pendingProgress.shift()! }
}

if (tool.status === 'completed' && tool.results) {
tool.status = 'yielded'
for (const message of tool.results) {
yield { message }
}
} else if (tool.status === 'executing' && !tool.isConcurrencySafe) {
break // 非并发工具阻塞后续交付
}
}
}

4.9 工具编排的备选方案:toolOrchestration.ts

当流式工具执行被禁用时,系统回退到 src/services/tools/toolOrchestration.ts(189 行)的批量执行器:

flowchart TD Input["LLM 返回的 tool_use 列表"] --> Partition["partitionToolCalls()
按并发安全性分批"] Partition --> Batch1["批次 1: [Read, Grep, Glob]
并发安全 → 并行执行"] Partition --> Batch2["批次 2: [Edit]
非并发 → 串行执行"] Partition --> Batch3["批次 3: [Read, Read]
并发安全 → 并行执行"] Batch1 --> Run1["runToolsConcurrently()
Promise.race, 最大并发 10"] Batch2 --> Run2["runToolsSerially()
逐个执行"] Batch3 --> Run3["runToolsConcurrently()"]

分批规则(partitionToolCalls):

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/services/tools/toolOrchestration.ts:91
function partitionToolCalls(toolUseMessages, toolUseContext): Batch[] {
return toolUseMessages.reduce((acc, toolUse) => {
const isConcurrencySafe = tool?.isConcurrencySafe(parsedInput.data) ?? false
// 连续的并发安全工具合并为一批
if (isConcurrencySafe && acc[acc.length - 1]?.isConcurrencySafe) {
acc[acc.length - 1].blocks.push(toolUse)
} else {
acc.push({ isConcurrencySafe, blocks: [toolUse] })
}
return acc
}, [])
}

最大并发数通过环境变量 CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY 控制,默认 10


4.10 并发控制:哪些工具可以并行?

flowchart TD TC["工具调用到达"] --> Check{"isConcurrencySafe(input)?"} Check -- "true" --> CS["并发安全工具"] Check -- "false" --> NCS["非并发工具"] CS --> Rule1["可与其他并发安全工具并行"] NCS --> Rule2["必须独占执行"] subgraph ConcurrentSafe["并发安全工具示例"] direction LR G1["GlobTool ✓"] G2["GrepTool ✓"] G3["FileReadTool ✓"] G4["WebFetchTool ✓"] G5["WebSearchTool ✓"] G6["MCP (readOnlyHint) ✓"] end subgraph NotConcurrent["非并发工具示例"] direction LR N1["BashTool (写命令) ✗"] N2["FileEditTool ✗"] N3["FileWriteTool ✗"] N4["AgentTool ✗"] N5["NotebookEditTool ✗"] end CS -.-> ConcurrentSafe NCS -.-> NotConcurrent

特殊情况:BashTool 的动态判断

BashTool 的 isConcurrencySafe 不是固定的,而是根据命令内容动态判断:

  • git statusls -lacat file.txt可并发(只读命令)
  • npm installrm -rfgit commit不可并发(写入命令)

判断逻辑在 readOnlyValidation.ts(68KB)中,通过解析 Shell 命令的 AST 实现。

StreamingToolExecutor 的并发判定

1
2
3
4
5
6
7
8
9
// src/services/tools/StreamingToolExecutor.ts:129
private canExecuteTool(isConcurrencySafe: boolean): boolean {
const executingTools = this.tools.filter(t => t.status === 'executing')
return (
executingTools.length === 0 || // 无工具执行中 → 任何工具可启动
(isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
// 只有当前执行的全是并发安全的,且新工具也是并发安全的,才能并行
)
}

4.11 错误级联:Bash 错误取消兄弟工具

当多个工具并行执行时,如果一个 Bash 工具出错,会触发兄弟取消

sequenceDiagram participant STE as StreamingToolExecutor participant BashA as Bash: mkdir /tmp/a participant BashB as Bash: cd /tmp/a && npm init participant Read as Read: file.txt STE->>BashA: 开始执行 STE->>BashB: 开始执行 STE->>Read: 开始执行 BashA--xSTE: 错误: 权限拒绝 Note over STE: hasErrored = true
siblingAbortController.abort() STE--xBashB: 收到 abort 信号,取消 Note over STE: BashB 收到合成错误:
"Cancelled: parallel tool call
Bash(mkdir /tmp/a…) errored" Read-->>STE: 正常完成 Note over Read: Read 是非 Bash 工具
不会被 Bash 错误级联取消

为什么只有 Bash 错误会级联?

1
2
3
4
5
6
// src/services/tools/StreamingToolExecutor.ts:359
if (tool.block.name === BASH_TOOL_NAME) {
this.hasErrored = true
this.erroredToolDescription = this.getToolDescription(tool)
this.siblingAbortController.abort('sibling_error')
}

Bash 命令常有隐式依赖链(mkdir 失败 → 后续 cd 无意义),而 Read、WebFetch 等工具是独立的,一个失败不影响其他。


4.12 中断行为:用户新消息时怎么办?

当工具正在执行时用户提交了新消息,每个工具可以声明自己的中断行为:

行为 含义 默认值
'cancel' 停止工具,丢弃结果,接收合成错误消息 -
'block' 继续运行,用户新消息排队等待 (默认)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/services/tools/StreamingToolExecutor.ts:210
private getAbortReason(tool): 'sibling_error' | 'user_interrupted' | 'streaming_fallback' | null {
if (this.discarded) return 'streaming_fallback'
if (this.hasErrored) return 'sibling_error'
if (this.toolUseContext.abortController.signal.aborted) {
if (this.toolUseContext.abortController.signal.reason === 'interrupt') {
// 只取消声明了 'cancel' 行为的工具
return this.getToolInterruptBehavior(tool) === 'cancel'
? 'user_interrupted'
: null
}
return 'user_interrupted'
}
return null
}

4.13 权限系统概览

权限系统是工具执行管线中的"安全闸门",详细内容将在后续章节深入。这里先给出整体架构:

flowchart TD ToolCall["工具调用请求"] --> Step1 Step1["① 全局 Deny 规则
getDenyRuleForTool()"] -- 匹配 --> Deny["拒绝"] Step1 -- 不匹配 --> Step2 Step2["② 全局 Ask 规则
getAskRuleForTool()"] -- 匹配 --> Ask["询问用户"] Step2 -- 不匹配 --> Step3 Step3["③ 工具特定权限
tool.checkPermissions()"] --> Step4 Step4["④ 规则匹配
checkRuleBasedPermissions()"] --> Step5 Step5["⑤ 权限模式判断"] Step5 -- "bypassPermissions" --> Allow["放行"] Step5 -- "plan" --> PlanCheck{"只读工具?"} PlanCheck -- "是" --> Allow PlanCheck -- "否" --> Ask Step5 -- "default" --> Ask Step5 -- "auto" --> Classifier["AI 安全分类器"] Classifier --> Allow Classifier --> Ask

五种权限模式:

模式 行为 典型场景
default 危险操作询问用户 日常使用
plan 只读自动通过,写入需确认 规划阶段
acceptEdits 文件编辑自动通过,Shell 仍询问 信任文件修改
bypassPermissions 全部放行 完全信任(CI/自动化)
auto AI 分类器自动判断 实验性功能

4.14 工具钩子:Pre/Post 扩展点

工具钩子(src/services/tools/toolHooks.ts)提供了三个扩展点:

flowchart LR Pre["PreToolUse 钩子
───────────
· 允许/拒绝/询问
· 修改输入
· 添加上下文
· 阻止执行"] --> Exec["tool.call()
执行工具"] Exec --> Post["PostToolUse 钩子
───────────
· 阻止后续循环
· 修改 MCP 输出
· 添加上下文"] Exec -- "失败" --> PostFail["PostToolUseFailure 钩子
───────────
· 记录失败信息
· 清理副作用"]

钩子与权限的交互规则resolveHookPermissionDecision):

1
2
3
4
钩子说 allow + 规则说 deny  →  deny 胜出(安全优先)
钩子说 deny → 立即拒绝
钩子说 ask → 传递给交互式对话框
钩子无意见 → 走标准权限流程

这个"deny 总是胜出"的设计确保了:即使第三方钩子试图绕过安全规则,系统也能保持安全。


4.15 MCP 工具:动态扩展边界

MCP(Model Context Protocol)让 Claude Code 可以动态接入外部工具服务

flowchart TD subgraph MCPServer["MCP 服务器(外部进程)"] S1["GitHub MCP Server"] S2["Jira MCP Server"] S3["自定义 MCP Server"] end subgraph ClaudeCode["Claude Code"] Client["MCP Client
services/mcp/client.ts"] Template["MCPTool 模板
tools/MCPTool/MCPTool.ts"] Fetch["fetchToolsForClient()
发现并创建工具"] Pool["assembleToolPool()
合并到工具池"] end S1 --> Client S2 --> Client S3 --> Client Client --> Fetch Fetch --> Template Template --> Pool Pool --> LLM["传给 LLM 选择"]

MCP 工具的创建过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/services/mcp/client.ts (fetchToolsForClient)
// 1. 调用 MCP 服务器的 tools/list
const serverTools = await client.listTools()

// 2. 为每个服务器工具创建 Tool 对象
const mcpTool = {
...MCPTool, // 展开模板
name: `mcp__${serverName}__${toolName}`, // 命名规则
mcpInfo: { serverName, toolName },
isConcurrencySafe: () => annotations.readOnlyHint ?? false,
isReadOnly: () => annotations.readOnlyHint ?? false,
isDestructive: () => annotations.destructiveHint ?? false,
inputJSONSchema: serverTool.inputSchema, // 直接用 JSON Schema
checkPermissions: () => ({ behavior: 'passthrough' }),
call: async (args) => callMCPToolWithRetry(...)
}

命名规则:mcp__<服务器名>__<工具名>,例如 mcp__github__create_prmcp__jira__list_issues


4.16 ToolSearch:延迟加载大量工具

当工具数量很多(内置 + MCP 可能超过 50 个)时,把所有工具的完整 schema 放入提示词会浪费 token。ToolSearch 实现了延迟加载

flowchart TD Start["LLM 第一轮
只看到核心工具 + ToolSearch"] Start --> Need{"需要特定工具?"} Need -- "是" --> Search["调用 ToolSearch
query: 'select:NotebookEdit'
或关键词搜索"] Search --> Load["加载完整 schema
加入 discovered 集合"] Load --> Use["下一轮可以使用该工具"] Need -- "否" --> Direct["直接用已加载的工具"]

延迟加载的工具标记了 shouldDefer: true。当 LLM 试图直接调用一个延迟工具(未先通过 ToolSearch 发现),系统会返回一个友好的错误提示:

1
2
3
This tool's schema was not sent to the API — it was not in the discovered-tool set.
Without the schema, typed parameters get emitted as strings and the parser rejects them.
Load the tool first: call ToolSearch with query "select:NotebookEdit", then retry.

4.17 子 Agent 的工具限制

当 AgentTool 启动子 Agent 时,子 Agent 不能使用所有工具

flowchart TD subgraph Main["主 Agent 工具池"] direction LR All["全部 40+ 工具"] end subgraph SubAgent["子 Agent(同步)工具池"] direction LR Sub["主工具池 - 禁用工具"] end subgraph AsyncAgent["子 Agent(异步)工具池"] direction LR Async["仅白名单工具"] end subgraph Coordinator["协调者模式工具池"] direction LR Coord["Agent + TaskStop
+ SendMessage
+ SyntheticOutput"] end Main --> SubAgent Main --> AsyncAgent Main --> Coordinator

三种工具限制集:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/constants/tools.ts

// 所有子 Agent 都禁用的工具
ALL_AGENT_DISALLOWED_TOOLS = [
TaskOutput, ExitPlanMode, EnterPlanMode,
AskUserQuestion, TaskStop,
Agent // 防递归(ant 用户除外)
]

// 异步 Agent 允许的工具(白名单)
ASYNC_AGENT_ALLOWED_TOOLS = [
Read, WebSearch, TodoWrite, Grep, WebFetch, Glob,
Bash/PowerShell, Edit, Write, NotebookEdit,
Skill, ToolSearch, EnterWorktree, ExitWorktree
]

// 协调者模式允许的工具(仅管理类)
COORDINATOR_MODE_ALLOWED_TOOLS = [
Agent, TaskStop, SendMessage, SyntheticOutput
]

设计原因

  • 禁用 Agent 防止递归无限创建子 Agent
  • 禁用 AskUserQuestion 因为子 Agent 无法与用户交互
  • 禁用 ExitPlanModeEnterPlanMode 因为计划模式是主线程的抽象

4.18 工具结果处理:大结果持久化

工具的输出可能非常大(比如 cat 一个大文件)。为了避免撑爆上下文窗口,系统有大结果持久化机制:

flowchart TD Result["工具返回结果"] --> Check{"结果大小 >
maxResultSizeChars?"} Check -- "否" --> Direct["直接放入对话消息"] Check -- "是" --> Persist["持久化到磁盘文件"] Persist --> Preview["返回预览 + 文件路径
给 LLM 看摘要"]

每个工具可以设置不同的 maxResultSizeChars

工具 maxResultSizeChars 说明
FileReadTool Infinity 永不持久化(自身已有行数限制)
BashTool 100_000 Shell 输出可能很大
GrepTool 100_000 搜索结果可能很多
一般工具 100_000 默认值

FileReadTool 设为 Infinity 有特殊原因:如果持久化 Read 的结果到文件,LLM 可能会试图 Read 那个持久化文件,形成 Read → file → Read 循环


4.19 工具分类体系

Claude Code 从多个维度对工具进行分类,每个维度影响不同的行为:

flowchart TD Tool["一个工具"] --> D1["并发安全性
isConcurrencySafe()"] Tool --> D2["读写性
isReadOnly()"] Tool --> D3["破坏性
isDestructive()"] Tool --> D4["搜索/读取分类
isSearchOrReadCommand()"] Tool --> D5["加载策略
shouldDefer / alwaysLoad"] Tool --> D6["中断行为
interruptBehavior()"] D1 --> E1["影响:并行 vs 串行执行"] D2 --> E2["影响:权限检查严格程度"] D3 --> E3["影响:MCP 工具标注"] D4 --> E4["影响:UI 折叠/分组显示"] D5 --> E5["影响:是否需要 ToolSearch"] D6 --> E6["影响:用户中断时的处理"]

搜索/读取分类(UI 折叠)

1
2
3
4
5
isSearchOrReadCommand?(input): {
isSearch: boolean // grep、find、rg → 搜索操作
isRead: boolean // cat、head、tail → 读取操作
isList?: boolean // ls、tree、du → 目录列举
}

这个分类决定了 UI 中工具调用的折叠显示行为——连续的多个搜索/读取操作可以合并为一个紧凑的条目,避免刷屏。


4.20 典型工具剖析:FileReadTool vs BashTool

FileReadTool:简洁的只读工具

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// src/tools/FileReadTool/FileReadTool.ts(简化)
export const FileReadTool = buildTool({
name: 'Read',
maxResultSizeChars: Infinity, // 永不持久化

get inputSchema() {
return z.strictObject({
file_path: z.string().describe('绝对路径'),
offset: z.number().optional().describe('起始行号'),
limit: z.number().optional().describe('读取行数'),
pages: z.string().optional().describe('PDF 页码范围'),
})
},

isConcurrencySafe: () => true, // ← 并发安全
isReadOnly: () => true, // ← 只读

async call(args, context) {
const content = await readFileInRange(args.file_path, args.offset, args.limit)
return { data: addLineNumbers(content) }
},

async checkPermissions(input, context) {
return checkReadPermissionForTool(input.file_path, context)
},
})

FileReadTool 的特点:简单、安全、可并发

BashTool:最复杂的工具

BashTool(160KB)的复杂性来自它需要处理任意 Shell 命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
BashTool 的内部分层:
┌──────────────────────────────────────┐
│ 命令解析层 (bashCommandHelpers.ts) │
│ 解析 Shell AST,识别命令类型 │
├──────────────────────────────────────┤
│ 安全分析层 (bashSecurity.ts, 102KB) │
│ 命令注入检测、危险模式识别 │
├──────────────────────────────────────┤
│ 只读判断层 (readOnlyValidation.ts) │
│ 判断命令是否只读(影响并发策略) │
├──────────────────────────────────────┤
│ 路径校验层 (pathValidation.ts) │
│ 确保不操作工作目录外的文件 │
├──────────────────────────────────────┤
│ 权限判断层 (bashPermissions.ts) │
│ 结合规则和分类器判断是否允许 │
├──────────────────────────────────────┤
│ 沙箱层 (shouldUseSandbox.ts) │
│ 决定是否在沙箱中执行 │
├──────────────────────────────────────┤
│ 执行层 (BashTool.tsx) │
│ spawn 子进程、超时控制、输出捕获 │
├──────────────────────────────────────┤
│ sed 特殊处理 (sedEditParser.ts) │
│ 将 sed 编辑转换为可审查的 diff │
└──────────────────────────────────────┘

BashTool 的 isConcurrencySafe动态的

1
2
3
4
5
6
7
8
isConcurrencySafe(input) {
// 解析命令,判断是否只读
return isReadOnlyCommand(input.command)
// "git status" → true
// "npm install" → false
// "ls -la | grep foo" → true
// "echo hi > file.txt" → false
}

4.21 关键工程思想总结

思想 1:工具即数据(Tool as Data)

工具不是类实例,而是符合接口的普通对象。这让工具的注册、过滤、排序、序列化都很简单:

1
2
3
4
5
6
7
8
// 过滤只读工具
tools.filter(t => t.isReadOnly(input))

// 按名称排序(缓存稳定性)
tools.sort((a, b) => a.name.localeCompare(b.name))

// 按名称去重(内置优先于 MCP)
uniqBy(tools, 'name')

思想 2:Fail-Closed 默认值

新工具忘记声明并发安全性时,系统假设它不安全。宁可降低性能(串行执行),不冒数据风险。

思想 3:投机并行(Speculative Parallelism)

1
2
3
4
5
Bash 工具调用到达
├── 立即启动安全分类器(后台)
├── 同时执行 PreToolUse 钩子
├── 同时准备权限对话框
└── 全部完成后,分类器结果已就绪

安全分类器的启动不等待前序步骤,而是投机并行运行,减少用户等待时间。

思想 4:有选择的错误级联

只有 Bash 错误取消兄弟工具,因为 Bash 命令常有隐式依赖链。而 Read、Grep 等独立工具的错误不影响其他。这是对领域知识的编码——不是所有错误都一样严重。

思想 5:分区排序保证缓存稳定

工具列表的排序策略(内置工具连续排列在前,MCP 工具在后)不是为了好看,而是为了 LLM 提示词缓存的命中率。如果排序不稳定,每次 MCP 配置变化都会让所有用户的缓存失效。

思想 6:延迟加载减少 Token 消耗

ToolSearch 机制让 LLM 首轮只看到核心工具,需要时再加载其他工具。这在工具数量很多时(40+ 内置 + N 个 MCP)显著节省了提示词的 token 消耗。

思想 7:渲染与执行同源

每个工具自带渲染方法(renderToolUseMessagerenderToolResultMessage 等)。这避免了"在一个地方加工具,在另一个地方加 UI"的分裂,但也意味着每个工具需要同时理解 React 和业务逻辑。

思想 8:钩子不可绕过安全规则

PreToolUse 钩子可以 allow/deny/ask,但 deny 规则总是胜出。即使第三方插件的钩子返回 allow,如果安全规则说 deny,最终结果还是 deny。安全策略是不可商量的。

思想 9:统一接口消除内外差异

内置工具和 MCP 工具共用同一个 Tool 接口。对 LLM 来说,Readmcp__github__create_pr 没有本质区别——都是可以调用的工具。这种统一让工具池的管理、排序、权限检查都只需一套逻辑。

思想 10:微模块化组织

每个工具是独立的目录,内部按职责拆分文件(定义、权限、安全、UI、提示词)。这让不同的工具可以独立发展,一个工具的修改不影响其他。BashTool 可以有 20 个文件 640KB,FileReadTool 只需 5 个文件 70KB,各取所需。


下一章预告:第五章将深入权限与安全系统——Claude Code 如何在"让 Agent 自由行动"和"保护用户安全"之间找到平衡。