第 10 课:Tool.ts — 工具接口设计

3 阅读11分钟

模块四:工具系统 | 前置依赖:第 07 课 | 预计学习时间:75 分钟


学习目标

完成本课后,你将能够:

  1. 完整描述 Tool<Input, Output, ProgressData> 泛型接口的 30+ 个方法与属性
  2. 解释 buildTool() 工厂函数的默认值策略与类型推导机制
  3. 说明 ToolUseContext 的完整字段结构及其在工具执行中的角色
  4. 理解 findToolByName() 的别名查找机制
  5. 按职责分类工具接口的所有方法:执行、权限、展示、元数据

10.1 Tool.ts 的地位

Tool.ts 是整个工具系统的"宪法"。所有 40+ 种工具(Bash、FileEdit、Glob、Grep、Agent、MCP 等)都实现这一个接口。理解了 Tool.ts,你就理解了:

  • Claude Code 能做什么call 方法)
  • 能不能做checkPermissions 方法)
  • 如何呈现render* 方法系列)
  • 安全属性isReadOnlyisDestructive 方法)
Tool.ts800 行,包含:
├── 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+ 个方法和属性
}
参数约束含义示例
Inputextends AnyObject (Zod schema)工具输入的校验 schemaz.object({ file_path: z.string() })
Output无约束工具执行返回值类型{ content: string; lineCount: number }
Pextends ToolProgressData进度消息数据类型BashProgress

AnyObjectz.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(安全优先):

方法默认值含义
isEnabledtrue工具默认启用
isConcurrencySafefalse默认假设不能并行(安全)
isReadOnlyfalse默认假设会写入(安全)
isDestructivefalse默认不是破坏性的
checkPermissionsallow交给通用权限系统处理
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>>

翻译成人话:ToolDefTool 接口中,把 isEnabledisConcurrencySafeisReadOnlyisDestructivecheckPermissionstoAutoClassifierInputuserFacingName 这 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" 工具,它返回当前时间。需要定义:

  1. name
  2. inputSchema(空输入)
  3. call 方法
  4. prompt 方法
  5. description 方法
  6. renderToolUseMessage 方法
  7. mapToolResultToToolResultBlockParam 方法

思考:你不需要实现哪些方法?buildTool 的默认值帮你处理了什么?

练习 2:安全属性分析

对以下 5 个工具,分析它们的 isReadOnlyisDestructiveisConcurrencySafe 应该返回什么:

  1. Glob(搜索文件名)
  2. FileEdit(编辑文件)
  3. Bash(执行命令)—— 分别考虑 lsrm -rf
  4. Agent(启动子 Agent)
  5. AskUser(向用户提问)

练习 3:ToolUseContext 精简

ToolUseContext 有 40+ 个字段。如果你要将它拆分为更小的接口,你会怎么分?提示:考虑按以下维度分组:

  • 配置 vs 运行时
  • 必选 vs 可选
  • 主线程 vs 子 Agent

练习 4:工具重命名方案

假设你需要将 "Read" 工具重命名为 "FileRead":

  1. 如何利用 aliases 机制保持向后兼容?
  2. 已存在的会话历史中引用 "Read" 的消息,恢复时会怎样?
  3. 权限规则中引用 "Read" 的条目需要更新吗?
  4. MCP 工具有自己的命名空间(mcp__server__tool),它们的重命名有什么特殊之处?

本课小结

要点内容
泛型参数Tool<Input, Output, P> — 输入 schema、输出类型、进度数据类型
方法总数30+ 个方法/属性,分 5 类:执行、元数据、展示、API 交互、Hooks
buildTool工厂函数,7 个默认值(fail-closed 安全策略)
findToolByName支持主名称 + aliases 别名查找
ToolUseContext40+ 字段的依赖注入上下文,串联工具执行全生命周期
ToolResult{ data, newMessages?, contextModifier?, mcpMeta? }
Partial input渲染方法接收 Partial 输入以支持流式渲染
maxResultSizeChars控制结果大小,超限则持久化到磁盘
isReadOnly(input)安全属性依赖具体输入,不是工具级别的静态属性

下一课预告

第 11 课:tools.ts — 工具注册与动态加载 — 有了接口定义,下一步是看 40+ 种工具如何被注册、过滤、和加载。我们将深入 tools.ts 工具注册表,理解 Feature Gate 如何控制工具可见性,MCP 工具如何动态注册,以及 ToolSearch(延迟加载)如何减少初始 prompt 大小。从"定义工具"到"工具可用",中间经过了多少层?