Skip to content

Layer 2: 工具系统 —— Agent 的能力扩展

如何设计一个既标准化又可扩展的工具体系,让 50+ 工具能够安全地并发执行,同时支持动态加载(MCP)和延迟发现(Deferred Tools)?

工具系统是 Agent 的「手」——Agent Loop 决定做什么,Tool System 决定怎么做。


Tool.ts
type Tool<Input, Output, P = unknown> = {
// ──── 身份 ────
name: string // 唯一标识符
aliases?: string[] // 向后兼容别名
searchHint?: string // ToolSearch 关键词(3-10 词)
// ──── 输入/输出 ────
inputSchema: Input // Zod schema(类型安全)
inputJSONSchema?: ToolInputJSONSchema // 原始 JSON Schema(给 MCP 工具)
outputSchema?: z.ZodType // 可选输出 schema
// ──── 核心执行 ────
call(
args: z.infer<Input>,
context: ToolUseContext,
canUseTool: CanUseToolFn,
parentMessage: AssistantMessage,
onProgress?: ToolCallProgress<P>
): Promise<ToolResult<Output>>
// ──── 安全标记 ────
isConcurrencySafe(input): boolean // 能否与其他工具并行?
isReadOnly(input): boolean // 是否只读?
isDestructive(input): boolean // 是否不可逆?
checkPermissions(input, ctx): PermissionResult // 权限检查
// ──── 描述与渲染 ────
description(input, options): string
prompt(options): string // 完整工具说明(每会话调用一次)
renderToolUseMessage(input): ReactElement
renderToolResultMessage(output, ...): ReactElement
// ──── 高级特性 ────
shouldDefer?: boolean // 延迟加载(需要 ToolSearch 激活)
alwaysLoad?: boolean // 永远不延迟
maxResultSizeChars: number // 超过此值 → 持久化到磁盘
isEnabled(): boolean // 当前环境是否可用
validateInput(input, ctx): ValidationResult // 预执行验证
}
type ToolResult<T> = {
data: T // 实际结果
newMessages?: Message[] // 追加到对话的额外消息
contextModifier?: (ctx: ToolUseContext) => ToolUseContext // 上下文修改器
mcpMeta?: { _meta?, structuredContent? } // MCP 协议元数据
}

★ 设计洞察contextModifier 是一个巧妙的设计。工具不直接修改全局状态,而是返回一个纯函数来修改上下文。这意味着:

  • 只有非并发安全的工具才能返回 contextModifier(并发安全的工具不应修改共享状态)
  • 修改在工具执行完成后才应用,保证了执行过程中的状态一致性
  • 测试时容易 assert contextModifier 的效果
function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D>

所有工具通过 buildTool() 工厂创建,它提供安全默认值

属性默认值设计意图
isEnabledtrue工具默认可用
isConcurrencySafefalse默认不能并发——安全第一
isReadOnlyfalse默认非只读——宁可多问,不可误写
isDestructivefalse需要显式声明
checkPermissions{ behavior: 'allow' }默认放行(交给外层权限系统)

★ 设计洞察:这就是Fail-Closed 哲学——当工具作者忘记声明某个属性时,系统会选择更安全的行为。忘记声明 isConcurrencySafe?那就串行执行。忘记声明 isReadOnly?那就当写操作处理。


getAllBaseTools() ← 所有工具(含 feature-gated 的)
↓ 过滤 deny 规则 + isEnabled
getTools(permCtx) ← 当前权限下可用的内置工具
↓ 合并 MCP 工具 + 去重 + 排序
assembleToolPool(permCtx, mcp) ← 最终工具池(发送给 API)

2.2 getAllBaseTools():单一真理来源

Section titled “2.2 getAllBaseTools():单一真理来源”
tools.ts
function getAllBaseTools(): Tools {
return [
// ── 始终可用 ──
AgentTool, BashTool, FileReadTool, FileEditTool, FileWriteTool,
GlobTool, GrepTool, WebFetchTool, WebSearchTool,
TaskCreateTool, TaskUpdateTool,
// ...30+ 始终可用的工具
// ── Feature-Gated ──
...(feature('KAIROS') ? [SleepTool, PushNotificationTool] : []),
...(feature('AGENT_TRIGGERS') ? [CronCreateTool, CronDeleteTool] : []),
...(feature('COORDINATOR_MODE') ? [CoordinatorModeTool] : []),
...(USER_TYPE === 'ant' ? [ConfigTool, REPLTool] : []),
// ...更多条件注册
]
}
function assembleToolPool(permissionContext, mcpTools): Tools {
// 1. 获取内置工具(已过滤 deny 规则和 isEnabled)
const builtins = getTools(permissionContext)
// 2. 过滤 MCP 工具的 deny 规则
const filteredMcp = filterToolsByDenyRules(mcpTools, permissionContext)
// 3. 去重(内置优先)
const deduped = deduplicateByName(builtins, filteredMcp)
// 4. 排序(prompt cache 稳定性)
// 内置工具连续排列,MCP 工具追加在后
return sortForPromptCacheStability(deduped)
}

★ 设计洞察:排序是为了 Prompt Cache 稳定性。API 的 prompt cache 是按消息前缀匹配的——如果工具列表的顺序每次变化,cache 就会失效。通过固定排序(内置在前、MCP 在后、同类按名字排),最大化 cache 命中率。

问题:50+ 工具的描述占用大量 token(系统提示词膨胀)
解决:不常用的工具标记 shouldDefer = true,只发送名字和 searchHint
激活流程:
1. 模型需要某个 deferred 工具
2. 模型调用 ToolSearch(query)
3. ToolSearch 返回完整的工具 schema
4. 模型现在可以调用这个工具了

API 返回 tool_use block
findToolByName(name) ← 查找工具(含别名回退)
Zod 输入校验 ← inputSchema.parse(input)
tool.validateInput() ← 工具特定验证
backfillObservableInput() ← 补充衍生字段(幂等)
runPreToolUseHooks() ← Pre-Hook(可修改输入、可阻止执行)
resolvePermission() ← Hook 权限 → canUseTool() → 用户对话框
tool.call() ← 实际执行
runPostToolUseHooks() ← Post-Hook(可处理结果)
yield ToolResult ← 返回结果给 Agent Loop
services/tools/toolExecution.ts
async function* runToolUse(
toolUse: ToolUseBlock,
assistantMessage: AssistantMessage,
canUseTool: CanUseToolFn,
toolUseContext: ToolUseContext,
): AsyncGenerator<MessageUpdateLazy, void>

关键特性:

  • Async generator:支持流式 yield 进度消息和最终结果
  • Deferred 工具提示:如果 Zod 校验失败且工具是 deferred 的,提示模型先用 ToolSearch
  • 投机性 Bash 分类器:BashTool 会在权限检查之前提前启动 ML 分类器(节省等待时间)

3.3 StreamingToolExecutor:边流式边执行

Section titled “3.3 StreamingToolExecutor:边流式边执行”
class StreamingToolExecutor {
// 工具在 API 流式返回过程中就入队执行
addTool(block: ToolUseBlock, message: AssistantMessage): void
// 取回已完成的结果(不阻塞,有多少返回多少)
getCompletedResults(): ToolResult[]
// 流式结束后,等待剩余工具完成
async *getRemainingResults(): AsyncGenerator<ToolResult>
}

并发语义

┌──────────────────────────────────────────────┐
│ StreamingToolExecutor │
│ │
│ Queue: [Read₁, Read₂, Edit₃, Grep₄] │
│ │
│ 执行策略: │
│ 1. Read₁ + Read₂ 并行(都是 concurrencySafe) │
│ 2. Edit₃ 独占执行(非 concurrencySafe) │
│ 3. Grep₄ 等 Edit₃ 完成后开始 │
│ │
│ canExecuteTool(tool) 检查: │
│ - 当前无正在执行的工具 → 可以执行 │
│ - 所有正在执行的 + 新工具 都是 concurrencySafe │
│ → 可以并行执行 │
│ - 否则 → 等待 │
│ │
│ 特殊规则: │
│ - Bash error → 取消同批次所有 sibling │
│ (因为 Bash 命令间常有隐式依赖) │
│ - 用户中断 → 按 interruptBehavior 决定 │
│ 'cancel' → 取消 'block' → 阻塞等待 │
└──────────────────────────────────────────────┘
services/tools/toolOrchestration.ts
async function* runTools(
toolUseBlocks: ToolUseBlock[],
assistantMessages: AssistantMessage[],
canUseTool: CanUseToolFn,
toolUseContext: ToolUseContext,
): AsyncGenerator<MessageUpdate, void>

分区算法(partitionToolCalls):

输入: [Grep, Read, FileEdit, Read, Glob]
分区结果:
Batch 1 (concurrent): [Grep, Read] ← 连续的只读工具
Batch 2 (serial): [FileEdit] ← 非只读,独占
Batch 3 (concurrent): [Read, Glob] ← 又是连续的只读
执行:
Batch 1: 并行执行,最大并发 10
Batch 2: 串行执行,apply contextModifier
Batch 3: 并行执行

最大并发数通过环境变量控制:

CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY = 10 (默认值)

BashTool 是最复杂的内置工具之一,值得深入研究。

z.strictObject({
command: z.string(), // Shell 命令
timeout: z.number().optional(), // 超时(ms, 最大 600000)
description: z.string().optional(), // 活动描述(spinner 文本)
run_in_background: z.boolean().optional(), // 后台执行
dangerouslyDisableSandbox: z.boolean().optional(), // 绕过沙箱
})
// BashTool 自报告的安全属性
isConcurrencySafe(input):
解析命令 → 检查是否有 cd、环境变量修改
只读命令 → safe
有 cd → not safe(改变工作目录)
isReadOnly(input):
使用 checkReadOnlyConstraints() + 语义分析
无文件写入、无副作用 → true
isSearchOrReadCommand(input):
搜索: find, grep, rg, ag, ack, locate, which, whereis
读取: cat, head, tail, less, more, jq, awk, cut, sort, uniq, wc, stat, file
列表: ls, tree, du
命令解析(shell-quote)
沙箱检查(sandbox bypass flag)
执行 shell 命令
每 2 秒 yield 进度消息(onProgress callback)
检测特殊情况:
- 代码索引完成?
- git 操作?
- 输出包含图片?
大输出?→ 持久化到磁盘
后台任务?→ 创建 BackgroundTask
返回 ToolResult

mcp__[server-name]__[tool-name]
示例:
mcp__github__create_issue
mcp__slack__send_message
mcp__figma__get_design_context
tools/MCPTool/MCPTool.ts
MCPTool 不是一个工具,而是一个工具工厂。
每个 MCP 服务器注册时,为其每个工具创建一个 MCPTool 实例。
MCPTool 实例 = {
name: `mcp__${serverName}__${toolName}`
inputJSONSchema: 从 MCP 服务器获取(JSON Schema,非 Zod)
call(): 验证输入 → 调用 MCP tool(100s 超时) → 处理 elicitation → 截断/返回结果
}

5.3 Elicitation:工具执行中的用户交互

Section titled “5.3 Elicitation:工具执行中的用户交互”
MCP 工具执行中 → 服务器发送 elicitation 请求(JSON-RPC -32042)
触发 Elicitation Hook
用户在 UI 中输入响应
ElicitationResult Hook 触发
工具恢复执行

安全关键系统的核心设计原则:默认选择更安全的行为

忘记声明 系统行为
───────── ─────────
isConcurrencySafe → 串行执行(安全)
isReadOnly → 当写操作处理(保守)
checkPermissions → 放行(交给外层)
maxResultSizeChars → 有限制(不会爆内存)
传统模式:
API stream ████████████ (5s)
Tool run ████ (3s)
总计: 8s
流式模式:
API stream ████████████ (5s)
Tool run ████ (3s, 从第 2s 开始)
总计: 5s (节省 37.5%)

contextModifier:副作用的函数式封装

Section titled “contextModifier:副作用的函数式封装”

工具不直接修改全局状态,而是返回一个修改函数。好处:

  1. 可组合:多个 modifier 可以链式应用
  2. 可测试:assert modifier(oldCtx) 的输出
  3. 可控制:只有非并发工具才能返回 modifier,避免竞态
50+ 工具 × ~200 tokens/工具描述 = ~10,000 tokens 系统提示词
Deferred 后:20 常用工具 + 30 个名字 ≈ ~5,000 tokens
节省:~50% 系统提示词 token

交互方向说明
← L1 (Agent Loop)queryLoop Phase 3 调用 runTools() / StreamingToolExecutor
→ L3 (权限系统)每次工具执行前通过 canUseTool()hasPermissionsToUseTool()
→ L4 (Hook 系统)Pre/Post ToolUse Hooks 在执行管线中触发
→ L5 (上下文管理)maxResultSizeChars 触发大结果外置到磁盘
→ L6 (子 Agent)AgentTool 是最复杂的工具,会递归创建新的 Agent Loop
← MCP 系统assembleToolPool() 合并 MCP 动态工具到工具池