第 11 课:只读工具 — FileRead, Glob, Grep

1 阅读8分钟

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


学习目标

完成本课后,你将能够:

  1. 描述 FileReadTool 如何统一处理文本、图片、PDF、Notebook 四种文件格式
  2. 解释 GlobTool 的模式匹配机制与修改时间排序策略
  3. 说明 GrepTool 基于 ripgrep 的三种输出模式及 head_limit 分页机制
  4. 识别三个只读工具共享的设计模式:isReadOnly()isConcurrencySafe()、权限检查委托

11.1 只读工具的共同特征

在深入每个工具之前,先看它们的共性。三个只读工具都实现了 buildTool() 返回的 ToolDef 接口,并且共享以下关键行为:

┌────────────────────────────────────────────────────────┐
│              只读工具共同特征                            │
├────────────────────────────────────────────────────────┤
│                                                        │
│  isReadOnly() → true      不修改任何文件               │
│  isConcurrencySafe() → true  可以并发执行              │
│  checkPermissions()       委托给 checkReadPermission   │
│  validateInput()          执行前校验参数合法性          │
│  expandPath()             统一路径规范化               │
│  toRelativePath()         输出路径节省 token           │
│  UNC 路径安全检查          防止 NTLM 凭据泄漏          │
│                                                        │
└────────────────────────────────────────────────────────┘

并发安全

isConcurrencySafe() 返回 true 意味着 Claude 可以在同一轮响应中并行发起多个只读工具调用。例如同时 Grep 搜索三个目录、同时 Read 两个文件 —— Agent 循环会并行执行它们。

权限检查委托

三个工具的权限检查都遵循同一模式:

async checkPermissions(input, context): Promise<PermissionDecision> {
  const appState = context.getAppState()
  return checkReadPermissionForTool(
    ThisTool,          // 工具自身引用
    input,             // 用户输入
    appState.toolPermissionContext,  // 当前权限上下文
  )
}

checkReadPermissionForTool 是一个共享的权限检查函数,它会根据当前权限模式(default/plan/bypass 等)决定是自动允许还是需要用户确认。


11.2 FileReadTool — 万能文件阅读器

文件位置

tools/FileReadTool/
├── FileReadTool.ts     # 核心逻辑(~600 行)
├── imageProcessor.ts   # 图片处理(sharp/napi 适配)
├── limits.ts           # 读取限制配置
├── prompt.ts           # 提示词模板
└── UI.tsx              # 终端渲染组件

输入参数

FileReadTool 的输入通过 Zod schema 严格定义:

const inputSchema = lazySchema(() =>
  z.strictObject({
    file_path: z.string(),           // 必须是绝对路径
    offset: z.number().int().nonnegative().optional(),  // 起始行号
    limit: z.number().int().positive().optional(),      // 读取行数
    pages: z.string().optional(),    // PDF 页码范围,如 "1-5"
  }),
)

注意 lazySchema() 的使用 —— 这是一个延迟求值包装器,避免模块加载时就构建 schema。

输出类型:六种联合类型

FileReadTool 的输出是一个 discriminated union,通过 type 字段区分六种情况:

┌──────────────────────────────────────────────────────┐
│              FileReadTool 输出类型                     │
├──────────────────────────────────────────────────────┤
│                                                      │
│  type: "text"              普通文本文件               │
│  ├── content: string       带行号的文件内容            │
│  ├── numLines: number      返回行数                   │
│  ├── startLine: number     起始行号                   │
│  └── totalLines: number    文件总行数                  │
│                                                      │
│  type: "image"             图片文件                   │
│  ├── base64: string        Base64 编码数据            │
│  ├── type: "image/jpeg" | "image/png" | ...          │
│  └── dimensions: { originalWidth, displayWidth, ... } │
│                                                      │
│  type: "notebook"          Jupyter Notebook           │
│  └── cells: Cell[]         所有 cell 内容             │
│                                                      │
│  type: "pdf"               小 PDF(直接发送)          │
│  └── base64: string        完整 PDF 数据              │
│                                                      │
│  type: "parts"             大 PDF(分页提取)          │
│  ├── count: number         提取页数                   │
│  └── outputDir: string     提取后的图片目录            │
│                                                      │
│  type: "file_unchanged"    文件未修改(去重)          │
│  └── filePath: string      文件路径                   │
│                                                      │
└──────────────────────────────────────────────────────┘

读取限制系统

limits.ts 定义了两层限制机制:

限制默认值检查时机溢出行为
maxSizeBytes256 KB读取前(stat)抛出错误
maxTokens25,000读取后(API 计数)抛出错误

限制值的优先级链:

环境变量 CLAUDE_CODE_FILE_READ_MAX_OUTPUT_TOKENS
  ↓ (未设置时)
GrowthBook 远程配置 (tengu_amber_wren)
  ↓ (未设置时)
DEFAULT_MAX_OUTPUT_TOKENS (25000)

getDefaultFileReadingLimits() 使用 memoize() 缓存,确保会话内限制值不会因 GrowthBook 后台刷新而变化。

文件去重机制

这是一个精妙的优化。当 Claude 在同一会话中多次读取同一个文件时,FileReadTool 会检测文件是否变化:

// 检查 readFileState 中的缓存记录
const existingState = readFileState.get(fullFilePath)
if (existingState && !existingState.isPartialView && existingState.offset !== undefined) {
  const rangeMatch = existingState.offset === offset && existingState.limit === limit
  if (rangeMatch) {
    const mtimeMs = await getFileModificationTimeAsync(fullFilePath)
    if (mtimeMs === existingState.timestamp) {
      // 文件未变化,返回简短的 stub 而非完整内容
      return {
        data: { type: 'file_unchanged', file: { filePath: file_path } }
      }
    }
  }
}

返回的 stub 消息只有约 100 字节,而完整文件内容可能有 25K token。源码注释中提到,BQ 代理数据显示 约 18% 的 Read 调用是同文件重复读取,这个优化直接减少了 cache_creation token 消耗。

图片处理管道

imageProcessor.ts 展示了一个优雅的动态加载模式:

export async function getImageProcessor(): Promise<SharpFunction> {
  if (imageProcessorModule) {
    return imageProcessorModule.default  // 缓存命中
  }

  if (isInBundledMode()) {
    try {
      // 优先使用原生 NAPI 图片处理器
      const imageProcessor = await import('image-processor-napi')
      const sharp = imageProcessor.sharp || imageProcessor.default
      imageProcessorModule = { default: sharp }
      return sharp
    } catch {
      // 降级到 sharp
      console.warn('Native image processor not available, falling back to sharp')
    }
  }

  // 非打包模式或降级使用 sharp
  const imported = await import('sharp')
  // ...
}

这里体现了两个设计策略:

  1. 原生优先:打包模式下优先使用 image-processor-napi(性能更好)
  2. 优雅降级:原生模块不可用时自动回退到 sharp

被阻止的设备路径

FileReadTool 维护了一个被阻止的设备路径集合,防止进程挂起:

const BLOCKED_DEVICE_PATHS = new Set([
  '/dev/zero',     // 无限输出
  '/dev/random',   // 无限输出
  '/dev/urandom',  // 无限输出
  '/dev/stdin',    // 阻塞等待输入
  '/dev/tty',      // 阻塞等待输入
  '/dev/console',  // 阻塞等待输入
  '/dev/stdout',   // 读取无意义
  '/dev/stderr',   // 读取无意义
])

macOS 截图路径兼容

一个有趣的边缘案例处理 —— macOS 截图文件名中 AM/PM 前的空格字符因系统版本不同可能是普通空格或窄不换行空格 (U+202F):

function getAlternateScreenshotPath(filePath: string): string | undefined {
  const filename = path.basename(filePath)
  const amPmPattern = /^(.+)([ \u202F])(AM|PM)(\.png)$/
  const match = filename.match(amPmPattern)
  if (!match) return undefined
  const currentSpace = match[2]
  const alternateSpace = currentSpace === ' ' ? THIN_SPACE : ' '
  return filePath.replace(/* ... */)
}

11.3 GlobTool — 文件模式匹配

文件位置

tools/GlobTool/
├── GlobTool.ts    # 核心逻辑(~198 行)
├── prompt.ts      # 工具描述
└── UI.tsx         # 渲染组件

设计简洁性

GlobTool 是三个只读工具中最简洁的,只有两个输入参数:

const inputSchema = lazySchema(() =>
  z.strictObject({
    pattern: z.string(),  // glob 模式,如 "**/*.ts"
    path: z.string().optional(),  // 搜索根目录,默认 cwd
  }),
)

核心执行流程

用户输入 pattern + path
  │
  ▼
validateInput() ─── 验证 path 存在且是目录
  │
  ▼
glob(pattern, path, { limit, offset }, signal, permissionContext)
  │                        │
  │                        └── 100 条结果限制
  ▼
files.map(toRelativePath)  ── 转为相对路径节省 token
  │
  ▼
返回 { filenames, durationMs, numFiles, truncated }

关键特性

结果限制:默认最多返回 100 个文件匹配结果。如果超过,truncated 标志设为 true,模型会收到提示"Consider using a more specific path or pattern."

修改时间排序:GlobTool 本身依赖底层 glob() 工具函数的实现,返回的文件按修改时间排序。这让 Claude 优先看到最近修改的文件,通常这些文件与当前任务最相关。

路径相对化

const filenames = files.map(toRelativePath)

绝对路径 /Users/oker/project/src/index.ts 变成 src/index.ts,在长列表中显著节省 token。

UI 复用

一个值得注意的细节 —— GlobTool 的 renderToolResultMessage 复用了 GrepTool 的实现:

// UI.tsx
export const renderToolResultMessage = GrepTool.renderToolResultMessage

因为两者的结果展示格式相似(文件列表 + 统计),没有必要重复实现。


11.4 GrepTool — 基于 ripgrep 的内容搜索

文件位置

tools/GrepTool/
├── GrepTool.ts    # 核心逻辑(~577 行)
├── prompt.ts      # 工具描述
└── UI.tsx         # 渲染组件

丰富的输入参数

GrepTool 是三个只读工具中参数最多的:

z.strictObject({
  pattern: z.string(),               // 正则表达式
  path: z.string().optional(),        // 搜索路径
  glob: z.string().optional(),        // 文件过滤模式
  output_mode: z.enum([               // 三种输出模式
    'content',           // 显示匹配行内容
    'files_with_matches', // 只显示文件路径(默认)
    'count',              // 显示匹配计数
  ]).optional(),
  '-B': z.number().optional(),        // 匹配前上下文行数
  '-A': z.number().optional(),        // 匹配后上下文行数
  '-C': z.number().optional(),        // 上下文行数(-C 别名)
  context: z.number().optional(),     // 上下文行数
  '-n': z.boolean().optional(),       // 显示行号(默认 true)
  '-i': z.boolean().optional(),       // 忽略大小写
  type: z.string().optional(),        // 文件类型过滤
  head_limit: z.number().optional(),  // 结果数量限制
  offset: z.number().optional(),      // 结果偏移量
  multiline: z.boolean().optional(),  // 多行匹配模式
})

三种输出模式

┌───────────────────────────────────────────────────────────┐
│                GrepTool 输出模式                           │
├───────────────────────────────────────────────────────────┤
│                                                           │
│  files_with_matches (默认)                                │
│  ┌─────────────────────────┐                              │
│  │ src/utils/file.ts       │  只返回文件路径               │
│  │ src/tools/BashTool.tsx  │  按修改时间排序               │
│  │ tests/file.test.ts      │  相对路径节省 token           │
│  └─────────────────────────┘                              │
│                                                           │
│  content                                                  │
│  ┌─────────────────────────┐                              │
│  │ src/utils/file.ts:42:   │  显示文件名:行号:内容         │
│  │   export function foo() │  支持 -A/-B/-C 上下文         │
│  │ src/utils/file.ts:85:   │  支持 -n 行号显示             │
│  │   return foo()          │                              │
│  └─────────────────────────┘                              │
│                                                           │
│  count                                                    │
│  ┌─────────────────────────┐                              │
│  │ src/utils/file.ts:3     │  文件名:匹配次数              │
│  │ src/tools/BashTool.tsx:1│  汇总总匹配数和文件数          │
│  └─────────────────────────┘                              │
│                                                           │
└───────────────────────────────────────────────────────────┘

head_limit 分页机制

GrepTool 实现了一个精心设计的分页系统:

const DEFAULT_HEAD_LIMIT = 250  // 默认限制

function applyHeadLimit<T>(
  items: T[],
  limit: number | undefined,
  offset: number = 0,
): { items: T[]; appliedLimit: number | undefined } {
  // 显式传入 0 = 不限制(逃生舱口)
  if (limit === 0) {
    return { items: items.slice(offset), appliedLimit: undefined }
  }
  const effectiveLimit = limit ?? DEFAULT_HEAD_LIMIT
  const sliced = items.slice(offset, offset + effectiveLimit)
  // 只在实际发生截断时才报告 appliedLimit
  const wasTruncated = items.length - offset > effectiveLimit
  return {
    items: sliced,
    appliedLimit: wasTruncated ? effectiveLimit : undefined,
  }
}

源码注释解释了为什么需要这个限制:

无限制的 content 模式 grep 可以填满 20KB 的持久化阈值(每个 grep 密集会话约 6-24K token)。250 对探索性搜索来说已经足够慷慨,同时防止上下文膨胀。

ripgrep 调用构建

GrepTool 的核心是构建 ripgrep 命令行参数:

const args = ['--hidden']

// 排除版本控制目录
for (const dir of VCS_DIRECTORIES_TO_EXCLUDE) {
  args.push('--glob', `!${dir}`)  // .git, .svn, .hg, .bzr, .jj, .sl
}

// 限制行长度,防止 base64/压缩内容污染输出
args.push('--max-columns', '500')

// 多行匹配
if (multiline) {
  args.push('-U', '--multiline-dotall')
}

// 构建忽略模式(来自权限系统)
const ignorePatterns = normalizePatternsToPath(
  getFileReadIgnorePatterns(appState.toolPermissionContext),
  getCwd(),
)
for (const ignorePattern of ignorePatterns) {
  const rgIgnorePattern = ignorePattern.startsWith('/')
    ? `!${ignorePattern}`
    : `!**/${ignorePattern}`
  args.push('--glob', rgIgnorePattern)
}

注意几个安全细节:

  • - 开头的 pattern 会用 -e 标志包裹,防止被 ripgrep 解释为命令行选项
  • 版本控制目录自动排除(.git, .svn, .hg 等)
  • 行长度限制为 500 字符,防止 base64 或 minified 内容污染搜索结果

files_with_matches 模式的修改时间排序

当使用默认模式时,GrepTool 会对匹配的文件按修改时间排序:

const stats = await Promise.allSettled(
  results.map(_ => getFsImplementation().stat(_)),
)
const sortedMatches = results
  .map((_, i) => {
    const r = stats[i]!
    return [_, r.status === 'fulfilled' ? (r.value.mtimeMs ?? 0) : 0] as const
  })
  .sort((a, b) => {
    if (process.env.NODE_ENV === 'test') {
      return a[0].localeCompare(b[0])  // 测试环境按文件名排序,保证确定性
    }
    const timeComparison = b[1] - a[1]
    if (timeComparison === 0) {
      return a[0].localeCompare(b[0])  // 时间相同按文件名
    }
    return timeComparison
  })

使用 Promise.allSettled 而非 Promise.all,这样即使某个文件在 ripgrep 扫描后被删除(stat 失败),也不会影响其他结果。


11.5 工具结果序列化

三个工具都实现了 mapToolResultToToolResultBlockParam(),负责将结构化输出转换为 Claude API 能理解的 tool_result 格式。

GlobTool 的序列化

mapToolResultToToolResultBlockParam(output, toolUseID) {
  if (output.filenames.length === 0) {
    return {
      tool_use_id: toolUseID,
      type: 'tool_result',
      content: 'No files found',
    }
  }
  return {
    tool_use_id: toolUseID,
    type: 'tool_result',
    content: [
      ...output.filenames,
      ...(output.truncated
        ? ['(Results are truncated. Consider using a more specific path or pattern.)']
        : []),
    ].join('\n'),
  }
}

GrepTool 的序列化(content 模式)

if (mode === 'content') {
  const limitInfo = formatLimitInfo(appliedLimit, appliedOffset)
  const resultContent = content || 'No matches found'
  const finalContent = limitInfo
    ? `${resultContent}\n\n[Showing results with pagination = ${limitInfo}]`
    : resultContent
  return {
    tool_use_id: toolUseID,
    type: 'tool_result',
    content: finalContent,
  }
}

当结果被截断时,分页信息会附加在末尾,让模型知道可以使用 offset 参数查看更多结果。


11.6 搜索文本提取

三个工具都实现了 extractSearchText(),用于全文搜索索引:

工具extractSearchText 行为
FileReadTool返回空字符串 —— 文件内容不索引(避免重复)
GlobTool返回 filenames.join('\n') —— 索引文件路径
GrepToolcontent 模式返回内容,其他模式返回文件路径

FileReadTool 返回空字符串的原因在源码注释中有说明:

UI.tsx:140 — ALL types render summary chrome only: "Read N lines", "Read image (42KB)". Never the content itself. The model-facing serialization sends content; UI shows none of it. Nothing to index.


课后练习

练习 1:追踪一次图片读取

假设用户说"帮我看看 /tmp/screenshot.png",从 FileReadTool.call() 开始追踪:

  1. 文件类型如何被识别?
  2. 图片如何被压缩和编码?
  3. 最终发送给 Claude API 的数据结构是什么样的?

练习 2:实现一个自定义只读工具

参考 GlobTool 的结构,设计一个 TreeTool(返回目录树),需要包含:

  • inputSchema(root_path、max_depth、include_hidden)
  • outputSchema(tree_string、numDirs、numFiles)
  • isReadOnly() 返回 true

练习 3:GrepTool 分页实验

考虑以下场景:一个项目中有 1000 个文件匹配 import。用 head_limit=50 和 offset=0 做第一次搜索,然后用 offset=50 做第二次。

  1. 两次调用的 appliedLimit 分别是什么?
  2. 如果第二次调用的 offset=950,返回 50 个结果,appliedLimit 是多少?
  3. 如果传入 head_limit=0 会发生什么?

练习 4:分析去重收益

FileReadTool 的去重机制(file_unchanged stub)用约 100 字节替代了可能 25K token 的完整内容。源码提到 18% 的 Read 调用是重复的。计算一下:如果一个会话中有 50 次 Read 调用,每次平均 10K token,去重能节省多少 token?


本课小结

要点内容
共同特征isReadOnly=true, isConcurrencySafe=true, 共享权限检查委托
FileReadTool六种输出类型联合体,支持文本/图片/PDF/Notebook,去重优化节省 18% 重复读取
GlobTool最简洁的工具(~200 行),修改时间排序,100 条限制,复用 GrepTool 的 UI
GrepTool基于 ripgrep,三种输出模式,head_limit=250 分页防止上下文膨胀
安全机制UNC 路径检查、设备路径阻止、VCS 目录排除、行长度限制
性能优化路径相对化节省 token、去重 stub、Promise.allSettled 容错

下一课预告

第 12 课:写入工具 — FileEdit, FileWrite — 我们将进入"有副作用"的工具领域。FileEditTool 如何保证 old_string 的唯一性?如何处理花引号规范化?FileWriteTool 为什么强制"先读后写"?以及 speculation(乐观更新)状态追踪如何工作?