4.1-4.2 工具注册 --- Fail-Closed 类型安全设计与四层工具架构
对应原书 :第4章 4.1节"问题与挑战"、4.2节"内置工具全景"、4.3节"工具注册表 --- Fail-Closed 的类型安全设计"、4.5节"九步执行管线"、4.6节"并发编排"
辅助源码 :
Tool.ts(793行)、tools.ts(390行)、constants/tools.ts(113行)、services/tools/toolExecution.ts、services/tools/StreamingToolExecutor.ts、tools/GlobTool/GlobTool.ts(示例工具)重点关注:Tool 泛型接口、buildTool 工厂、Fail-Closed 默认值、编译期类型体操、工具注册表组装、四维模式过滤、Prompt Cache 稳定性排序、九步执行管线、并发编排与三层 AbortController
1. 导语:工具系统为何如此复杂
原书第4章开篇提出了一个尖锐的问题:一个最简单的工具调用无非是"接收 LLM 返回的 JSON 参数 → 调用对应函数 → 返回结果",一个 switch-case 加几个函数就能搞定。但在生产环境中,工具系统面临五大挑战:
| 挑战 | 本质问题 | Claude Code 的应对 |
|------|---------|-------------------|
| **安全性** | LLM 可能被 Prompt 注入,生成 `rm -rf /` | 七层防御链 + 权限决策树 |
| **并发性** | LLM 一次返回多个工具调用,哪些并行? | `isConcurrencySafe` 声明 + 分区批处理 |
| **可中断性** | 用户随时 Ctrl+C,正在写入的文件怎么办? | 三层 AbortController + interruptBehavior |
| **可扩展性** | 内置 40+ 工具 + MCP 动态注入 | 注册表 + feature flag 条件加载 |
| **可观测性** | 每步记录遥测,不泄露代码/路径 | `TelemetrySafeError_I_VERIFIED_...` |
源码中,这五大挑战的应对散布在 Tool.ts(接口定义)、tools.ts(注册表)、toolExecution.ts(执行管线)、StreamingToolExecutor.ts(并发编排)四个文件中,共同构成一个从类型定义到运行时执行的完整体系。
2. 四层架构总览:从注册表到执行引擎
原书将工具系统描述为四层架构。源码验证如下:
┌──────────────────────────────────────────────────────────────┐
│ Layer 3: 查询引擎 (QueryEngine.ts / query.ts) │
│ 从 LLM 响应中提取 tool_use 块,驱动执行 │
├──────────────────────────────────────────────────────────────┤
│ Layer 2: 执行编排 (StreamingToolExecutor.ts) │
│ 并发控制、分区策略、错误级联、流式响应 │
├──────────────────────────────────────────────────────────────┤
│ Layer 1: 单工具执行 (toolExecution.ts) │
│ 输入验证 → Hook → 权限决策 → call() → 后置 Hook │
├──────────────────────────────────────────────────────────────┤
│ Layer 0: 工具注册表 (tools.ts + Tool.ts) │
│ 工具发现、过滤、合并、排序 │
└──────────────────────────────────────────────────────────────┘
本笔记聚焦 Layer 0(注册表与基类),同时覆盖 Layer 1(执行管线入口)和 Layer 2(并发编排)的关键设计,为后续 4.3-4.10 各篇笔记建立基础。
3. Tool 泛型接口:30+ 方法的完整契约
Tool.ts 的核心是 Tool<Input, Output, P> 泛型类型------一个包含 30+ 方法/属性的完整接口,定义了工具从注册到渲染的全生命周期契约。
3.1 三层泛型参数
typescript
export type Tool<
Input extends AnyObject = AnyObject, // Zod schema 定义的输入类型
Output = unknown, // 输出类型
P extends ToolProgressData = ToolProgressData, // 进度事件类型
> = { ... }
- Input :必须是
z.ZodType<{ [key: string]: unknown }>的子类型,即 Zod schema 定义的对象类型。这保证了每个工具的输入都有运行时验证。 - Output :工具执行的输出类型,默认
unknown,由各工具自行约束。 - P :进度事件类型,继承自
ToolProgressData,支持流式进度推送。
3.2 方法分类
Tool 接口的 30+ 方法可按职责分为六大类:
| 类别 | 方法 | 说明 |
|------|------|------|
| **元数据** | `name`, `aliases`, `searchHint`, `isMcp`, `isLsp`, `mcpInfo`, `shouldDefer`, `alwaysLoad`, `strict` | 工具标识与发现 |
| **安全声明** | `isEnabled`, `isReadOnly`, `isConcurrencySafe`, `isDestructive`, `isOpenWorld` | Fail-Closed 默认值覆盖 |
| **输入/输出** | `inputSchema`, `inputJSONSchema`, `outputSchema`, `validateInput`, `checkPermissions`, `getPath`, `preparePermissionMatcher` | 验证与权限 |
| **执行** | `call()`, `mapToolResultToToolResultBlockParam`, `backfillObservableInput`, `maxResultSizeChars` | 核心执行逻辑 |
| **渲染** | `description()`, `prompt()`, `renderToolUseMessage()`, `renderToolResultMessage?()`, `renderToolUseProgressMessage?()`, `renderToolUseRejectedMessage?()`, `renderToolUseErrorMessage?()`, `renderGroupedToolUse?()`, `extractSearchText?()`, `isResultTruncated?()`, `renderToolUseTag?()`, `renderToolUseQueuedMessage?()` | UI 渲染 |
| **交互** | `userFacingName()`, `userFacingNameBackgroundColor?()`, `getToolUseSummary?()`, `getActivityDescription?()`, `toAutoClassifierInput()`, `isSearchOrReadCommand?()`, `requiresUserInteraction?()`, `interruptBehavior?()`, `isTransparentWrapper?()` | 用户体验与中断 |
3.3 关键方法详解
call() --- 核心执行
typescript
call(
args: z.infer<Input>,
context: ToolUseContext,
canUseTool: CanUseToolFn,
parentMessage: AssistantMessage,
onProgress?: ToolCallProgress<P>,
): Promise<ToolResult<Output>>
这是工具的核心执行方法。注意它接收四个参数:
args:经 Zod 验证后的输入context:完整的执行上下文(ToolUseContext)canUseTool:权限检查回调函数parentMessage:触发此工具调用的 Assistant 消息onProgress:可选的进度回调
checkPermissions() --- 工具特定权限
typescript
checkPermissions(
input: z.infer<Input>,
context: ToolUseContext,
): Promise<PermissionResult>
原书提到"权限决策解析"是九步管线中的第 8 步。checkPermissions 是工具特定的权限逻辑,在通用权限系统之外提供工具级别的补充检查。默认实现返回 { behavior: 'allow', updatedInput },即默认放行------通用权限系统才是主要守门人。
interruptBehavior() --- 中断行为声明
typescript
interruptBehavior?(): 'cancel' | 'block'
当用户在工具执行期间提交新消息时:
'cancel'--- 停止工具并丢弃结果'block'--- 继续运行,新消息等待
默认为 'block'。这个设计让长时间运行的工具(如编译)不会被意外中断。
maxResultSizeChars --- 结果持久化阈值
typescript
maxResultSizeChars: number
当工具结果超过此字符数时,结果会被持久化到磁盘文件,Claude 只收到预览和文件路径。Read 工具设为 Infinity(永不持久化,因为会创建 Read→file→Read 循环)。
shouldDefer / alwaysLoad --- 延迟加载
typescript
readonly shouldDefer?: boolean // 需要 ToolSearch 先加载才能调用
readonly alwaysLoad?: boolean // 永不延迟,始终在初始 prompt 中
这两个标记实现了工具的按需加载策略,减少初始 prompt 的 token 消耗。shouldDefer 的工具在模型首次使用前需要通过 ToolSearch 发现,alwaysLoad 则确保某些关键工具始终可见。
4. buildTool:Fail-Closed 默认值引擎
buildTool() 是所有工具的工厂函数。它的核心设计是 Fail-Closed 默认值------当开发者忘记声明安全属性时,系统自动选择最严格的路径。
4.1 TOOL_DEFAULTS:七个默认值
typescript
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) => '', // 默认空(被 name 覆盖)
}
原书精辟地总结了这个设计哲学:
不对称的代价------一次误判"安全"可能导致数据丢失,而一次误判"危险"最多让用户多点一次确认按钮。
关键的三组默认值:
isConcurrencySafe → false:新工具默认串行执行,避免并发竞态isReadOnly → false:新工具默认需要权限检查,避免意外写入checkPermissions → allow:默认放行给通用权限系统,工具特定权限只是补充
4.2 buildTool 的运行时逻辑
typescript
export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
return {
...TOOL_DEFAULTS, // 1. 先铺默认值
userFacingName: () => def.name, // 2. 用工具名作为默认 userFacingName
...def, // 3. 用开发者定义覆盖
} as BuiltTool<D>
}
三步展开:默认值 → name 默认 → 开发者覆盖。展开顺序确保开发者定义总是优先于默认值。
4.3 GlobTool 示例:buildTool 的实际使用
以 GlobTool 为例,展示一个完整工具定义:
typescript
export const GlobTool = buildTool({
name: GLOB_TOOL_NAME,
searchHint: 'find files by name pattern or wildcard',
maxResultSizeChars: 100_000,
async description() { return DESCRIPTION },
userFacingName,
getToolUseSummary,
getActivityDescription(input) {
const summary = getToolUseSummary(input)
return summary ? `Finding ${summary}` : 'Finding files'
},
get inputSchema(): InputSchema { return inputSchema() },
get outputSchema(): OutputSchema { return outputSchema() },
isConcurrencySafe() { return true }, // ✅ 只读,并发安全
isReadOnly() { return true }, // ✅ 只读,自动放行
toAutoClassifierInput(input) { return input.pattern },
isSearchOrReadCommand() { return { isSearch: true, isRead: false } },
getPath({ path }): string { return path ? expandPath(path) : getCwd() },
async preparePermissionMatcher({ pattern }) {
return rulePattern => matchWildcardPattern(rulePattern, pattern)
},
async validateInput({ path }): Promise<ValidationResult> { ... },
async checkPermissions(input, context): Promise<PermissionDecision> { ... },
async prompt() { return DESCRIPTION },
renderToolUseMessage,
renderToolUseErrorMessage,
renderToolResultMessage,
extractSearchText({ filenames }) { return filenames.join('\n') },
async call(input, { abortController, getAppState, globLimits }) { ... },
mapToolResultToToolResultBlockParam(output, toolUseID) { ... },
} satisfies ToolDef<InputSchema, Output>)
注意几个关键点:
- GlobTool 显式声明
isConcurrencySafe: true和isReadOnly: true,覆盖了 Fail-Closed 默认值 - 使用
satisfies ToolDef<InputSchema, Output>确保类型安全 inputSchema和outputSchema使用 getter +lazySchema()实现延迟初始化call()方法是纯执行逻辑,副作用通过ToolResult返回
5. 编译期类型体操:ToolDef 与 BuiltTool
buildTool 最精妙的设计不在运行时,而在编译期------通过 TypeScript 条件类型和映射类型,将"运行时的防御转移到编译期的约束"。
5.1 DefaultableToolKeys:可默认的七个键
typescript
type DefaultableToolKeys =
| 'isEnabled'
| 'isConcurrencySafe'
| 'isReadOnly'
| 'isDestructive'
| 'checkPermissions'
| 'toAutoClassifierInput'
| 'userFacingName'
这七个方法在 ToolDef 中是可选的(Partial<>),但在 BuiltTool 中是必需的(-?)。
5.2 ToolDef:开发者侧的宽松类型
typescript
export type ToolDef<Input, Output, P> =
Omit<Tool<Input, Output, P>, DefaultableToolKeys> & // 非默认方法必须提供
Partial<Pick<Tool<Input, Output, P>, DefaultableToolKeys>> // 默认方法可选
开发者定义工具时可以省略有默认值的方法,但必须提供 name、call、inputSchema 等非默认方法。
5.3 BuiltTool:调用方侧的严格类型
typescript
type BuiltTool<D> = Omit<D, DefaultableToolKeys> & {
[K in DefaultableToolKeys]-?: // -? 移除可选性
K extends keyof D
? undefined extends D[K]
? ToolDefaults[K] // 开发者提供了但可能 undefined → 用默认
: D[K] // 开发者明确提供了 → 用开发者的
: ToolDefaults[K] // 开发者没提供 → 用默认
}
这段条件类型的含义:
-?:所有默认键变为必需,调用方不需要任何运行时 null 检查- 如果开发者提供了某个方法且非 undefined → 用开发者的类型
- 如果开发者提供了但可能 undefined → 用默认类型
- 如果开发者没提供 → 用默认类型
原书评价:
这是 TypeScript 类型系统的一个优雅应用:把运行时的防御转移到编译期的约束。
6. ToolUseContext:执行上下文的全景
ToolUseContext 是工具执行时接收的完整上下文,包含 40+ 个字段。这是工具系统中最复杂的类型之一。
6.1 核心结构
typescript
export type ToolUseContext = {
options: {
commands: Command[] // 可用命令
debug: boolean // 调试模式
mainLoopModel: string // 主循环模型名
tools: Tools // 可用工具列表
verbose: boolean // 详细输出
thinkingConfig: ThinkingConfig // thinking 模式配置
mcpClients: MCPServerConnection[] // MCP 客户端
mcpResources: Record<string, ServerResource[]> // MCP 资源
isNonInteractiveSession: boolean // 非交互会话
agentDefinitions: AgentDefinitionsResult // Agent 定义
maxBudgetUsd?: number // 最大预算
customSystemPrompt?: string // 自定义系统提示
appendSystemPrompt?: string // 追加系统提示
querySource?: QuerySource // 查询来源
refreshTools?: () => Tools // 刷新工具列表回调
}
abortController: AbortController // 中断控制器
readFileState: FileStateCache // 文件状态缓存
getAppState(): AppState // 获取应用状态
setAppState(f: (prev: AppState) => AppState): void // 更新应用状态
setAppStateForTasks?: ... // 会话级状态更新
handleElicitation?: ... // URL 引导处理
setToolJSX?: SetToolJSXFn // 工具 JSX 渲染
addNotification?: (notif: Notification) => void // 通知
appendSystemMessage?: ... // 系统消息追加
sendOSNotification?: ... // OS 级通知
// ... 还有 20+ 个字段
messages: Message[] // 当前消息列表
toolUseId?: string // 当前工具调用 ID
agentId?: AgentId // 子 Agent ID
agentType?: string // Agent 类型名
contentReplacementState?: ContentReplacementState // 内容替换状态
renderedSystemPrompt?: SystemPrompt // 父级渲染的系统提示
}
6.2 关键设计
setAppState vs setAppStateForTasks :两个字段看似相同,但有重要区别。setAppState 对异步 Agent 是空操作(no-op),而 setAppStateForTasks 始终到达根 Store。这确保了任何嵌套深度的 Agent 都能注册/清理基础设施。
renderedSystemPrompt :冻结在 turn 开始时的父级系统提示字节。用于 fork 子 Agent 时共享父级的 prompt cache------如果在 fork 时重新调用 getSystemPrompt(),GrowthBook 的 cold→warm 切换可能导致缓存失效。
contentReplacementState:工具结果预算的每会话状态。主线程 REPL 只初始化一次(永不重置),子 Agent 默认克隆父状态。
7. ToolResult:函数式副作用注入
typescript
export type ToolResult<T> = {
data: T // 核心输出数据
newMessages?: ( // 附加消息
| UserMessage | AssistantMessage | AttachmentMessage | SystemMessage
)[]
contextModifier?: (context: ToolUseContext) => ToolUseContext // 上下文修改器
mcpMeta?: { // MCP 协议元数据
_meta?: Record<string, unknown>
structuredContent?: Record<string, unknown>
}
}
原书重点分析了 contextModifier 字段:
工具执行后可以通过
contextModifier函数修改执行上下文。核心call方法保持纯输出,副作用(如修改权限状态、更新文件缓存)通过组合子注入。这种设计便于测试:测试时只需检查返回的contextModifier是否正确,不需要 mock 全局状态。
这是函数式副作用注入 模式------将副作用从工具的核心执行逻辑中分离出来,以可组合、可测试的方式表达。注意注释说明 contextModifier 只对非并发安全的工具有效("only honored for tools that aren't concurrency safe"),因为并发工具的上下文修改需要延迟收集以消除竞态。
8. tools.ts:工具注册表的组装逻辑
tools.ts 是工具注册表的核心,负责工具的发现、过滤、合并和排序。
8.1 getAllBaseTools():全量工具列表
typescript
export function getAllBaseTools(): Tools {
return [
AgentTool,
TaskOutputTool,
BashTool,
...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]), // 内嵌搜索工具时省略
ExitPlanModeV2Tool,
FileReadTool,
FileEditTool,
FileWriteTool,
NotebookEditTool,
WebFetchTool,
TodoWriteTool,
WebSearchTool,
TaskStopTool,
AskUserQuestionTool,
SkillTool,
EnterPlanModeTool,
...(process.env.USER_TYPE === 'ant' ? [ConfigTool, TungstenTool] : []),
...(SuggestBackgroundPRTool ? [SuggestBackgroundPRTool] : []),
...(WebBrowserTool ? [WebBrowserTool] : []),
...(isTodoV2Enabled() ? [TaskCreateTool, TaskGetTool, TaskUpdateTool, TaskListTool] : []),
...(OverflowTestTool ? [OverflowTestTool] : []),
...(CtxInspectTool ? [CtxInspectTool] : []),
...(TerminalCaptureTool ? [TerminalCaptureTool] : []),
...(isEnvTruthy(process.env.ENABLE_LSP_TOOL) ? [LSPTool] : []),
...(isWorktreeModeEnabled() ? [EnterWorktreeTool, ExitWorktreeTool] : []),
getSendMessageTool(),
...(ListPeersTool ? [ListPeersTool] : []),
...(isAgentSwarmsEnabled() ? [getTeamCreateTool(), getTeamDeleteTool()] : []),
...(VerifyPlanExecutionTool ? [VerifyPlanExecutionTool] : []),
...(process.env.USER_TYPE === 'ant' && REPLTool ? [REPLTool] : []),
...(WorkflowTool ? [WorkflowTool] : []),
...(SleepTool ? [SleepTool] : []),
...cronTools,
...(RemoteTriggerTool ? [RemoteTriggerTool] : []),
...(MonitorTool ? [MonitorTool] : []),
BriefTool,
...(SendUserFileTool ? [SendUserFileTool] : []),
...(PushNotificationTool ? [PushNotificationTool] : []),
...(SubscribePRTool ? [SubscribePRTool] : []),
...(getPowerShellTool() ? [getPowerShellTool()] : []),
...(SnipTool ? [SnipTool] : []),
...(process.env.NODE_ENV === 'test' ? [TestingPermissionTool] : []),
ListMcpResourcesTool,
ReadMcpResourceTool,
...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : []),
]
}
8.2 条件加载的三种模式
源码中工具的条件加载有三种模式:
| 模式 | 机制 | 示例 |
|------|------|------|
| **环境变量** | `process.env.USER_TYPE === 'ant'` | ConfigTool, TungstenTool, REPLTool |
| **Feature Flag** | `feature('PROACTIVE')` / `feature('AGENT_TRIGGERS')` | SleepTool, CronTools, RemoteTriggerTool |
| **运行时检测** | `isWorktreeModeEnabled()` / `isAgentSwarmsEnabled()` | WorktreeTools, TeamTools |
Dead Code Elimination :条件加载通过 require() 动态导入实现,而非静态 import。这使得 Bun 打包器可以在构建时根据 feature flag 移除未启用的工具代码,减少包体积。源码注释明确标注了 // Dead code elimination: conditional import。
8.3 getTools():权限过滤后的工具列表
typescript
export const getTools = (permissionContext: ToolPermissionContext): Tools => {
// 1. Simple 模式:仅保留 Bash/Read/Edit
if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
if (isReplModeEnabled() && REPLTool) {
const replSimple: Tool[] = [REPLTool]
// Coordinator 模式下额外暴露 Agent/TaskStop/SendMessage
if (feature('COORDINATOR_MODE') && coordinatorModeModule?.isCoordinatorMode()) {
replSimple.push(TaskStopTool, getSendMessageTool())
}
return filterToolsByDenyRules(replSimple, permissionContext)
}
const simpleTools: Tool[] = [BashTool, FileReadTool, FileEditTool]
if (feature('COORDINATOR_MODE') && coordinatorModeModule?.isCoordinatorMode()) {
simpleTools.push(AgentTool, TaskStopTool, getSendMessageTool())
}
return filterToolsByDenyRules(simpleTools, permissionContext)
}
// 2. 获取全量工具,排除特殊工具
const specialTools = new Set([
ListMcpResourcesTool.name, ReadMcpResourceTool.name, SYNTHETIC_OUTPUT_TOOL_NAME
])
const tools = getAllBaseTools().filter(tool => !specialTools.has(tool.name))
// 3. Deny 规则过滤
let allowedTools = filterToolsByDenyRules(tools, permissionContext)
// 4. REPL 模式:隐藏被 REPL 包装的原始工具
if (isReplModeEnabled()) {
const replEnabled = allowedTools.some(tool => toolMatchesName(tool, REPL_TOOL_NAME))
if (replEnabled) {
allowedTools = allowedTools.filter(tool => !REPL_ONLY_TOOLS.has(tool.name))
}
}
// 5. isEnabled() 过滤
const isEnabled = allowedTools.map(_ => _.isEnabled())
return allowedTools.filter((_, i) => isEnabled[i])
}
9. 四维模式过滤:Simple / REPL / Coordinator / Deny
原书将 getTools() 的过滤逻辑总结为"四维模式过滤"。源码验证如下:
| 维度 | 机制 | 效果 |
|------|------|------|
| **Simple 模式** | `CLAUDE_CODE_SIMPLE` 环境变量 | 仅保留 Bash/Read/Edit,最小工具集 |
| **REPL 模式** | `isReplModeEnabled()` + `REPL_ONLY_TOOLS` | 隐藏原始工具,强制走 REPL 统一入口 |
| **Coordinator** | `feature('COORDINATOR_MODE')` + `isCoordinatorMode()` | 额外暴露 Agent/TaskStop/SendMessage 调度能力 |
| **Deny 规则** | `filterToolsByDenyRules()` | 模型看不到已禁用工具 |
9.1 容错设计:REPL 被禁时的兜底
typescript
if (isReplModeEnabled()) {
const replEnabled = allowedTools.some(tool => toolMatchesName(tool, REPL_TOOL_NAME))
if (replEnabled) {
allowedTools = allowedTools.filter(tool => !REPL_ONLY_TOOLS.has(tool.name))
}
}
原书指出了一个重要的容错设计:
如果 REPLTool 被 deny 规则移除,原始工具自动重新可见,避免模型无工具可用。这体现了一个原则:过滤是减法,但减到零时要兜底。
代码中 if (replEnabled) 条件确保:只有 REPL 工具确实可用时才隐藏原始工具。如果 deny 规则移除了 REPL,replEnabled 为 false,原始工具保持可见。
9.2 filterToolsByDenyRules:通用 deny 过滤
typescript
export function filterToolsByDenyRules<T extends {
name: string
mcpInfo?: { serverName: string; toolName: string }
}>(tools: readonly T[], permissionContext: ToolPermissionContext): T[] {
return tools.filter(tool => !getDenyRuleForTool(permissionContext, tool))
}
这个函数在工具池组装时(而非调用时)就过滤掉被 deny 的工具,确保模型根本看不到这些工具。源码注释指出:
Uses the same matcher as the runtime permission check (step 1a), so MCP server-prefix rules like
mcp__serverstrip all tools from that server before the model sees them --- not just at call time.
10. Prompt Cache 稳定性排序:内置在前 MCP 在后
assembleToolPool() 函数有一个看似简单却意义重大的排序设计:
typescript
export function assembleToolPool(
permissionContext: ToolPermissionContext,
mcpTools: Tools,
): Tools {
const builtInTools = getTools(permissionContext)
const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext)
const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name)
return uniqBy(
[...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
'name',
)
}
10.1 为什么要分区排序?
源码注释详细解释了原因:
The server's claude_code_system_cache_policy places a global cache breakpoint after the last prefix-matched built-in tool; a flat sort would interleave MCP tools into built-ins and invalidate all downstream cache keys whenever an MCP tool sorts between existing built-ins.
如果混排,MCP 工具的动态插入或删除会导致所有下游缓存键失效,浪费大量 token 费用。分区排序确保:
- 内置工具分区在前,按名称排序------这部分极少变化,缓存命中率高
- MCP 工具分区在后,按名称排序------这部分可能动态变化,但不影响内置工具的缓存
10.2 uniqBy 的去重语义
uniqBy([...builtIn, ...mcp], 'name') 保留插入顺序,内置工具优先。当 MCP 工具与内置工具同名时,内置工具胜出。这避免了恶意 MCP 工具覆盖内置工具的行为。
10.3 getMergedTools vs assembleToolPool
typescript
// 不排序,仅拼接
export function getMergedTools(permissionContext, mcpTools): Tools {
const builtInTools = getTools(permissionContext)
return [...builtInTools, ...mcpTools]
}
// 排序 + 去重,用于发送给 API
export function assembleToolPool(permissionContext, mcpTools): Tools { ... }
getMergedTools 用于内部计算(如 token 计数、工具搜索阈值),不需要排序稳定性;assembleToolPool 用于实际发送给 API,需要缓存稳定性。
11. 循环依赖破解:延迟 require 模式
tools.ts 中有三个工具使用延迟 require 来破解循环依赖:
typescript
// TeamCreateTool → tools.ts → TeamCreateTool (循环)
const getTeamCreateTool = () =>
require('./tools/TeamCreateTool/TeamCreateTool.js').TeamCreateTool
as typeof import('./tools/TeamCreateTool/TeamCreateTool.js').TeamCreateTool
const getTeamDeleteTool = () =>
require('./tools/TeamDeleteTool/TeamDeleteTool.js').TeamDeleteTool
as typeof import('./tools/TeamDeleteTool/TeamDeleteTool.js').TeamDeleteTool
const getSendMessageTool = () =>
require('./tools/SendMessageTool/SendMessageTool.js').SendMessageTool
as typeof import('./tools/SendMessageTool/SendMessageTool.js').SendMessageTool
原书解释:
getter 模式保证首次调用时模块图已稳定。这是 Node.js 生态中处理循环依赖的经典手法。
关键设计:
- 延迟执行 :
require()在 getter 函数内,而非模块顶层,确保只在首次调用时执行 - 类型安全 :使用
as typeof import(...)保持类型签名与静态导入完全一致 - 为什么这三个工具?:TeamCreateTool 和 TeamDeleteTool 依赖 tools.ts 中的工具列表(用于 Agent 工具限制),SendMessageTool 也存在类似的反向依赖。延迟 require 打破了这个循环。
12. constants/tools.ts:Agent 工具限制矩阵
constants/tools.ts 定义了不同 Agent 类型可用/禁用的工具集合,是多 Agent 协作(阶段五)的基础。
12.1 四个工具集合
typescript
// 所有 Agent 都禁用的工具
export const ALL_AGENT_DISALLOWED_TOOLS = new Set([
TASK_OUTPUT_TOOL_NAME, // 防止递归
EXIT_PLAN_MODE_V2_TOOL_NAME, // Plan mode 是主线程抽象
ENTER_PLAN_MODE_TOOL_NAME,
...(process.env.USER_TYPE === 'ant' ? [] : [AGENT_TOOL_NAME]), // ant 用户允许嵌套 Agent
ASK_USER_QUESTION_TOOL_NAME,
TASK_STOP_TOOL_NAME, // 需要主线程任务状态
...(feature('WORKFLOW_SCRIPTS') ? [WORKFLOW_TOOL_NAME] : []), // 防止递归工作流
])
// 自定义 Agent 额外禁用(目前与 ALL_AGENT_DISALLOWED_TOOLS 相同)
export const CUSTOM_AGENT_DISALLOWED_TOOLS = new Set([...ALL_AGENT_DISALLOWED_TOOLS])
// 异步 Agent 允许的工具(白名单模式)
export const ASYNC_AGENT_ALLOWED_TOOLS = new Set([
FILE_READ_TOOL_NAME, WEB_SEARCH_TOOL_NAME, TODO_WRITE_TOOL_NAME,
GREP_TOOL_NAME, WEB_FETCH_TOOL_NAME, GLOB_TOOL_NAME,
...SHELL_TOOL_NAMES, FILE_EDIT_TOOL_NAME, FILE_WRITE_TOOL_NAME,
NOTEBOOK_EDIT_TOOL_NAME, SKILL_TOOL_NAME, SYNTHETIC_OUTPUT_TOOL_NAME,
TOOL_SEARCH_TOOL_NAME, ENTER_WORKTREE_TOOL_NAME, EXIT_WORKTREE_TOOL_NAME,
])
// 进程内 Teammate 允许的额外工具
export const IN_PROCESS_TEAMMATE_ALLOWED_TOOLS = new Set([
TASK_CREATE_TOOL_NAME, TASK_GET_TOOL_NAME, TASK_LIST_TOOL_NAME,
TASK_UPDATE_TOOL_NAME, SEND_MESSAGE_TOOL_NAME,
...(feature('AGENT_TRIGGERS') ? [CRON_CREATE_TOOL_NAME, ...] : []),
])
// Coordinator 模式允许的工具(仅调度,不直接干活)
export const COORDINATOR_MODE_ALLOWED_TOOLS = new Set([
AGENT_TOOL_NAME, TASK_STOP_TOOL_NAME, SEND_MESSAGE_TOOL_NAME, SYNTHETIC_OUTPUT_TOOL_NAME,
])
12.2 设计分析
黑名单 vs 白名单并存:
ALL_AGENT_DISALLOWED_TOOLS是黑名单------所有 Agent 都不能用的工具ASYNC_AGENT_ALLOWED_TOOLS是白名单------异步 Agent 只能用这些工具- 两层过滤:先黑名单移除危险工具,再白名单限制可用范围
Coordinator 的极简设计:Coordinator 模式只有 4 个工具(Agent/TaskStop/SendMessage/SyntheticOutput),体现了"协调者不直接干活"的架构原则。
阻止递归 :AGENT_TOOL_NAME 在非 ant 用户中被禁用,防止子 Agent 嵌套子 Agent 导致递归。WORKFLOW_TOOL_NAME 也被禁用,防止工作流递归。
13. 工具描述即 Prompt:软约束的行为引导
原书 4.2.2 节提出了一个重要设计模式:工具描述即 Prompt。
13.1 description 字段的双重身份
typescript
// Tool 接口中的 description 方法
description(
input: z.infer<Input>,
options: {
isNonInteractiveSession: boolean
toolPermissionContext: ToolPermissionContext
tools: Tools
},
): Promise<string>
每个工具的 description() 返回的文本会被注入到系统 Prompt 中,LLM 通过阅读这些描述来决定"用哪个工具"和"怎么用"。
13.2 行为引导示例
原书以 BashTool 为例:
BashTool 的描述中明确说"不要用 Bash 来 grep/find/cat",引导 LLM 优先使用专用工具------这不是技术约束,而是 Prompt 引导。如果删掉这句话,LLM 会大量使用 Bash 来做文件搜索,绕过了专用工具的优化路径。
13.3 缓存经济性
原书指出三个层面的影响:
- 措辞直接影响工具选择行为 :描述中的
IMPORTANT标注引导 LLM 注意关键约束 - 长度影响 Prompt Cache 经济性:所有工具描述拼接后占据系统 Prompt 的相当比例,过长浪费 token,过短导致误用
- 稳定性影响缓存命中率 :每次修改工具描述都会使 Prompt Cache 失效------这就是
assembleToolPool()排序策略如此重要的原因
源码注释也印证了这一点:
NOTE: This MUST stay in sync with https://console.statsig.com/.../claude_code_global_system_caching, in order to cache the system prompt across users.
14. 九步执行管线:runToolUse 完整剖析
原书 4.5 节描述了工具执行的九步管线。源码中,这个管线分布在 runToolUse() 和 checkPermissionsAndCallTool() 两个函数中。
14.1 runToolUse():管线入口
typescript
export async function* runToolUse(
toolUse: ToolUseBlock,
assistantMessage: AssistantMessage,
canUseTool: CanUseToolFn,
toolUseContext: ToolUseContext,
): AsyncGenerator<MessageUpdateLazy, void> {
// Step 1: findToolByName --- 含别名回退
let tool = findToolByName(toolUseContext.options.tools, toolUse.name)
if (!tool) {
const fallbackTool = findToolByName(getAllBaseTools(), toolUse.name)
if (fallbackTool && fallbackTool.aliases?.includes(toolUse.name)) {
tool = fallbackTool // 别名回退(如 "KillShell" → "TaskStop")
}
}
// Step 2: Abort 检查 --- 用户中断?
if (toolUseContext.abortController.signal.aborted) {
yield { message: /* CANCEL_MESSAGE */ }
return
}
// Step 3-9: 委托给 streamedCheckPermissionsAndCallTool
for await (const update of streamedCheckPermissionsAndCallTool(...)) {
yield update
}
}
14.2 九步管线详解
| 步骤 | 函数 | 源码位置 | 说明 |
|---|---|---|---|
| 1. findToolByName | runToolUse |
L345-356 | 含别名回退,保证旧名称向后兼容 |
| 2. Abort 检查 | runToolUse |
L415-453 | 检查用户中断和流式 fallback |
| 3. Zod Schema 验证 | checkPermissionsAndCallTool |
L615-680 | safeParse,失败返回 InputValidationError |
| 4. 工具自定义验证 | checkPermissionsAndCallTool |
L683-733 | tool.validateInput(),如 FileEditTool 的 read-before-write |
| 5. Bash 分类器投机预检 | checkPermissionsAndCallTool |
L740-752 | 并行启动,不阻塞主流程 |
| 6. 输入回填 | checkPermissionsAndCallTool |
L776-789 | backfillObservableInput(),展开 ~ 为绝对路径 |
| 7. PreToolUse Hooks | checkPermissionsAndCallTool |
后续 | 可修改输入、返回权限决策 |
| 8. 权限决策解析 | checkPermissionsAndCallTool |
后续 | Hook → 规则 → canUseTool → 最终决策 |
| 9. tool.call() + PostToolUse Hooks | checkPermissionsAndCallTool |
后续 | 执行 + 结果映射 |
14.3 关键设计决策
步骤 1 的别名机制:
typescript
if (!tool) {
const fallbackTool = findToolByName(getAllBaseTools(), toolUse.name)
if (fallbackTool && fallbackTool.aliases?.includes(toolUse.name)) {
tool = fallbackTool
}
}
当工具被重命名或废弃时(如 KillShell → TaskStop),旧名称通过 aliases 数组继续工作。注意回退只在别名匹配时生效------如果工具名直接匹配但不在当前可用工具列表中,不会回退。这保证了模型在对话历史中引用旧工具名时不会报错。
步骤 3 的延迟工具 Schema 提示:
typescript
const schemaHint = buildSchemaNotSentHint(tool, toolUseContext.messages, toolUseContext.options.tools)
if (schemaHint) {
errorContent += schemaHint // 附加 "call ToolSearch with query select:toolName first"
}
当 Zod 验证失败时,系统检查是否是因为延迟工具的 schema 未发送给 API。如果是,附加一条结构化提示引导 LLM 先调用 ToolSearch 加载工具。这是自修复引导机制。
步骤 5 的投机预检:
typescript
if (tool.name === BASH_TOOL_NAME && parsedInput.data && 'command' in parsedInput.data) {
startSpeculativeClassifierCheck(
(parsedInput.data as BashToolInput).command,
appState.toolPermissionContext,
toolUseContext.abortController.signal,
toolUseContext.options.isNonInteractiveSession,
)
}
在用户审批权限弹窗期间,系统并行启动 Bash 分类器。如果用户批准,分类器结果已就绪;如果拒绝,结果被丢弃。这是乐观并行策略------用少量计算资源换取可感知的延迟降低。
步骤 6 的路径回填:
typescript
let callInput = processedInput
const backfilledClone =
tool.backfillObservableInput && typeof processedInput === 'object' && processedInput !== null
? ({ ...processedInput } as typeof processedInput)
: null
将 ~ 展开为绝对路径看似简单,但防御意义重大------如果 Hook 配置了基于路径的白名单规则,~/././../../etc/passwd 在回填前可以绕过白名单。注意回填作用于浅拷贝,不影响 API-bound 的原始输入(保护 prompt cache)。
14.4 流式桥接模式
typescript
function streamedCheckPermissionsAndCallTool(...): AsyncIterable<MessageUpdateLazy> {
const stream = new Stream<MessageUpdateLazy>()
checkPermissionsAndCallTool(...,
progress => {
stream.enqueue({ message: createProgressMessage(...) }) // 进度事件
}
)
.then(results => { for (const r of results) stream.enqueue(r) }) // 最终结果
.catch(error => { stream.error(error) })
.finally(() => { stream.done() })
return stream
}
原书分析:
工具的
onProgress回调和最终结果通过同一个 stream 通道输出,上层消费者用for await统一处理。这是 callback 到 pull-based stream 的经典桥接。
15. StreamingToolExecutor:并发编排与三层 AbortController
StreamingToolExecutor 是 Layer 2 的核心,管理工具的并发执行。
15.1 四状态生命周期
typescript
type ToolStatus = 'queued' | 'executing' | 'completed' | 'yielded'
每个工具经历四个状态:
queued:已加入队列,等待执行条件满足executing:正在执行completed:执行完成,结果已收集yielded:结果已输出给消费者
15.2 并发规则
typescript
private canExecuteTool(isConcurrencySafe: boolean): boolean {
const executingTools = this.tools.filter(t => t.status === 'executing')
return (
executingTools.length === 0 ||
(isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
)
}
规则简洁而安全:
- 没有执行中的工具 → 任何工具可以开始
- 所有执行中的工具都是 concurrencySafe → 新的并发安全工具可以并行加入
- 存在非并发安全工具在执行 → 所有后续工具排队等待
结合 TOOL_DEFAULTS 中的 isConcurrencySafe: () => false,系统默认走最安全的串行路径。
15.3 三层 AbortController 架构
rootAbortController (根级:用户中断 Ctrl+C)
└── siblingAbortController (中级:Bash 错误级联)
└── toolAbortController (叶级:单工具控制)
关键语义(原书 4.6.2 节):
typescript
// StreamingToolExecutor 构造函数
this.siblingAbortController = createChildAbortController(toolUseContext.abortController)
// executeTool 中创建叶级 controller
const toolAbortController = createChildAbortController(this.siblingAbortController)
// 关键:abort reason 决定是否冒泡
toolAbortController.signal.addEventListener('abort', () => {
if (
toolAbortController.signal.reason !== 'sibling_error' &&
!this.toolUseContext.abortController.signal.aborted &&
!this.discarded
) {
// 非 sibling_error 的 abort 冒泡到根级
this.toolUseContext.abortController.abort(toolAbortController.signal.reason)
}
}, { once: true })
原书解释:
当一个 Bash 工具执行出错时,abort
siblingAbortController,级联取消所有并行的兄弟工具------但不会冒泡到根级。查询循环不会因为一个 Bash 错误就终止整个 turn。然而,非sibling_error类型的 abort(如权限拒绝)则会冒泡到根级,终止查询。
为什么要区分?因为:
- Bash 命令失败是"正常的"(编译错误、测试失败),Agent 应该看到错误并调整策略
- 权限被拒是"致命的",继续执行没有意义
15.4 中断行为精细化
typescript
private getAbortReason(tool: TrackedTool):
'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') {
// 用户输入了新消息------只有 interruptBehavior='cancel' 的工具才中断
return this.getToolInterruptBehavior(tool) === 'cancel'
? 'user_interrupted'
: null
}
return 'user_interrupted'
}
return null
}
'interrupt' reason 表示用户在工具运行期间输入了新消息。此时只有 interruptBehavior() === 'cancel' 的工具才会被中断,'block' 类型的工具继续运行。这实现了原书提到的"可中断性"设计。
16. 延迟工具与自修复引导
原书 4.9 节描述了延迟工具的自修复引导机制。源码在 toolExecution.ts 的 buildSchemaNotSentHint() 函数中实现:
typescript
export function buildSchemaNotSentHint(
tool: Tool,
messages: Message[],
tools: readonly { name: string }[],
): string | null {
if (!isToolSearchEnabledOptimistic()) return null
if (!isToolSearchToolAvailable(tools)) return null
if (!isDeferredTool(tool)) return null
const discovered = extractDiscoveredToolNames(messages)
if (discovered.has(tool.name)) return null // 已发现,不需要提示
return (
`\n\nThis tool's schema was not sent to the API --- ` +
`it was not in the discovered-tool set derived from message history. ` +
`Without the schema in your prompt, typed parameters get emitted as strings ` +
`and the client-side parser rejects them. ` +
`Load the tool first: call ${TOOL_SEARCH_TOOL_NAME} with query "select:${tool.name}", then retry this call.`
)
}
当 LLM 尝试调用一个延迟工具但 schema 未发送时,系统不简单报错,而是返回一条结构化提示,引导 LLM 先调用 ToolSearch 加载工具。原书评价:
这是一种自修复引导机制------LLM 犯错后,从错误消息中学习正确流程,下次就不会再犯同样的错。与其设计一个防止所有错误的完美系统,不如设计一个能从错误中优雅恢复的弹性系统。
17. 设计模式提炼
| 模式 | 核心思想 | 源码位置 | 适用场景 |
|---|---|---|---|
| Fail-Closed 默认值 | 安全相关属性的默认值选最严格选项 | TOOL_DEFAULTS |
任何需要可选安全声明的注册表系统 |
| 编译期类型体操 | 用条件类型和映射类型将运行时防御转移到编译期 | BuiltTool<D> |
需要默认值填充的类型安全 API |
| 分区批处理编排 | 按并发安全性切割为交替分区,分区间顺序执行 | StreamingToolExecutor.processQueue() |
混合读写操作的批处理 |
| 三层取消语义 | 嵌套 AbortController + reason 分类决定冒泡 | siblingAbortController + toolAbortController |
多层嵌套的异步任务取消 |
| 函数式副作用注入 | contextModifier 组合子分离核心逻辑与副作用 |
ToolResult.contextModifier |
需要可测试副作用的执行框架 |
| 工具描述即 Prompt | 工具描述作为 LLM 的行为引导 | description() 方法 |
所有 LLM 工具系统 |
| Prompt Cache 稳定性排序 | 内置在前 MCP 在后,分区按名排序 | assembleToolPool() |
有缓存需求的动态工具集 |
| 循环依赖破解 | 延迟 require + getter 模式 | getTeamCreateTool() |
Node.js 循环依赖 |
| 自修复引导 | 错误消息引导 LLM 学习正确流程 | buildSchemaNotSentHint() |
延迟加载系统 |
| 投机并行预检 | 在等待期间并行启动分类器 | startSpeculativeClassifierCheck() |
有等待环节的决策系统 |
18. 原书对照验证
| 原书描述 | 源码验证 | 状态 |
|---|---|---|
| 四层架构(Layer 0-3) | tools.ts(L0) → toolExecution.ts(L1) → StreamingToolExecutor.ts(L2) → query.ts(L3) |
✅ 准确 |
| 30+ 方法 Tool 接口 | Tool.ts L362-695,统计有 35+ 方法/属性 |
✅ 准确(实际更多) |
| buildTool 的 Fail-Closed 默认值 | TOOL_DEFAULTS 中 isConcurrencySafe: false, isReadOnly: false |
✅ 准确 |
| 编译期类型体操 | ToolDef + BuiltTool<D> 条件类型和映射类型 |
✅ 准确 |
| Prompt Cache 稳定性排序 | assembleToolPool() 中 [...builtIn].sort(byName).concat(mcp.sort(byName)) |
✅ 准确 |
| 四维模式过滤 | Simple/REPL/Coordinator/Deny 四维 | ✅ 准确 |
| REPL 被 deny 时兜底 | if (replEnabled) 条件检查 |
✅ 准确 |
| 循环依赖延迟 require | getTeamCreateTool()/getTeamDeleteTool()/getSendMessageTool() |
✅ 准确 |
| 工具描述即 Prompt | description() 方法返回注入系统 Prompt 的文本 |
✅ 准确 |
| 九步执行管线 | runToolUse → checkPermissionsAndCallTool,9 步逐一对应 |
✅ 准确 |
| 别名回退机制 | findToolByName + aliases?.includes(toolName) |
✅ 准确 |
| 投机预检 | startSpeculativeClassifierCheck() 在权限检查前并行启动 |
✅ 准确 |
| 路径回填 | backfillObservableInput() 作用于浅拷贝 |
✅ 准确 |
| 流式桥接模式 | Stream<MessageUpdateLazy> 统一进度和结果 |
✅ 准确 |
| 函数式副作用注入 | ToolResult.contextModifier 字段 |
✅ 准确 |
| 四状态生命周期 | queued → executing → completed → yielded |
✅ 准确 |
| 三层 AbortController | root → sibling → tool,reason 决定冒泡 | ✅ 准确 |
| sibling_error 不冒泡 | reason !== 'sibling_error' 条件检查 |
✅ 准确 |
| interruptBehavior | `'cancel' | 'block',默认 'block'` |
| 自修复引导 | buildSchemaNotSentHint() 返回 ToolSearch 引导 |
✅ 准确 |
| TelemetrySafeError 防呆设计 | TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 类名 |
✅ 准确 |
| 30+ 内置工具 | getAllBaseTools() 返回约 35-45 个工具(含条件加载) |
✅ 准确 |
原书描述偏差
| 原书描述 | 源码实际情况 | 偏差说明 |
|---|---|---|
| "30+ 个内置工具" | getAllBaseTools() 返回约 35-45 个(含 feature flag 条件加载) |
偏保守,实际更多 |
| "六大类分类" | 源码中工具的安全标记(isReadOnly/isConcurrencySafe/isDestructive)是三布尔标记,不是严格的六大类 |
分类是概念性的,源码用标记组合表达 |
| 4.3 节标题为"工具注册表" | 阅读计划标记为"4.1-4.2 工具注册" | 章节编号差异,不影响内容 |
| "Layer 2: toolorchestration.ts" | 实际文件名为 StreamingToolExecutor.ts,无 toolorchestration.ts |
文件名差异 |
附录:关键文件索引
| 文件 | 行数 | 核心职责 |
|---|---|---|
Tool.ts |
793 | Tool 泛型接口、buildTool 工厂、TOOL_DEFAULTS、ToolUseContext |
tools.ts |
390 | getAllBaseTools、getTools、assembleToolPool、filterToolsByDenyRules |
constants/tools.ts |
113 | Agent 工具限制矩阵(ALL_AGENT_DISALLOWED/ASYNC_AGENT_ALLOWED/...) |
services/tools/toolExecution.ts |
~1000+ | runToolUse、checkPermissionsAndCallTool、buildSchemaNotSentHint |
services/tools/StreamingToolExecutor.ts |
~400+ | 并发编排、三层 AbortController、分区批处理 |
tools/GlobTool/GlobTool.ts |
199 | buildTool 使用示例(只读工具) |
下一篇:4.3 BashTool 七层安全防御 --- 从 AST 解析到语义分类器的纵深防御链