模块四:工具系统 | 前置依赖:第 07 课 | 预计学习时间:75 分钟
学习目标
完成本课后,你将能够:
- 完整描述
Tool<Input, Output, ProgressData>泛型接口的 30+ 个方法与属性 - 解释
buildTool()工厂函数的默认值策略与类型推导机制 - 说明
ToolUseContext的完整字段结构及其在工具执行中的角色 - 理解
findToolByName()的别名查找机制 - 按职责分类工具接口的所有方法:执行、权限、展示、元数据
10.1 Tool.ts 的地位
Tool.ts 是整个工具系统的"宪法"。所有 40+ 种工具(Bash、FileEdit、Glob、Grep、Agent、MCP 等)都实现这一个接口。理解了 Tool.ts,你就理解了:
- Claude Code 能做什么(
call方法) - 能不能做(
checkPermissions方法) - 如何呈现(
render*方法系列) - 安全属性(
isReadOnly、isDestructive方法)
Tool.ts 约 800 行,包含:
├── ToolInputJSONSchema 类型
├── ToolUseContext 类型(约 150 行)
├── ToolPermissionContext 类型
├── Tool<Input, Output, P> 泛型接口(约 340 行)
├── ToolDef 类型(buildTool 的输入)
├── buildTool() 工厂函数
├── findToolByName() 查找函数
├── toolMatchesName() 匹配函数
└── 各种辅助类型(ToolResult、ToolProgress、ValidationResult 等)
10.2 Tool 泛型接口 — 三个类型参数
export type Tool<
Input extends AnyObject = AnyObject,
Output = unknown,
P extends ToolProgressData = ToolProgressData,
> = {
// ... 30+ 个方法和属性
}
| 参数 | 约束 | 含义 | 示例 |
|---|---|---|---|
Input | extends AnyObject (Zod schema) | 工具输入的校验 schema | z.object({ file_path: z.string() }) |
Output | 无约束 | 工具执行返回值类型 | { content: string; lineCount: number } |
P | extends ToolProgressData | 进度消息数据类型 | BashProgress |
AnyObject 是 z.ZodType<{ [key: string]: unknown }> —— 任何 Zod schema 只要输出一个对象即可。
10.3 方法分类 — 五大职责
分类一:核心执行(3 个方法)
call — 工具执行
call(
args: z.infer<Input>, // Zod schema 推导的输入类型
context: ToolUseContext, // 执行上下文
canUseTool: CanUseToolFn, // 权限检查函数
parentMessage: AssistantMessage, // 触发此工具的 assistant 消息
onProgress?: ToolCallProgress<P>, // 进度回调
): Promise<ToolResult<Output>>
返回值 ToolResult<Output> 包含:
export type ToolResult<T> = {
data: T // 工具输出数据
newMessages?: (UserMessage | AssistantMessage | AttachmentMessage | SystemMessage)[]
contextModifier?: (context: ToolUseContext) => ToolUseContext // 修改上下文
mcpMeta?: { _meta?: Record<string, unknown>; structuredContent?: Record<string, unknown> }
}
contextModifier 允许工具执行后修改上下文 —— 但只对非并发安全的工具生效。
validateInput — 输入校验
validateInput?(
input: z.infer<Input>,
context: ToolUseContext,
): Promise<ValidationResult>
在权限检查之前调用。返回 { result: true } 或 { result: false, message: string, errorCode: number }。
export type ValidationResult =
| { result: true }
| { result: false; message: string; errorCode: number }
checkPermissions — 权限检查
checkPermissions(
input: z.infer<Input>,
context: ToolUseContext,
): Promise<PermissionResult>
在 validateInput 通过后调用。这是工具特定的权限逻辑(通用权限逻辑在 permissions.ts 中)。
分类二:元数据与属性(15+ 个方法/属性)
基本信息
readonly name: string // 工具名称
aliases?: string[] // 别名(重命名后的向后兼容)
searchHint?: string // ToolSearch 的关键词提示
readonly inputSchema: Input // Zod 输入 schema
readonly inputJSONSchema?: ToolInputJSONSchema // MCP 工具直接提供 JSON Schema
outputSchema?: z.ZodType<unknown> // 输出 schema(可选)
安全属性
isReadOnly(input: z.infer<Input>): boolean
// 是否只读。Read、Glob、Grep 返回 true。
// 注意:接收 input 参数 — 同一工具可能根据输入不同而不同
isDestructive?(input: z.infer<Input>): boolean
// 是否破坏性。Delete、Overwrite 等返回 true。默认 false。
isConcurrencySafe(input: z.infer<Input>): boolean
// 是否并发安全。决定能否与其他工具并行执行。默认 false(保守)。
isEnabled(): boolean
// 是否启用。可根据 feature gate、环境变量等动态决定。默认 true。
高级属性
isOpenWorld?(input: z.infer<Input>): boolean
// 是否是"开放世界"操作(如网络请求)
requiresUserInteraction?(): boolean
// 是否需要用户交互(如 AskUser 工具)
isMcp?: boolean // 是否是 MCP 工具
isLsp?: boolean // 是否是 LSP 工具
readonly shouldDefer?: boolean // 是否延迟加载(ToolSearch)
readonly alwaysLoad?: boolean // 是否始终加载(不延迟)
readonly strict?: boolean // 是否启用严格模式
maxResultSizeChars: number // 结果最大字符数(超过则持久化到磁盘)
mcpInfo?: { serverName: string; toolName: string } // MCP 工具元数据
中断行为
interruptBehavior?(): 'cancel' | 'block'
// 用户提交新消息时:
// 'cancel' — 停止工具并丢弃结果
// 'block' — 继续运行,新消息排队等待
// 默认 'block'
输入比较
inputsEquivalent?(a: z.infer<Input>, b: z.infer<Input>): boolean
// 判断两个输入是否"等价"(用于投机执行的缓存命中)
分类三:展示方法(10 个方法)
这些方法负责工具在终端 UI 中的展示。每个工具可以自定义自己的渲染方式。
核心渲染
// 渲染工具调用消息("正在执行 Read src/index.ts")
renderToolUseMessage(
input: Partial<z.infer<Input>>, // Partial 因为可能还在流式接收中
options: { theme: ThemeName; verbose: boolean; commands?: Command[] },
): React.ReactNode
// 渲染工具结果消息
renderToolResultMessage?(
content: Output,
progressMessagesForMessage: ProgressMessage<P>[],
options: { style?: 'condensed'; theme: ThemeName; verbose: boolean; ... },
): React.ReactNode
// 渲染执行进度
renderToolUseProgressMessage?(
progressMessagesForMessage: ProgressMessage<P>[],
options: { tools: Tools; verbose: boolean; ... },
): React.ReactNode
特殊渲染
// 渲染拒绝消息(自定义拒绝 UI)
renderToolUseRejectedMessage?(input, options): React.ReactNode
// 渲染错误消息(自定义错误 UI)
renderToolUseErrorMessage?(result, options): React.ReactNode
// 渲染排队消息
renderToolUseQueuedMessage?(): React.ReactNode
// 渲染标签(工具名后面的附加信息)
renderToolUseTag?(input): React.ReactNode
// 批量渲染(将多个同类工具调用合并显示)
renderGroupedToolUse?(toolUses, options): React.ReactNode | null
辅助展示
// 用户可见的工具名称
userFacingName(input: Partial<z.infer<Input>> | undefined): string
// 工具名称背景色
userFacingNameBackgroundColor?(input): keyof Theme | undefined
// 简短摘要
getToolUseSummary?(input): string | null
// 活动描述(用于 spinner)
getActivityDescription?(input): string | null
// 结果是否被截断(控制点击展开行为)
isResultTruncated?(output: Output): boolean
// 是否为透明包装器(不显示自身)
isTransparentWrapper?(): boolean
分类四:API 交互(4 个方法)
// 生成工具描述(发送给 Claude 的工具说明)
description(
input: z.infer<Input>,
options: { isNonInteractiveSession: boolean; ... },
): Promise<string>
// 生成工具提示词
prompt(options: {
getToolPermissionContext: () => Promise<ToolPermissionContext>
tools: Tools
agents: AgentDefinition[]
allowedAgentTypes?: string[]
}): Promise<string>
// 将工具结果转为 API 格式
mapToolResultToToolResultBlockParam(
content: Output,
toolUseID: string,
): ToolResultBlockParam
// 为自动模式分类器生成输入
toAutoClassifierInput(input: z.infer<Input>): unknown
分类五:Hooks 与搜索(4 个方法)
// 准备权限匹配器(用于 hook 的 if 条件)
preparePermissionMatcher?(
input: z.infer<Input>,
): Promise<(pattern: string) => boolean>
// 获取文件路径(用于文件操作工具)
getPath?(input: z.infer<Input>): string
// 回填可观察输入(为 SDK/transcript 添加派生字段)
backfillObservableInput?(input: Record<string, unknown>): void
// 提取搜索文本(用于 transcript 搜索索引)
extractSearchText?(out: Output): string
// 判断是否为搜索/读取命令(用于 UI 折叠)
isSearchOrReadCommand?(input: z.infer<Input>): {
isSearch: boolean; isRead: boolean; isList?: boolean
}
10.4 buildTool — 工厂函数与默认值
直接构造一个完整的 Tool 对象需要实现 30+ 个方法,大部分工具的很多方法行为相同。buildTool() 提供了合理的默认值:
const TOOL_DEFAULTS = {
isEnabled: () => true,
isConcurrencySafe: (_input?: unknown) => false,
isReadOnly: (_input?: unknown) => false,
isDestructive: (_input?: unknown) => false,
checkPermissions: (input, _ctx?) =>
Promise.resolve({ behavior: 'allow', updatedInput: input }),
toAutoClassifierInput: (_input?: unknown) => '',
userFacingName: (_input?: unknown) => '',
}
默认值的设计哲学 —— fail-closed(安全优先):
| 方法 | 默认值 | 含义 |
|---|---|---|
isEnabled | true | 工具默认启用 |
isConcurrencySafe | false | 默认假设不能并行(安全) |
isReadOnly | false | 默认假设会写入(安全) |
isDestructive | false | 默认不是破坏性的 |
checkPermissions | allow | 交给通用权限系统处理 |
toAutoClassifierInput | '' | 不参与安全分类(需要的工具必须自己覆盖) |
export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
return {
...TOOL_DEFAULTS,
userFacingName: () => def.name, // 默认用工具名
...def, // 用户定义覆盖默认值
} as BuiltTool<D>
}
ToolDef — buildTool 的输入类型
export type ToolDef<Input, Output, P> =
Omit<Tool<Input, Output, P>, DefaultableToolKeys> &
Partial<Pick<Tool<Input, Output, P>, DefaultableToolKeys>>
翻译成人话:ToolDef 是 Tool 接口中,把 isEnabled、isConcurrencySafe、isReadOnly、isDestructive、checkPermissions、toAutoClassifierInput、userFacingName 这 7 个方法变为可选的版本。
类型推导魔法
buildTool 的类型系统设计相当精巧:
type BuiltTool<D> = Omit<D, DefaultableToolKeys> & {
[K in DefaultableToolKeys]-?: K extends keyof D
? undefined extends D[K]
? ToolDefaults[K] // D 中可选 → 使用默认值类型
: D[K] // D 中必选 → 使用 D 的类型
: ToolDefaults[K] // D 中不存在 → 使用默认值类型
}
这意味着:
- 如果工具定义了
isReadOnly,返回类型保留工具的实现 - 如果没定义,返回类型自动变为默认值的类型
- 60+ 个工具都通过了类型检查,证明了这个类型系统的正确性
10.5 findToolByName — 工具查找
export function toolMatchesName(
tool: { name: string; aliases?: string[] },
name: string,
): boolean {
return tool.name === name || (tool.aliases?.includes(name) ?? false)
}
export function findToolByName(tools: Tools, name: string): Tool | undefined {
return tools.find(t => toolMatchesName(t, name))
}
别名机制 支持工具重命名后的向后兼容:
Claude 返回 tool_use: { name: "OldToolName" }
│
▼ findToolByName(tools, "OldToolName")
│
▼ 遍历工具列表
│ ├── tool.name === "OldToolName"? → 没找到
│ └── tool.aliases?.includes("OldToolName")? → 找到了!
│
▼ 返回工具实例
Tools 是一个只读数组类型:
export type Tools = readonly Tool[]
10.6 ToolUseContext — 工具执行的完整上下文
ToolUseContext 是工具执行时的"环境变量包",约 150 行的类型定义:
export type ToolUseContext = {
// === 配置选项 ===
options: {
commands: Command[] // 可用命令列表
debug: boolean
mainLoopModel: string // 当前模型
tools: Tools // 所有已注册工具
verbose: boolean
thinkingConfig: ThinkingConfig
mcpClients: MCPServerConnection[]
mcpResources: Record<string, ServerResource[]>
isNonInteractiveSession: boolean
agentDefinitions: AgentDefinitionsResult
maxBudgetUsd?: number // 预算限制
customSystemPrompt?: string
appendSystemPrompt?: string
refreshTools?: () => Tools // 刷新工具列表
}
// === 控制机制 ===
abortController: AbortController // 中断控制
readFileState: FileStateCache // 文件读取状态缓存
// === 状态管理 ===
getAppState(): AppState
setAppState(f: (prev: AppState) => AppState): void
setAppStateForTasks?: (f: (prev: AppState) => AppState) => void
// === UI 交互 ===
setToolJSX?: SetToolJSXFn
addNotification?: (notif: Notification) => void
appendSystemMessage?: (msg: SystemMessage) => void
sendOSNotification?: (opts: { message: string; ... }) => void
setInProgressToolUseIDs: (f: (prev: Set<string>) => Set<string>) => void
setHasInterruptibleToolInProgress?: (v: boolean) => void
setResponseLength: (f: (prev: number) => number) => void
setStreamMode?: (mode: SpinnerMode) => void
openMessageSelector?: () => void
// === 记忆与附件 ===
nestedMemoryAttachmentTriggers?: Set<string>
loadedNestedMemoryPaths?: Set<string>
dynamicSkillDirTriggers?: Set<string>
discoveredSkillNames?: Set<string>
// === 文件追踪 ===
updateFileHistoryState: (updater: (prev: FileHistoryState) => FileHistoryState) => void
updateAttributionState: (updater: (prev: AttributionState) => AttributionState) => void
// === Agent 相关 ===
agentId?: AgentId // 子 Agent ID(主线程为 undefined)
agentType?: string
messages: Message[] // 当前消息列表
queryTracking?: QueryChainTracking
// === 权限相关 ===
toolDecisions?: Map<string, { source: string; decision: 'accept' | 'reject'; timestamp: number }>
requestPrompt?: (sourceName: string) => (request: PromptRequest) => Promise<PromptResponse>
requireCanUseTool?: boolean
localDenialTracking?: DenialTrackingState
// === 高级功能 ===
handleElicitation?: (serverName: string, params: ..., signal: ...) => Promise<ElicitResult>
setConversationId?: (id: UUID) => void
contentReplacementState?: ContentReplacementState
renderedSystemPrompt?: SystemPrompt
pushApiMetricsEntry?: (ttftMs: number) => void
onCompactProgress?: (event: CompactProgressEvent) => void
setSDKStatus?: (status: SDKStatus) => void
userModified?: boolean
preserveToolUseResults?: boolean
criticalSystemReminder_EXPERIMENTAL?: string
fileReadingLimits?: { maxTokens?: number; maxSizeBytes?: number }
globLimits?: { maxResults?: number }
toolUseId?: string
}
为什么 ToolUseContext 这么大?
它承担了"工具执行环境的全部依赖注入"角色。一个工具在执行时可能需要:
Bash 工具需要什么?
├── options.mainLoopModel → 决定是否在非交互模式
├── abortController → 支持用户中断
├── readFileState → 追踪已读文件
├── setAppState → 更新全局状态
├── addNotification → 通知用户
├── updateFileHistoryState → 追踪文件修改历史
├── agentId → 区分主线程和子 Agent
└── messages → 参考历史上下文
而不是让每个工具独立获取这些依赖,统一通过 ToolUseContext 传入 —— 依赖注入。
getEmptyToolPermissionContext
export const getEmptyToolPermissionContext: () => ToolPermissionContext = () => ({
mode: 'default',
additionalWorkingDirectories: new Map(),
alwaysAllowRules: {},
alwaysDenyRules: {},
alwaysAskRules: {},
isBypassPermissionsModeAvailable: false,
})
ToolPermissionContext 定义了权限模式和规则。DeepImmutable 包装保证它不会被意外修改。
10.7 工具执行的完整生命周期
把所有部分串起来:
Claude 返回 tool_use 块
│
▼ findToolByName(tools, block.name) → 查找工具
│
▼ tool.validateInput?(input, context) → 输入校验
│ └── ValidationResult: true 或 { false, message, errorCode }
│
▼ tool.checkPermissions(input, context) → 工具特定权限
│ └── PermissionResult: allow / deny / ask
│
▼ 通用权限系统检查(permissions.ts)
│ ├── alwaysAllowRules 检查
│ ├── alwaysDenyRules 检查
│ ├── auto-mode 分类器(如果开启)
│ └── 用户交互确认(如果需要)
│
▼ tool.call(input, context, canUseTool, parentMessage, onProgress)
│ ├── 执行实际操作
│ ├── 通过 onProgress 报告进度
│ └── 返回 ToolResult<Output>
│
▼ tool.mapToolResultToToolResultBlockParam(result.data, toolUseID)
│ └── 转换为 API 格式
│
▼ 结果追加到消息列表
│
▼ 如果有 contextModifier → 更新 ToolUseContext
10.8 设计洞察
为什么 isReadOnly 接收 input?
isReadOnly(input: z.infer<Input>): boolean
同一个 Bash 工具,ls 是只读的,rm 不是。所以安全属性必须依赖于具体输入。
为什么 renderToolUseMessage 接收 Partial 输入?
renderToolUseMessage(
input: Partial<z.infer<Input>>, // ← Partial!
options: { ... },
): React.ReactNode
因为 Claude 的响应是流式的。当 UI 需要渲染"正在调用 Read..."时,输入参数可能还没完全传输完成。Partial 允许 UI 在只有部分参数时就开始渲染。
maxResultSizeChars 与磁盘持久化
maxResultSizeChars: number
// 设为 Infinity:结果永远不持久化(如 Read 工具 — 避免循环读取)
// 设为有限值:超过后保存到文件,Claude 收到预览 + 文件路径
这解决了工具结果过大撑爆上下文的问题。Bash 命令可能输出 MB 级别的内容,不能全部放进对话历史。
课后练习
练习 1:实现一个最小工具
使用 buildTool 实现一个 "CurrentTime" 工具,它返回当前时间。需要定义:
nameinputSchema(空输入)call方法prompt方法description方法renderToolUseMessage方法mapToolResultToToolResultBlockParam方法
思考:你不需要实现哪些方法?buildTool 的默认值帮你处理了什么?
练习 2:安全属性分析
对以下 5 个工具,分析它们的 isReadOnly、isDestructive、isConcurrencySafe 应该返回什么:
- Glob(搜索文件名)
- FileEdit(编辑文件)
- Bash(执行命令)—— 分别考虑
ls和rm -rf - Agent(启动子 Agent)
- AskUser(向用户提问)
练习 3:ToolUseContext 精简
ToolUseContext 有 40+ 个字段。如果你要将它拆分为更小的接口,你会怎么分?提示:考虑按以下维度分组:
- 配置 vs 运行时
- 必选 vs 可选
- 主线程 vs 子 Agent
练习 4:工具重命名方案
假设你需要将 "Read" 工具重命名为 "FileRead":
- 如何利用
aliases机制保持向后兼容? - 已存在的会话历史中引用 "Read" 的消息,恢复时会怎样?
- 权限规则中引用 "Read" 的条目需要更新吗?
- MCP 工具有自己的命名空间(
mcp__server__tool),它们的重命名有什么特殊之处?
本课小结
| 要点 | 内容 |
|---|---|
| 泛型参数 | Tool<Input, Output, P> — 输入 schema、输出类型、进度数据类型 |
| 方法总数 | 30+ 个方法/属性,分 5 类:执行、元数据、展示、API 交互、Hooks |
| buildTool | 工厂函数,7 个默认值(fail-closed 安全策略) |
| findToolByName | 支持主名称 + aliases 别名查找 |
| ToolUseContext | 40+ 字段的依赖注入上下文,串联工具执行全生命周期 |
| ToolResult | { data, newMessages?, contextModifier?, mcpMeta? } |
| Partial input | 渲染方法接收 Partial 输入以支持流式渲染 |
| maxResultSizeChars | 控制结果大小,超限则持久化到磁盘 |
| isReadOnly(input) | 安全属性依赖具体输入,不是工具级别的静态属性 |
下一课预告
第 11 课:tools.ts — 工具注册与动态加载 — 有了接口定义,下一步是看 40+ 种工具如何被注册、过滤、和加载。我们将深入 tools.ts 工具注册表,理解 Feature Gate 如何控制工具可见性,MCP 工具如何动态注册,以及 ToolSearch(延迟加载)如何减少初始 prompt 大小。从"定义工具"到"工具可用",中间经过了多少层?