Claude Code / s02

Tool Use:扩展模型能触达的边界

“加一个工具,只加一个 handler”

s01 只有一个 bash 工具。这一节加 3 个专用工具,同时引入一个关键设计:dispatch map

加工具不需要改循环。循环永远不变。


问题

只有 bash 时,所有操作都走 shell:

  • cat 输出可能被截断,不可预测
  • sed 遇到特殊字符就崩
  • 每次 bash 调用都是不受约束的安全面,没有路径沙箱

专用工具(read_filewrite_fileedit_file)可以在工具层做路径验证,防止模型意外访问工作区外的文件。

关键洞察:加工具不需要改循环。


dispatch map 设计

s01 的工具执行是硬编码的:

# s01:硬编码,每加一个工具就要改循环
if block.name == "bash":
    output = run_bash(block.input["command"])

s02 换成 dispatch map:

# s02:字典查找,加工具只加 handler
TOOL_HANDLERS = {
    "bash":       lambda **kw: run_bash(kw["command"]),
    "read_file":  lambda **kw: run_read(kw["path"], kw.get("limit")),
    "write_file": lambda **kw: run_write(kw["path"], kw["content"]),
    "edit_file":  lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
}

# 循环中:一行查找替代 if/elif 链
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"

循环体本身和 s01 完全一样,只是工具执行那行变成了字典查找。

这个 Python 字典正是 Claude Code 真实架构的简化映射——在 Claude Code 源码中,同样的思路被推到了工业级规模。


源码实证:Claude Code 的工具注册表

以下内容来自 Claude Code 泄露源码的真实分析。

工具注册:src/tools.ts

Claude Code 在 getAllBaseTools() 函数中返回一个 Tools 数组——这就是它的 “dispatch map”。核心工具直接注册,实验性工具通过 feature flag 条件注册:

// src/tools.ts — 工具注册表(简化)
export function getAllBaseTools(): Tools {
  return [
    AgentTool,
    TaskOutputTool,
    BashTool,
    // 内嵌搜索工具时跳过 Glob/Grep
    ...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
    FileReadTool,
    FileEditTool,
    FileWriteTool,
    NotebookEditTool,
    WebFetchTool,
    TodoWriteTool,
    WebSearchTool,
    SkillTool,
    AskUserQuestionTool,
    EnterPlanModeTool,
    ExitPlanModeV2Tool,
    // feature flag 条件注册
    ...(SleepTool ? [SleepTool] : []),
    ...cronTools,  // CronCreate, CronDelete, CronList
    ...(isWorktreeModeEnabled()
      ? [EnterWorktreeTool, ExitWorktreeTool] : []),
    ...(isTodoV2Enabled()
      ? [TaskCreateTool, TaskGetTool, TaskUpdateTool, TaskListTool] : []),
    ...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : []),
    // ... 共计 ~40 个工具
  ]
}

关键设计:数组展开 + 条件三元运算符feature('PROACTIVE') 返回 trueSleepTool 才会进入数组;process.env.USER_TYPE === 'ant' 控制 Anthropic 内部工具。这让同一个二进制产物能服务不同用户群。

完整工具清单

src/tools/ 目录下共 40+ 个工具目录:

类别 工具 作用
文件操作 FileReadTool, FileWriteTool, FileEditTool, NotebookEditTool 读、写、精确编辑、Jupyter 编辑
搜索 GlobTool, GrepTool, ToolSearchTool, LSPTool 文件名匹配、内容搜索、工具搜索、语言服务
执行 BashTool, PowerShellTool, REPLTool Shell 执行、PowerShell、REPL
Agent/协作 AgentTool, SkillTool, SendMessageTool, TeamCreateTool, TeamDeleteTool 子 Agent、技能调用、消息传递、团队管理
任务管理 TodoWriteTool, TaskCreateTool, TaskGetTool, TaskUpdateTool, TaskListTool, TaskOutputTool, TaskStopTool 待办事项、任务 CRUD、输出采集
Web WebFetchTool, WebSearchTool, WebBrowserTool URL 抓取、搜索引擎、浏览器
计划/模式 EnterPlanModeTool, ExitPlanModeV2Tool, EnterWorktreeTool, ExitWorktreeTool 计划模式、Git worktree 隔离
调度/监控 CronCreateTool, CronDeleteTool, CronListTool, SleepTool, MonitorTool, RemoteTriggerTool 定时任务、休眠、监控
交互 AskUserQuestionTool, BriefTool, ConfigTool, PushNotificationTool 向用户提问、简报、配置、推送通知
MCP ListMcpResourcesTool, ReadMcpResourceTool MCP 协议资源访问

Tool 类型定义:src/Tool.ts

每个工具都实现 Tool<Input, Output, Progress> 类型:

// src/Tool.ts — 核心类型(简化)
export type Tool<Input, Output, P> = {
  readonly name: string
  readonly inputSchema: Input           // Zod schema
  call(
    args: z.infer<Input>,
    context: ToolUseContext,
    canUseTool: CanUseToolFn,
    parentMessage: AssistantMessage,
    onProgress?: ToolCallProgress<P>,
  ): Promise<ToolResult<Output>>
  description(input, options): Promise<string>
  isEnabled(): boolean                  // 是否在当前环境可用
  isReadOnly(input): boolean            // 只读操作?
  isDestructive?(input): boolean        // 不可逆操作?
  isConcurrencySafe(input): boolean     // 可并发执行?
  maxResultSizeChars: number            // 结果超限时持久化到磁盘
  readonly shouldDefer?: boolean        // 延迟加载(需 ToolSearch 发现)
}

工具返回值:ToolResult<T>

export type ToolResult<T> = {
  data: T                    // 工具输出内容
  newMessages?: Message[]    // 可选:注入额外消息到上下文
  contextModifier?: (ctx: ToolUseContext) => ToolUseContext  // 可选:修改后续上下文
}

data 是返回给 LLM 的主体内容。newMessages 让工具可以向消息历史注入系统提示。contextModifier 让工具(如 EnterPlanModeTool)可以改变后续循环的行为。

ToolUseContext:工具运行时上下文

export type ToolUseContext = {
  messages: Message[]               // 完整对话历史
  abortController: AbortController  // 中断控制
  getAppState(): AppState           // 读全局状态
  setAppState(f): void              // 写全局状态
  queryTracking?: QueryChainTracking  // 查询链追踪
  options: {
    tools: Tools                    // 可用工具列表
    mcpClients: MCPServerConnection[]
    mainLoopModel: string
    // ...
  }
}

export type QueryChainTracking = {
  chainId: string   // 同一轮对话的链 ID
  depth: number     // 嵌套深度(Agent 中的 Agent)
}

权限模型:ToolPermissionContext

每次工具调用前都经过权限检查:

export type ToolPermissionContext = DeepImmutable<{
  mode: PermissionMode                    // 'default' | 'plan' | 'bypass'
  alwaysAllowRules: ToolPermissionRulesBySource   // 始终允许
  alwaysDenyRules: ToolPermissionRulesBySource     // 始终拒绝
  alwaysAskRules: ToolPermissionRulesBySource      // 始终询问
  additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>
  isBypassPermissionsModeAvailable: boolean
  shouldAvoidPermissionPrompts?: boolean  // 后台 Agent 自动拒绝
}>

权限检查的结果是三选一:allow(直接执行)、deny(拒绝并返回错误)、ask(弹出对话框请求用户授权)。工具还通过 isReadOnly()isDestructive() 标记自身的危险等级,filterToolsByDenyRules() 在工具列表发送给模型之前就过滤掉被禁止的工具。


路径沙箱

from pathlib import Path

WORKDIR = Path(".").resolve()

def safe_path(p: str) -> Path:
    """防止路径逃逸工作区"""
    path = (WORKDIR / p).resolve()
    if not path.is_relative_to(WORKDIR):
        raise ValueError(f"Path escapes workspace: {p}")
    return path

所有文件操作都通过 safe_path() 验证,模型无法读写工作区外的文件。

在 Claude Code 的真实实现中,ToolPermissionContext 中的 additionalWorkingDirectories 字段允许配置额外的合法工作目录,实现更灵活的沙箱策略。


四个工具的实现

import subprocess
from pathlib import Path

def run_bash(command: str) -> str:
    result = subprocess.run(
        command, shell=True, capture_output=True, text=True, timeout=30
    )
    return (result.stdout + result.stderr)[:50000]

def run_read(path: str, limit: int = None) -> str:
    text = safe_path(path).read_text()
    lines = text.splitlines()
    if limit and limit < len(lines):
        lines = lines[:limit]
    return "\n".join(lines)[:50000]

def run_write(path: str, content: str) -> str:
    p = safe_path(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(content)
    return f"Written: {path}"

def run_edit(path: str, old_text: str, new_text: str) -> str:
    p = safe_path(path)
    content = p.read_text()
    if old_text not in content:
        return f"Error: old_text not found in {path}"
    p.write_text(content.replace(old_text, new_text, 1))
    return f"Edited: {path}"

工具 Schema

每个工具需要一个 schema 告诉 LLM 怎么调用:

TOOLS = [
    {
        "name": "bash",
        "description": "执行 shell 命令",
        "input_schema": {
            "type": "object",
            "properties": {"command": {"type": "string"}},
            "required": ["command"]
        }
    },
    {
        "name": "read_file",
        "description": "读取文件内容,支持 limit 参数限制行数",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string"},
                "limit": {"type": "integer", "description": "最多读取的行数(可选)"}
            },
            "required": ["path"]
        }
    },
    {
        "name": "write_file",
        "description": "写入文件,自动创建父目录",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string"},
                "content": {"type": "string"}
            },
            "required": ["path", "content"]
        }
    },
    {
        "name": "edit_file",
        "description": "精确查找替换文件中的内容",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string"},
                "old_text": {"type": "string", "description": "要替换的原文本"},
                "new_text": {"type": "string", "description": "替换后的新文本"}
            },
            "required": ["path", "old_text", "new_text"]
        }
    }
]

在 Claude Code 中,schema 使用 Zod 定义(inputSchema 字段),运行时自动转换为 JSON Schema 发送给 API。每个工具还有动态 description() 方法,可以根据当前权限上下文生成不同的描述文本。


相对 s01 的变化

组件 s01 s02
工具数量 1(仅 bash) 4(bash + read + write + edit)
工具分发 硬编码 if TOOL_HANDLERS 字典
路径安全 safe_path() 沙箱
Agent loop 不变
flowchart LR A[LLM tool_use] --> B{TOOL_HANDLERS} B -->|bash| C[run_bash] B -->|read_file| D[run_read] B -->|write_file| E[run_write] B -->|edit_file| F[run_edit] C & D & E & F --> G[tool_result]

为什么 edit_file 比 bash sed 更好

# bash sed:遇到特殊字符(/、&、\n)就崩
sed -i 's/old_text/new_text/' file.py

# edit_file:Python 字符串替换,无特殊字符问题
run_edit("file.py", old_text="def foo():", new_text="def bar():")

edit_file 还有一个额外好处:如果 old_text 不存在,会返回明确的错误信息,而不是静默成功(sed 的常见陷阱)。


从 4 个工具到 40 个:架构启示

我们的 s02 只有 4 个工具,但架构设计和 Claude Code 的 40+ 工具注册表是同构的:

维度 s02 (Python) Claude Code (TypeScript)
注册表 TOOL_HANDLERS 字典 getAllBaseTools() 数组
Schema JSON dict Zod → JSON Schema
返回值 str ToolResult { data, newMessages?, contextModifier? }
权限 safe_path() ToolPermissionContext (allow/deny/ask)
条件注册 feature() flag + 环境变量
工具发现 全量发送 ToolSearchTool + shouldDefer 延迟加载

核心原则不变:加一个工具,只加一个 handler,循环永远不变。


设计哲学:Unix 哲学与工具设计

Claude Code 的工具系统深刻体现了 Unix 哲学:每个工具做好一件事,复杂功能通过组合完成

  • FileReadTool 只读文件
  • GrepTool 只搜索内容
  • FileEditTool 只做字符串替换
  • BashTool 只执行命令

复杂任务(如”重构认证模块”)不是由一个大而全的 RefactorTool 完成的,而是由 LLM 的推理能力编排多个原子工具完成。这和 Unix 管道的思想一致——cat file | grep pattern | sort | uniq 每一环只做一件事。

设计指南还指出一个容易忽视的细节:工具描述是”承重的艺术品”(load-bearing art form)。Claude Code 的每个工具描述都经过精心设计,因为模型完全依赖描述来决定何时使用哪个工具。一个模糊的描述会导致工具被误用或忽略。这就是为什么源码中 BashTool 的描述长达数十行——它不只是文档,而是模型决策的核心输入。

另一个 Unix 启发的设计:工具是无状态的。每次调用独立执行,通过 ToolUseContext 显式接收所有依赖,而不是通过全局变量。这让工具可以被安全地并行执行、被不同 Agent 复用。

这个原则的实际意义:加一个工具只需要加一个 handler 和一份 schema,Agent 循环永远不变。 这就是我们在 s02 中实践的设计。


下一篇:TodoWrite:让 Agent 不再迷路