Claude Code源码剖析 - 文件读取与代码搜索工具
Phase 4: 文件读取与代码搜索工具
src/tools/FileReadTool/FileReadTool.ts:Read 工具
Read 工具的输入、输出与注册
第一部分是输入schema
const inputSchema = lazySchema(() =>
z.strictObject({
file_path: z.string().describe('The absolute path to the file to read'),
offset: semanticNumber(z.number().int().nonnegative().optional()).describe(...),
limit: semanticNumber(z.number().int().positive().optional()).describe(...),
pages: z.string().optional().describe(...),
}),
)
Read 工具解决的问题是:让模型可以通过结构化 tool_use 请求读取本地文件内容。
它的输入不是随便传字符串,而是由 Zod schema 约束:
file_path:要读取的文件路径。offset:从第几行开始读取。limit:读取多少行。pages:PDF 文件的页码范围。
这说明 Claude Code 从工具设计层面就考虑了上下文长度问题。大文件不能总是一次性读完,所以 Read 支持局部读取。
第二部分是输出schema
return z.discriminatedUnion('type', [
{ type: 'text', ... },
{ type: 'image', ... },
{ type: 'notebook', ... },
{ type: 'pdf', ... },
{ type: 'parts', ... },
{ type: 'file_unchanged', ... },
])
Read 的输出也是结构化的 discriminated union,不只是字符串:
text:普通文本文件。image:图片文件。notebook:Jupyter notebook。pdf:PDF 文件。parts:PDF 拆页结果。file_unchanged:文件没有变化,不重复返回全文。
第三部分是将Tool注册到Claude Code
export const FileReadTool = buildTool({
name: FILE_READ_TOOL_NAME,
searchHint: 'read files, images, PDFs, notebooks',
maxResultSizeChars: Infinity,
strict: true,
async description() {
return DESCRIPTION
},
async prompt() {
...
return renderPromptTemplate(...)
},
})
// name: 'Read':模型发 tool_use.name = "Read" 时,本地 runtime 能找到它。
// searchHint:告诉系统这个工具适合读文件、图片、PDF、notebook。
// strict: true:输入要严格符合 schema。
// description():短描述。
// prompt():给模型看的详细使用说明。
可以这样记:
Read 工具 =
inputSchema:模型该怎么请求读取
outputSchema:工具可能读出什么类型
buildTool:把它注册成 Claude Code 的 Tool 对象
它和前面 Phase3 的闭环关系是:
assistant/tool_use(name="Read", input={ file_path, offset, limit })
-> FileReadTool.call()
-> ToolResult<ReadOutput>
-> mapToolResultToToolResultBlockParam()
-> user/tool_result
-> 下一轮模型继续分析文件内容
工具属性、路径归属与权限检查
这一段把 Read 工具接入 Claude Code 的统一 Tool 运行规则。
首先,它把输入输出 schema 暴露给 Tool 系统:
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
这说明 Read 工具不是裸函数,而是一个带输入结构和输出结构的 Tool 对象。
然后它声明自己的执行属性,判断这个工具可不可以支持并发
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
含义是:
Read 是只读工具。
Read 可以并发执行。
这很重要,因为 Claude Code 的工具编排会根据 isConcurrencySafe() 决定能不能并发跑;权限系统也会根据 isReadOnly() 区分低风险读取和高风险修改。
接着,源码提供路径相关信息:
toAutoClassifierInput(input) {
return input.file_path
},
isSearchOrReadCommand() {
return { isSearch: false, isRead: true }
},
getPath({ file_path }): string {
return file_path || getCwd()
},
这些函数告诉 runtime:
- 这个工具操作的核心对象是
file_path。 - 这是 read 工具,不是 search 工具。
- 如果没有路径,就默认使用当前工作目录。
backfillObservableInput() 会提前规范化路径:
backfillObservableInput(input) {
if (typeof input.file_path === 'string') {
input.file_path = expandPath(input.file_path)
}
},
这样可以避免 hook allowlist 被 ~、相对路径或路径空白绕过。
最后是权限检查:
async checkPermissions(input, context): Promise<PermissionDecision> {
const appState = context.getAppState()
return checkReadPermissionForTool(
FileReadTool,
input,
appState.toolPermissionContext,
)
},
它接回 Phase3 讲过的 Tool 执行流程:
assistant/tool_use
-> 找到 FileReadTool
-> inputSchema 校验
-> validateInput
-> checkPermissions
-> call
-> tool_result
这一段的核心不是读取文件本身,而是声明 Read 工具在 runtime 里的安全属性:
Read = 只读 + 可并发 + 有路径归属 + 需要经过读取权限检查
validateInput:读取前的参数与安全校验
源码位置:src/tools/FileReadTool/FileReadTool.ts:418-495
validateInput() 发生在真正执行 Read.call() 之前。它的作用是先判断这次读取请求是否明显不合法或危险。
入口:
async validateInput({ file_path, pages }, toolUseContext: ToolUseContext)
这一段主要检查两类东西:
pages:PDF 页码范围是否合法。file_path:路径是否被权限规则拒绝、是否是危险设备文件、是否是不支持的二进制文件。
第一步是校验 PDF 页码:
if (pages !== undefined) {
const parsed = parsePDFPageRange(pages)
if (!parsed) {
return {
result: false,
message: `Invalid pages parameter: "${pages}". Use formats like "1-5", "3", or "10-20". Pages are 1-indexed.`,
errorCode: 7,
}
}
...
}
如果格式不是 "1-5"、"3"、"10-20" 这类合法范围,就返回失败。
接着限制一次最多读取的 PDF 页数:
if (rangeSize > PDF_MAX_PAGES_PER_READ) {
return {
result: false,
message: `Page range "${pages}" exceeds maximum of ${PDF_MAX_PAGES_PER_READ} pages per request. Please use a smaller range.`,
errorCode: 8,
}
}
这体现了一个重要原则:
Read 工具会主动限制一次读取的规模,避免把过大的内容塞进上下文。
第二步是展开路径,并检查 deny rule:
const fullFilePath = expandPath(file_path)
const denyRule = matchingRuleForInput(
fullFilePath,
appState.toolPermissionContext,
'read',
'deny',
)
如果路径命中用户配置里的 deny 规则,就拒绝读取:
return {
result: false,
message:
'File is in a directory that is denied by your permission settings.',
errorCode: 1,
}
这说明:
Read 虽然是只读工具,但不是无条件允许。
用户可以通过权限规则禁止读取某些路径。
第三步是特殊处理 UNC 路径:
const isUncPath =
fullFilePath.startsWith('\\\\') || fullFilePath.startsWith('//')
if (isUncPath) {
return { result: true }
}
源码注释说明,这里不能在用户授权前做文件系统访问,否则可能触发 NTLM credential 泄露。
所以这一段保持“只做字符串判断,不做 I/O”。
第四步是拒绝不支持的二进制文件:
if (
hasBinaryExtension(fullFilePath) &&
!isPDFExtension(ext) &&
!IMAGE_EXTENSIONS.has(ext.slice(1))
) {
return {
result: false,
message: `This tool cannot read binary files...`,
errorCode: 4,
}
}
这里的规则是:
普通二进制文件:拒绝
PDF:允许,因为有专门处理逻辑
图片:允许,因为 Claude Code 可以把图片作为多模态输入
第五步是阻止危险设备文件:
if (isBlockedDevicePath(fullFilePath)) {
return {
result: false,
message: `Cannot read '${file_path}': this device file would block or produce infinite output.`,
errorCode: 9,
}
}
例如 /dev/zero、/dev/random、/dev/stdin 这类路径,读取时可能无限输出或阻塞进程。
最后,如果所有检查都通过:
return { result: true }
可以这样记:
validateInput()
= 在真正读文件之前,先做无 I/O 或低风险校验:
PDF 页码是否合法
路径是否被 deny
是否是 UNC 路径
是否是不支持的二进制
是否是会阻塞或无限输出的设备文件
它和 Phase3 的工具执行流程关系是:
inputSchema.safeParse()
-> validateInput()
-> checkPermissions()
-> call()
validateInput() 判断“参数和路径本身有没有明显问题”;checkPermissions() 判断“当前权限上下文是否允许这次读取”
call:读取前的限制、去重、skill 触发与错误提示
源码位置:src/tools/FileReadTool/FileReadTool.ts:496-651
call() 是 Read 工具真正执行时的入口,但这一段还不是实际读取文件内容的核心。它更像外层调度器,负责准备上下文,然后调用 callInner()。
入口:
async call(
{ file_path, offset = 1, limit = undefined, pages },
context,
_canUseTool?,
parentMessage?,
)
几个关键参数:
file_path:要读取的文件路径。offset = 1:默认从第 1 行开始读。limit = undefined:默认不显式指定读取行数。pages:PDF 文件的页码范围。context:工具运行上下文。parentMessage:当前工具调用关联的上层 message。
第一步是读取限制:
const { readFileState, fileReadingLimits } = context
const defaults = getDefaultFileReadingLimits()
const maxSizeBytes =
fileReadingLimits?.maxSizeBytes ?? defaults.maxSizeBytes
const maxTokens = fileReadingLimits?.maxTokens ?? defaults.maxTokens
Read 工具有两个重要限制:
maxSizeBytes:文件大小上限
maxTokens:返回给模型的 token 上限
如果当前 ToolUseContext 提供了 fileReadingLimits,就优先使用上下文里的覆盖值;否则使用默认限制。
第二步是路径规范化:
const ext = path.extname(file_path).toLowerCase().slice(1)
const fullFilePath = expandPath(file_path)
这里会提取文件扩展名,并把路径展开成更稳定的形式。
第三步是重复读取去重:
const existingState = dedupKillswitch
? undefined
: readFileState.get(fullFilePath)
如果之前已经读过同一个文件、同一个范围,并且文件修改时间没有变化:
if (mtimeMs === existingState.timestamp) {
return {
data: {
type: 'file_unchanged' as const,
file: { filePath: file_path },
},
}
}
也就是说,Read 不会再次返回完整文件内容,而是返回 file_unchanged。
原因是:之前的 Read tool_result 本来还在 conversation messages 里,重复塞一份全文会浪费上下文 token。
可以这样记:
同文件 + 同读取范围 + mtime 没变
-> 返回 file_unchanged
-> 让模型引用之前已有的读取结果
第四步是根据路径发现和激活 skills:
const newSkillDirs = await discoverSkillDirsForPaths([fullFilePath], cwd)
...
addSkillDirectories(newSkillDirs).catch(() => {})
...
activateConditionalSkillsForPaths([fullFilePath], cwd)
这不是文件读取主线,而是一个路径触发的扩展机制。
当 Read 观察到某些路径时,系统可能发现相关 skill,并把它加入后续上下文。
其中 addSkillDirectories(...).catch(() => {}) 没有 await,说明加载 skill 是后台动作,不阻塞当前读取。
第五步是调用真正读取逻辑:
return await callInner(
file_path,
fullFilePath,
fullFilePath,
ext,
offset,
limit,
pages,
maxSizeBytes,
maxTokens,
readFileState,
context,
parentMessage?.message.id,
)
call() 自己不负责解析文本、图片、PDF、notebook。
真正的读取和分支处理在 callInner() 中。
第六步是处理文件不存在:
if (code === 'ENOENT') {
const altPath = getAlternateScreenshotPath(fullFilePath)
...
const similarFilename = findSimilarFile(fullFilePath)
const cwdSuggestion = await suggestPathUnderCwd(fullFilePath)
...
throw new Error(message)
}
这里做了两个友好修正:
- macOS 截图文件名里的 AM/PM 前空格可能是普通空格,也可能是窄空格,所以会尝试 alternate screenshot path。
- 如果文件不存在,会尝试给出相似文件名或当前工作目录下的建议路径。
可以这样记:
call()
= 准备读取限制
+ 规范化路径
+ 避免重复读取
+ 触发 skill 发现
+ 调用 callInner()
+ 把文件不存在变成更可修正的错误提示
它在工具执行链路中的位置是:
validateInput()
-> checkPermissions()
-> call()
-> callInner()
-> ToolResult<Output>
mapToolResultToToolResultBlockParam:把读取结果回填成 tool_result
源码位置:src/tools/FileReadTool/FileReadTool.ts:652-718
mapToolResultToToolResultBlockParam() 负责把 Read.call() 返回的内部结构化结果转换成模型下一轮能看到的 tool_result block。
入口:
mapToolResultToToolResultBlockParam(data, toolUseID) {
switch (data.type) {
...
}
}
这里按 data.type 分支处理。因为 Read 的输出是 discriminated union,所以每种文件读取结果都有自己的映射方式。
图片结果会被映射成 image content block:
case 'image': {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: [
{
type: 'image',
source: {
type: 'base64',
data: data.file.base64,
media_type: data.file.type,
},
},
],
}
}
这说明 tool_result.content 不一定是字符串,也可以是 content block 数组。
因此 Read 工具可以把图片交给多模态模型理解。
notebook 结果交给专门函数处理:
case 'notebook':
return mapNotebookCellsToToolResult(data.file.cells, toolUseID)
Jupyter notebook 可能包含 markdown、代码、执行输出和图像,所以需要独立映射逻辑。
PDF 结果只在 tool_result 中返回元信息:
case 'pdf':
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: `PDF file read: ${data.file.filePath} (${formatFileSize(data.file.originalSize)})`,
}
源码注释说明,PDF 的实际内容会作为额外的 DocumentBlockParam 发送。
所以 tool_result 不一定承载全部原始内容。
file_unchanged 对应前面 call() 的重复读取去重:
case 'file_unchanged':
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: FILE_UNCHANGED_STUB,
}
含义是:
文件自上次读取后没有变化。
前面那次 Read tool_result 仍然有效,不需要重复返回全文。
文本文件是最常见的分支:
case 'text': {
let content: string
if (data.file.content) {
content =
memoryFileFreshnessPrefix(data) +
formatFileLines(data.file) +
(shouldIncludeFileReadMitigation()
? CYBER_RISK_MITIGATION_REMINDER
: '')
} else {
content =
data.file.totalLines === 0
? '<system-reminder>Warning: the file exists but the contents are empty.</system-reminder>'
: `<system-reminder>Warning: the file exists but is shorter than the provided offset (${data.file.startLine}). The file has ${data.file.totalLines} lines.</system-reminder>`
}
return {
tool_use_id: toolUseID,
type: 'tool_result',
content,
}
}
如果文件有内容,返回内容由三部分组成:
memory freshness 前缀
+ 带行号的文件内容
+ 可选安全提醒
其中 formatFileLines() 会把文件内容格式化成带行号的形式,类似 cat -n。
这对 coding agent 很重要,因为模型后续需要根据行号定位和解释代码。
如果文件没有内容,则返回 system reminder:
- 文件存在但内容为空。
- 或者读取 offset 超过文件总行数。
这比返回空字符串更清楚,因为模型能理解为什么没有读到内容。
这一段可以这样记:
Read.call() 得到内部 Output
-> mapToolResultToToolResultBlockParam()
-> 变成 Anthropic API 的 tool_result block
-> 作为 user message 回填给下一轮模型
它和前三阶段的关系是:
Phase 2: tool_result 是 user message 的 content block
Phase 3: Tool 负责把内部结果映射成 tool_result
Phase 4: Read 根据 text/image/pdf/notebook 等不同结果类型分别映射
辅助函数:行号、安全提醒、token 校验与图片结果
源码位置:src/tools/FileReadTool/FileReadTool.ts:720-803
这一段是 Read 工具进入 callInner() 前的辅助层,主要负责:
- 文件内容行号格式化
- 读取结果中的安全提醒
- memory 文件的新鲜度提示
- 文件内容 token 上限检查
- 图片读取结果的内部结构构造
行号格式化来自:
function formatFileLines(file: { content: string; startLine: number }): string {
return addLineNumbers(file)
}
这会把文件内容转成带行号的格式。
对 coding agent 来说,行号很重要,因为后续解释代码、定位 bug、执行编辑都需要稳定的位置参考。
安全提醒定义在:
export const CYBER_RISK_MITIGATION_REMINDER =
'\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware...'
它用于提醒模型:可以分析可疑代码或恶意代码,但不能帮助增强恶意能力。
是否追加提醒由当前主循环模型决定:
const MITIGATION_EXEMPT_MODELS = new Set(['claude-opus-4-6'])
function shouldIncludeFileReadMitigation(): boolean {
const shortName = getCanonicalName(getMainLoopModel())
return !MITIGATION_EXEMPT_MODELS.has(shortName)
}
memory 文件的新鲜度提示使用了一个 WeakMap:
const memoryFileMtimes = new WeakMap<object, number>()
function memoryFileFreshnessPrefix(data: object): string {
const mtimeMs = memoryFileMtimes.get(data)
if (mtimeMs === undefined) return ''
return memoryFreshnessNote(mtimeMs)
}
这里的设计点是:mtime 只是展示层需要的临时信息,不适合加入 outputSchema。
所以源码用 WeakMap 按结果对象 identity 暂存修改时间。
可以这样记:
callInner() 知道文件 mtime
mapToolResultToToolResultBlockParam() 需要 freshness note
但 outputSchema 不想暴露 mtime
所以用 WeakMap 做旁路传递
token 上限检查在:
async function validateContentTokens(
content: string,
ext: string,
maxTokens?: number,
): Promise<void> {
const effectiveMaxTokens =
maxTokens ?? getDefaultFileReadingLimits().maxTokens
const tokenEstimate = roughTokenCountEstimationForFileType(content, ext)
if (!tokenEstimate || tokenEstimate <= effectiveMaxTokens / 4) return
const tokenCount = await countTokensWithAPI(content)
const effectiveCount = tokenCount ?? tokenEstimate
if (effectiveCount > effectiveMaxTokens) {
throw new MaxFileReadTokenExceededError(effectiveCount, effectiveMaxTokens)
}
}
它先粗略估算 token。
如果估算值明显很小,就直接通过,避免调用 API。
只有当估算值可能接近上限时,才调用 countTokensWithAPI() 精确计数。
如果最终 token 数超过上限,就抛出:
MaxFileReadTokenExceededError
这说明 Read 工具不是无限制地把文件内容塞进上下文,而是在返回模型前做 token 预算保护。
图片读取结果的内部结构是:
type ImageResult = {
type: 'image'
file: {
base64: string
type: Base64ImageSource['media_type']
originalSize: number
dimensions?: ImageDimensions
}
}
构造函数:
function createImageResponse(
buffer: Buffer,
mediaType: string,
originalSize: number,
dimensions?: ImageDimensions,
): ImageResult {
return {
type: 'image',
file: {
base64: buffer.toString('base64'),
type: `image/${mediaType}` as Base64ImageSource['media_type'],
originalSize,
dimensions,
},
}
}
这里生成的是 Read 工具内部的 ImageResult。
真正把它变成 API 的 image content block,是前面的 mapToolResultToToolResultBlockParam() 负责的。
这一段可以这样记:
文本读取:加行号 + 控 token + 可加安全提醒
图片读取:转 base64 + 标记 media type + 保留尺寸信息
memory 文件:用 WeakMap 补 freshness note
callInner:notebook、图片与 PDF 的读取分支
源码位置:src/tools/FileReadTool/FileReadTool.ts:804-1017
callInner() 是 Read 工具真正读取文件内容的内部实现。它根据文件扩展名分流处理不同文件类型:
.ipynb -> notebook 读取
图片 -> image 读取
.pdf -> PDF 读取或拆页
其他 -> 文本读取
这一段先处理 notebook、图片和 PDF
async function callInner(
file_path: string,
fullFilePath: string,
resolvedFilePath: string,
ext: string,
offset: number,
limit: number | undefined,
pages: string | undefined,
maxSizeBytes: number,
maxTokens: number,
readFileState: ToolUseContext['readFileState'],
context: ToolUseContext,
messageId: string | undefined,
)
入口参数里最重要的是:
file_path:模型或用户传入的原始路径。fullFilePath:规范化后的完整路径,用于状态记录。resolvedFilePath:实际读取的路径,可能是修正后的替代路径。ext:文件扩展名。offset/limit:文本读取范围。pages:PDF 页码范围。maxSizeBytes/maxTokens:读取限制。readFileState:记录已读取文件状态,用于后续去重。
notebook 分支
if (ext === 'ipynb') {
const cells = await readNotebook(resolvedFilePath)
const cellsJson = jsonStringify(cells)
...
}
notebook 不是普通文本,所以源码先用 readNotebook() 提取 cells,再转成 JSON 字符串用于大小检查和 token 检查。
如果 notebook 内容超过字节上限:
if (cellsJsonBytes > maxSizeBytes) {
throw new Error(...)
}
错误信息会建议使用 Bash 和 jq 读取局部 cells,例如:
cat "file.ipynb" | jq '.cells[:20]'
这体现了一个重要策略:
如果完整读取太大,就引导模型改用更精确的局部读取方式。
通过大小检查后,还会检查 token:
await validateContentTokens(cellsJson, ext, maxTokens)
然后记录读取状态:
readFileState.set(fullFilePath, {
content: cellsJson,
timestamp: Math.floor(stats.mtimeMs),
offset,
limit,
})
这和前面 call() 的去重逻辑配合。下次读取同一文件同一范围时,如果文件没有变化,就可以返回 file_unchanged。
最后返回内部结构化结果:
const data = {
type: 'notebook' as const,
file: { filePath: file_path, cells },
}
return { data }
图片分支
if (IMAGE_EXTENSIONS.has(ext)) {
const data = await readImageWithTokenBudget(resolvedFilePath, maxTokens)
图片不走普通文本的 maxSizeBytes 限制,因为图片有自己的 token budget 和压缩逻辑。
如果图片有尺寸信息,会生成额外的 metadata 文本:
const metadataText = data.file.dimensions
? createImageMetadataText(data.file.dimensions)
: null
返回时除了 data,还可能带 newMessages:
return {
data,
...(metadataText && {
newMessages: [
createUserMessage({ content: metadataText, isMeta: true }),
],
}),
}
这里说明工具结果不只可以返回 data,还可以附加新的 message。
图片本体会通过 data 映射成 image block;尺寸说明则作为 meta user message 注入上下文。
PDF 分支
PDF 分支从这里开始:
if (isPDFExtension(ext)) {
如果调用时指定了 pages:
if (pages) {
const parsedRange = parsePDFPageRange(pages)
const extractResult = await extractPDFPages(
resolvedFilePath,
parsedRange ?? undefined,
)
源码会把指定 PDF 页码提取成页面图片,然后读取输出目录里的 .jpg 文件:
const entries = await readdir(extractResult.data.file.outputDir)
const imageFiles = entries.filter(f => f.endsWith('.jpg')).sort()
每一页图片会被读取、压缩,并转成 image block:
const imageBlocks = await Promise.all(
imageFiles.map(async f => {
...
return {
type: 'image' as const,
source: {
type: 'base64' as const,
media_type: `image/${resized.mediaType}`,
data: resized.buffer.toString('base64'),
},
}
}),
)
返回结果分为两层:
return {
data: extractResult.data,
...(imageBlocks.length > 0 && {
newMessages: [
createUserMessage({ content: imageBlocks, isMeta: true }),
],
}),
}
可以这样理解:
data:告诉模型 PDF 页已提取
newMessages:真正的 PDF 页面图片 blocks
如果没有指定 pages,源码会先检查 PDF 页数:
const pageCount = await getPDFPageCount(resolvedFilePath)
if (pageCount !== null && pageCount > PDF_AT_MENTION_INLINE_THRESHOLD) {
throw new Error(...)
}
页数太多时,不允许一次读完整 PDF,而是要求使用 pages 参数。
之后判断当前模型是否支持 PDF,以及文件是否过大:
const shouldExtractPages =
!isPDFSupported() || stats.size > PDF_EXTRACT_SIZE_THRESHOLD
如果当前模型不支持直接读 PDF,会抛出错误并提示使用更新模型或 pages 参数。
支持直接读 PDF 时:
const readResult = await readPDF(resolvedFilePath)
成功后返回:
return {
data: pdfData,
newMessages: [
createUserMessage({
content: [
{
type: 'document',
source: {
type: 'base64',
media_type: 'application/pdf',
data: pdfData.file.base64,
},
},
],
isMeta: true,
}),
],
}
这里的结构是:
data:PDF 元信息
newMessages:真正的 document block
这一段可以这样记:
Read 对不同文件类型采用不同回填方式:
notebook -> cells 结构
image -> image block + 可选尺寸 metadata
PDF pages -> 页面 image blocks
PDF full -> document block
这说明 Read 工具并不是简单的 fs.readFile(),而是一个把本地文件转换成模型可消费 content blocks 的适配层
callInner:普通文本读取与图片 token 压缩
源码位置:src/tools/FileReadTool/FileReadTool.ts:1019-1183
这一段包含两部分:
callInner()的普通文本读取分支。readImageWithTokenBudget()图片读取与压缩函数。
普通文本读取是 coding agent 最常走的路径。源码、配置文件、Markdown 文档等文本文件基本都在这里处理。
普通文本读取
首先把外部行号转换成内部 offset:
const lineOffset = offset === 0 ? 0 : offset - 1
Read 工具对外的 offset 是从 1 开始的行号;内部读取时转换成 0-based offset。
然后调用范围读取函数:
const { content, lineCount, totalLines, totalBytes, readBytes, mtimeMs } =
await readFileInRange(
resolvedFilePath,
lineOffset,
limit,
limit === undefined ? maxSizeBytes : undefined,
context.abortController.signal,
)
这里读出的信息包括:
content:实际读到的文本内容。lineCount:这次返回的行数。totalLines:文件总行数。totalBytes:文件总字节数。readBytes:这次读取的字节数。mtimeMs:文件修改时间。
注意:
limit === undefined ? maxSizeBytes : undefined
如果模型没有指定 limit,就使用 maxSizeBytes 限制一次读取的最大字节数。
如果模型显式指定了 limit,就按行数范围读取。
读完后检查 token:
await validateContentTokens(content, ext, maxTokens)
这说明文件读取限制有两层:
字节限制:避免一次读太大的文件
token 限制:避免返回内容挤爆模型上下文
接着记录读取状态:
readFileState.set(fullFilePath, {
content,
timestamp: Math.floor(mtimeMs),
offset,
limit,
})
这和前面的去重逻辑配合。
下次读取同一个文件、同一个范围时,如果文件修改时间没有变化,就可以返回 file_unchanged,避免重复塞入全文。
然后触发 memory/attachment 相关副线:
context.nestedMemoryAttachmentTriggers?.add(fullFilePath)
接着通知文件读取监听器:
for (const listener of fileReadListeners.slice()) {
listener(resolvedFilePath, content)
}
这里使用 .slice() 复制一份快照,是为了避免 listener 在回调里取消订阅时影响当前遍历。
最后构造文本结果:
const data = {
type: 'text' as const,
file: {
filePath: file_path,
content,
numLines: lineCount,
startLine: offset,
totalLines,
},
}
如果读取的是 auto memory 文件,还会把文件修改时间存在 WeakMap 中:
if (isAutoMemFile(fullFilePath)) {
memoryFileMtimes.set(data, mtimeMs)
}
这样后面的 mapToolResultToToolResultBlockParam() 可以为 memory 文件加 freshness note。
普通文本读取主线可以这样记:
offset 转换
-> readFileInRange()
-> validateContentTokens()
-> readFileState.set()
-> 通知 fileReadListeners
-> 构造 { type: "text", file: ... }
-> return { data }
图片 token 压缩
图片读取函数是:
export async function readImageWithTokenBudget(
filePath: string,
maxTokens: number = getDefaultFileReadingLimits().maxTokens,
maxBytes?: number,
): Promise<ImageResult>
第一步是只读取一次图片文件:
const imageBuffer = await getFsImplementation().readFileBytes(
filePath,
maxBytes,
)
后续所有 resize 和 compression 都基于同一个 imageBuffer,避免重复 I/O。
如果图片为空,则直接报错:
if (originalSize === 0) {
throw new Error(`Image file is empty: ${filePath}`)
}
然后检测图片格式:
const detectedMediaType = detectImageFormatFromBuffer(imageBuffer)
const detectedFormat = detectedMediaType.split('/')[1] || 'png'
接着先尝试标准 resize:
const resized = await maybeResizeAndDownsampleImageBuffer(
imageBuffer,
originalSize,
detectedFormat,
)
成功后构造内部图片结果:
result = createImageResponse(
resized.buffer,
resized.mediaType,
originalSize,
resized.dimensions,
)
如果普通 resize 失败,会记录错误并退回原始图片 buffer:
result = createImageResponse(imageBuffer, detectedFormat, originalSize)
接下来估算图片 token:
const estimatedTokens = Math.ceil(result.file.base64.length * 0.125)
如果超过预算,就尝试更激进的压缩:
const compressed = await compressImageBufferWithTokenLimit(
imageBuffer,
maxTokens,
detectedMediaType,
)
如果激进压缩失败,则用 sharp 做兜底压缩:
const fallbackBuffer = await sharp(imageBuffer)
.resize(400, 400, {
fit: 'inside',
withoutEnlargement: true,
})
.jpeg({ quality: 20 })
.toBuffer()
如果兜底也失败,最后才返回原始图片结果。
图片读取可以这样记:
读取图片一次
-> 检测格式
-> 标准 resize/downsample
-> 估算 token
-> 超预算则激进压缩
-> 再失败则 sharp 兜底压缩
-> 返回 ImageResult
这一段体现了 Read 工具的核心工程原则:
文本和图片都不能无限进入上下文。
文本通过字节限制 + token 校验控制规模;
图片通过 resize + compression 控制 token 预算。
maxSizeBytes 与 maxTokens:为什么要限制工具结果
源码位置:src/tools/FileReadTool/limits.ts:1-92
这一段定义 Read 工具的默认读取限制。核心问题是:
/**
* Read tool output limits. Two caps apply to text reads:
*
* | limit | default | checks | cost | on overflow |
* |---------------|---------|---------------------------|---------------|-----------------|
* | maxSizeBytes | 256 KB | TOTAL FILE SIZE (not out) | 1 stat | throws pre-read |
* | maxTokens | 25000 | actual output tokens | API roundtrip | throws post-read|
*/
源码里同时使用两种限制:
export type FileReadingLimits = {
maxTokens: number
maxSizeBytes: number
includeMaxSizeInPrompt?: boolean
targetedRangeNudge?: boolean
}
其中最重要的是:
maxSizeBytes:按字节限制文件大小。maxTokens:按模型 token 限制返回内容。
它们解决的问题不同:
maxSizeBytes 管文件 I/O 规模
maxTokens 管模型上下文规模
文件开头注释总结了两者区别:
maxSizeBytes:
默认 256KB
检查总文件大小
成本低
超过时在读取前报错
maxTokens:
默认 25000
检查实际输出 token
成本较高,可能需要 API 计数
超过时在读取后报错
源码注释还提到一个工程取舍:他们试过“超过限制时截断返回”,但后来撤回了。
原因是:
截断会减少工具错误率,
但会增加平均 token 消耗。
如果直接报错,返回的是很短的错误 tool_result;
如果截断,可能仍然返回接近上限的一大段内容。
所以 Read 的策略是:
超过上限时,宁愿返回短错误,也不返回一大段截断内容。
默认 token 上限是:
export const DEFAULT_MAX_OUTPUT_TOKENS = 25000
用户可以用环境变量覆盖:
function getEnvMaxTokens(): number | undefined {
const override = process.env.CLAUDE_CODE_FILE_READ_MAX_OUTPUT_TOKENS
if (override) {
const parsed = parseInt(override, 10)
if (!isNaN(parsed) && parsed > 0) {
return parsed
}
}
return undefined
}
如果环境变量存在且是正数,就作为 maxTokens;否则返回 undefined,让后续逻辑继续使用其他默认来源。
默认限制由 getDefaultFileReadingLimits() 生成:
export const getDefaultFileReadingLimits = memoize((): FileReadingLimits => {
这里使用 memoize(),是为了让默认限制在本 session 中保持稳定。
否则 GrowthBook feature flag 后台刷新时,读取上限可能在会话中途变化。
maxTokens 的优先级是:
环境变量 > GrowthBook 配置 > DEFAULT_MAX_OUTPUT_TOKENS
源码实现:
const envMaxTokens = getEnvMaxTokens()
const maxTokens =
envMaxTokens ??
(typeof override?.maxTokens === 'number' &&
Number.isFinite(override.maxTokens) &&
override.maxTokens > 0
? override.maxTokens
: DEFAULT_MAX_OUTPUT_TOKENS)
maxSizeBytes 则来自 GrowthBook 配置或默认的 MAX_OUTPUT_SIZE:
const maxSizeBytes =
typeof override?.maxSizeBytes === 'number' &&
Number.isFinite(override.maxSizeBytes) &&
override.maxSizeBytes > 0
? override.maxSizeBytes
: MAX_OUTPUT_SIZE
这一段可以这样记:
Read 工具的输出限制有两层:
第一层:maxSizeBytes
控制文件读取规模,便宜,偏 I/O 层。
第二层:maxTokens
控制模型上下文规模,昂贵,偏 LLM 层。
它和前面的 FileReadTool.call() / callInner() 的关系是:
getDefaultFileReadingLimits()
-> call() 取 maxSizeBytes / maxTokens
-> readFileInRange() 用 maxSizeBytes 控制读取规模
-> validateContentTokens() 用 maxTokens 控制模型输入规模
renderPromptTemplate:告诉模型如何正确使用 Read
这一段定义 Read 工具展示给模型的说明文字。
它不是执行逻辑,而是工具的“模型使用说明书”。
首先定义工具名:
export const FILE_READ_TOOL_NAME = 'Read'
源码注释说明,使用字符串常量是为了避免 circular dependencies。
其他模块只需要引用工具名时,不必直接 import 整个 FileReadTool 对象。
重复读取去重时使用的提示是:
export const FILE_UNCHANGED_STUB =
'File unchanged since last read. The content from the earlier Read tool_result in this conversation is still current — refer to that instead of re-reading.'
它对应前面 call() 里的 file_unchanged 结果:
文件没变,不重复返回全文。
模型应该参考前面已有的 Read tool_result。
默认最大读取行数:
export const MAX_LINES_TO_READ = 2000
短描述:
export const DESCRIPTION = 'Read a file from the local filesystem.'
行号格式说明:
export const LINE_FORMAT_INSTRUCTION =
'- Results are returned using cat -n format, with line numbers starting at 1'
这告诉模型:Read 返回内容会带行号,格式类似 cat -n,并且行号从 1 开始。
这和 FileReadTool.ts 里的 formatFileLines() / addLineNumbers() 对应。
offset/limit 有两种提示:
export const OFFSET_INSTRUCTION_DEFAULT =
"- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters"
默认提示更倾向于先读整个文件。
export const OFFSET_INSTRUCTION_TARGETED =
'- When you already know which part of the file you need, only read that part. This can be important for larger files.'
targeted 提示更强调:如果已经知道目标位置,就只读局部范围。
这和 limits.ts 里的 targetedRangeNudge 对应,说明运行时配置可以影响给模型的工具使用策略。
完整 prompt 由 renderPromptTemplate() 生成:
export function renderPromptTemplate(
lineFormat: string,
maxSizeInstruction: string,
offsetInstruction: string,
): string {
return `Reads a file from the local filesystem. You can access any file directly by using this tool.
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
Usage:
- The file_path parameter must be an absolute path, not a relative path
- By default, it reads up to ${MAX_LINES_TO_READ} lines starting from the beginning of the file${maxSizeInstruction}
${offsetInstruction}
${lineFormat}
- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.${
isPDFSupported()
? '\n- This tool can read PDF files (.pdf). For large PDFs (more than 10 pages), you MUST provide the pages parameter to read specific page ranges (e.g., pages: "1-5"). Reading a large PDF without the pages parameter will fail. Maximum 20 pages per request.'
: ''
}
- This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations.
- This tool can only read files, not directories. To read a directory, use an ls command via the ${BASH_TOOL_NAME} tool.
- You will regularly be asked to read screenshots. If the user provides a path to a screenshot, ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths.
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.`
}
其中几个规则很关键:
file_path 必须是绝对路径,不能是相对路径。
默认从文件开头读取最多 2000 行。
返回结果带行号。
Read 可以读取图片。
当前模型支持 PDF 时,prompt 会说明 PDF 读取规则。
Read 可以读取 Jupyter notebook。
Read 只能读文件,不能读目录。
用户给截图路径时,应该用 Read 查看图片。
PDF 说明是动态插入的:
isPDFSupported()
? '\n- This tool can read PDF files (.pdf)...'
: ''
也就是说,工具 prompt 会根据当前模型能力变化。
如果当前模型不支持 PDF,模型就不会在工具说明里看到完整 PDF 读取能力。
这一段可以这样记:
Tool prompt = 给模型看的工具操作手册。
它告诉模型:
参数怎么填
默认读取多少
结果是什么格式
哪些文件类型支持
什么时候要用 offset/limit/pages
什么事情不该交给 Read 做
Read 工具由三层共同约束:
inputSchema:结构上允许传什么
prompt:指导模型应该怎么传
validateInput / call:运行时真正检查和执行
src/tools/GlobTool/GlobTool.ts:Glob 文件匹配工具
GlobTool:按文件名模式发现候选文件
源码位置:src/tools/GlobTool/GlobTool.ts:26-198
Glob 工具解决的是“按文件名模式找文件”。
在 coding agent 中,它通常用在 Read 之前:
还不知道准确文件路径
-> 先用 Glob 找候选文件
-> 再用 Read 读取具体文件
输入 schema:
const inputSchema = lazySchema(() =>
z.strictObject({
pattern: z.string().describe('The glob pattern to match files against'),
path: z
.string()
.optional()
.describe(...),
}),
)
Glob 的输入包括:
pattern:glob 匹配模式,例如**/*.ts。path:可选搜索目录;不传时使用当前工作目录。
path 的说明中特别提醒模型:如果要使用默认目录,就省略该字段,不要传 "undefined" 或 "null" 字符串。
这是面向模型调用工具时的防错设计。
输出 schema:
const outputSchema = lazySchema(() =>
z.object({
durationMs: z.number(),
numFiles: z.number(),
filenames: z.array(z.string()),
truncated: z.boolean(),
}),
)
输出包括:
durationMs:搜索耗时。numFiles:返回文件数量。filenames:匹配到的文件路径列表。truncated:结果是否被截断。
工具注册:
export const GlobTool = buildTool({
name: GLOB_TOOL_NAME,
searchHint: 'find files by name pattern or wildcard',
maxResultSizeChars: 100_000,
Glob 通过 buildTool() 注册成 Claude Code 的 Tool 对象。maxResultSizeChars 用来限制最终结果字符串大小。
Glob 声明自己是只读、可并发工具:
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
搜索/读取标记:
isSearchOrReadCommand() {
return { isSearch: true, isRead: false }
}
这和 Read 工具形成对比:
Read: isSearch=false, isRead=true
Glob: isSearch=true, isRead=false
路径归属:
getPath({ path }): string {
return path ? expandPath(path) : getCwd()
}
如果传入 path,就展开路径;否则使用当前工作目录。
输入校验主要检查 path 是否是存在的目录:
async validateInput({ path }): Promise<ValidationResult> {
if (path) {
const fs = getFsImplementation()
const absolutePath = expandPath(path)
为了避免 UNC 路径在授权前触发网络访问,源码会跳过文件系统操作:
if (absolutePath.startsWith('\\\\') || absolutePath.startsWith('//')) {
return { result: true }
}
如果目录不存在,会返回友好错误,并尝试提示当前 cwd 下的可能路径:
let message = `Directory does not exist: ${path}. ${FILE_NOT_FOUND_CWD_NOTE} ${getCwd()}.`
如果路径存在但不是目录,则返回:
return {
result: false,
message: `Path is not a directory: ${path}`,
errorCode: 2,
}
权限检查仍然走 read permission:
async checkPermissions(input, context): Promise<PermissionDecision> {
const appState = context.getAppState()
return checkReadPermissionForTool(
GlobTool,
input,
appState.toolPermissionContext,
)
}
这说明搜索文件名也是读取能力的一部分,因为它会暴露项目文件结构。
真正执行搜索在 call():
async call(input, { abortController, getAppState, globLimits }) {
const start = Date.now()
const appState = getAppState()
const limit = globLimits?.maxResults ?? 100
const { files, truncated } = await glob(
input.pattern,
GlobTool.getPath(input),
{ limit, offset: 0 },
abortController.signal,
appState.toolPermissionContext,
)
关键点:
默认最多返回 100 个文件。
支持 abortController 中断搜索。
底层 glob 搜索也接收 toolPermissionContext。
搜索结果会被转成相对路径:
const filenames = files.map(toRelativePath)
这样可以减少 token 消耗,也让模型看到更简洁的路径。
最后构造输出:
const output: Output = {
filenames,
durationMs: Date.now() - start,
numFiles: filenames.length,
truncated,
}
return {
data: output,
}
结果映射成 tool_result:
mapToolResultToToolResultBlockParam(output, toolUseID) {
if (output.filenames.length === 0) {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: 'No files found',
}
}
如果有结果,则按行返回文件名:
content: [
...output.filenames,
...(output.truncated
? [
'(Results are truncated. Consider using a more specific path or pattern.)',
]
: []),
].join('\n')
如果结果被截断,会提示模型使用更具体的 path 或 pattern。
Glob 可以这样记:
Glob = 文件发现工具
pattern/path
-> glob()
-> 最多返回有限数量文件
-> 转相对路径节省 token
-> tool_result 返回候选文件列表
它和 Read 的配合关系是:
Glob 找文件
-> Read 读文件内容
DESCRIPTION:告诉模型什么时候使用 Glob
这一段定义 Glob 工具给模型看的说明。
工具名:
export const GLOB_TOOL_NAME = 'Glob'
工具说明:
export const DESCRIPTION = `- Fast file pattern matching tool that works with any codebase size
- Supports glob patterns like "**/*.js" or "src/**/*.ts"
- Returns matching file paths sorted by modification time
- Use this tool when you need to find files by name patterns
- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead`
它告诉模型几个关键信息:
Glob 是快速文件名模式匹配工具。
Glob 可以用于任意规模的代码库。
Glob 支持 "**/*.js"、"src/**/*.ts" 这样的模式。
Glob 返回的路径按修改时间排序。
当需要按文件名模式找文件时,应该使用 Glob。
最后一条特别重要:
开放式、多轮搜索任务应该使用 Agent tool。
也就是说,源码区分两种搜索场景:
明确目标搜索:
用 Glob / Grep。
开放式探索:
用 Agent tool,让子 agent 多轮搜索和汇总。
Glob 的定位可以这样记:
Glob = 按文件名模式快速发现候选文件。
它通常和 Read 配合:
Glob 找到候选文件路径
-> Read 读取具体文件内容
src/tools/GrepTool/GrepTool.ts:Grep 内容搜索工具
输入输出结构:用 ripgrep 搜索文件内容
Grep 工具解决的是“在文件内容里找文本或正则匹配”。
它和 Glob 的区别是:
Glob 找文件名 / 路径
Grep 找文件内容
最核心输入是 pattern:
pattern: z
.string()
.describe(
'The regular expression pattern to search for in file contents',
),
pattern 是要搜索的正则表达式。
搜索范围由 path 控制:
path: z
.string()
.optional()
.describe(
'File or directory to search in (rg PATH). Defaults to current working directory.',
),
path 可以是文件,也可以是目录。
如果不传,默认搜索当前工作目录。
文件过滤由 glob 控制:
glob: z
.string()
.optional()
.describe(
'Glob pattern to filter files (e.g. "*.js", "*.{ts,tsx}") - maps to rg --glob',
),
它映射到 rg --glob,用于限制搜索哪些文件。
Grep 有三种输出模式:
output_mode: z
.enum(['content', 'files_with_matches', 'count'])
.optional()
含义是:
content:返回匹配行内容。
files_with_matches:只返回有匹配的文件路径。
count:返回匹配数量。
默认是 files_with_matches。
这符合 coding agent 的搜索习惯:先找到命中文件,再决定是否用 Read 读取具体上下文。
内容模式支持上下文行:
'-B': semanticNumber(z.number().optional())
'-A': semanticNumber(z.number().optional())
'-C': semanticNumber(z.number().optional())
context: semanticNumber(z.number().optional())
对应 ripgrep 的:
-B:匹配前几行
-A:匹配后几行
-C / context:匹配前后各几行
内容模式下还可以显示行号:
'-n': semanticBoolean(z.boolean().optional()).describe(
'Show line numbers in output (rg -n). Requires output_mode: "content", ignored otherwise. Defaults to true.',
),
行号对 coding agent 很重要,因为后续解释和编辑代码都需要准确定位。
其他常用参数包括:
-i:忽略大小写
type:按文件类型搜索,映射到 rg --type
head_limit:限制返回前 N 条,默认 250
offset:跳过前 N 条,用于分页
multiline:启用跨行正则
默认排除版本控制目录:
const VCS_DIRECTORIES_TO_EXCLUDE = [
'.git',
'.svn',
'.hg',
'.bzr',
'.jj',
'.sl',
] as const
这些目录通常噪声很大,所以默认不进入搜索结果。
默认返回数量限制:
const DEFAULT_HEAD_LIMIT = 250
注释说明,250 足够探索,同时可以避免搜索结果撑爆上下文。
applyHeadLimit() 负责应用 head_limit 和 offset:
function applyHeadLimit<T>(
items: T[],
limit: number | undefined,
offset: number = 0,
): { items: T[]; appliedLimit: number | undefined } {
如果显式传入 head_limit=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,
}
这样可以避免模型误以为还有更多结果。
输出 schema:
const outputSchema = lazySchema(() =>
z.object({
mode: z.enum(['content', 'files_with_matches', 'count']).optional(),
numFiles: z.number(),
filenames: z.array(z.string()),
content: z.string().optional(),
numLines: z.number().optional(),
numMatches: z.number().optional(),
appliedLimit: z.number().optional(),
appliedOffset: z.number().optional(),
}),
)
输出包含:
mode:输出模式
numFiles:命中文件数
filenames:命中文件路径
content:内容模式下的匹配文本
numLines:内容模式下返回行数
numMatches:count 模式下匹配数量
appliedLimit / appliedOffset:实际应用的分页限制
工具注册开头:
export const GrepTool = buildTool({
name: GREP_TOOL_NAME,
searchHint: 'search file contents with regex (ripgrep)',
maxResultSizeChars: 20_000,
strict: true,
这里说明:
Grep 使用 ripgrep 搜索文件内容。
输入严格按 schema 校验。
结果大小有 20,000 字符上限。
Grep 可以这样记:
Grep = 本地实时内容搜索工具
pattern
+ path / glob / type
+ output_mode
+ context / line number
+ head_limit / offset
-> ripgrep
-> 返回可定位的源码证据
工具属性、路径校验与读取权限
源码位置:src/tools/GrepTool/GrepTool.ts:180-253
这一段把 Grep 接入 Claude Code 的 Tool 运行规则。
首先,Grep 声明自己是只读、可并发工具:
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
Grep 只搜索文件内容,不修改文件,因此属于只读工具。
多个搜索之间也不会互相修改共享文件状态,所以可以并发执行。
自动分类输入:
toAutoClassifierInput(input) {
return input.path ? `${input.pattern} in ${input.path}` : input.pattern
},
如果传了 path,就把搜索 pattern 和路径一起交给自动分类逻辑;否则只使用 pattern。
搜索/读取标记:
isSearchOrReadCommand() {
return { isSearch: true, isRead: false }
},
这和 Glob 一样,都是搜索工具。
它和 Read 的区别是:
Read: isSearch=false, isRead=true
Glob: isSearch=true, isRead=false
Grep: isSearch=true, isRead=false
路径归属:
getPath({ path }): string {
return path || getCwd()
},
如果提供 path,就在该路径下搜索;否则默认当前工作目录。
输入校验主要检查 path 是否存在:
async validateInput({ path }): Promise<ValidationResult> {
if (path) {
const fs = getFsImplementation()
const absolutePath = expandPath(path)
为了避免 UNC 路径在授权前触发网络访问,源码会跳过文件系统操作:
if (absolutePath.startsWith('\\\\') || absolutePath.startsWith('//')) {
return { result: true }
}
然后尝试 stat 路径:
await fs.stat(absolutePath)
如果路径不存在,返回友好错误:
let message = `Path does not exist: ${path}. ${FILE_NOT_FOUND_CWD_NOTE} ${getCwd()}.`
并且会尝试用 suggestPathUnderCwd() 给出可能的正确路径。
这里和 Glob 有一个区别:
Glob 的 path 必须是目录。
Grep 的 path 只要存在即可,可以是文件,也可以是目录。
原因是 ripgrep 本身支持:
rg PATTERN file.ts
rg PATTERN src/
权限检查:
async checkPermissions(input, context): Promise<PermissionDecision> {
const appState = context.getAppState()
return checkReadPermissionForTool(
GrepTool,
input,
appState.toolPermissionContext,
)
},
Grep 虽然只是搜索,但它会暴露文件路径和匹配内容,所以仍然属于读取能力,必须走 read permission。
搜索文本提取:
extractSearchText({ mode, content, filenames }) {
if (mode === 'content' && content) return content
return filenames.join('\n')
},
如果是 content 模式,就提取匹配内容;否则提取命中文件路径列表。
这一段可以这样记:
Grep = 只读 + 可并发 + 搜索工具 + read permission
它不会修改文件,
但会暴露文件内容和路径,
所以仍然必须经过读取权限检查。
mapToolResultToToolResultBlockParam:按输出模式回填搜索结果
源码位置:src/tools/GrepTool/GrepTool.ts:254-309
这一段负责把 Grep.call() 返回的内部搜索结果转换成模型下一轮能看到的 tool_result。
入口:
mapToolResultToToolResultBlockParam(
{
mode = 'files_with_matches',
numFiles,
filenames,
content,
numLines: _numLines,
numMatches,
appliedLimit,
appliedOffset,
},
toolUseID,
)
如果 mode 没有设置,默认使用 files_with_matches。
Grep 有三种结果回填方式:
content:返回匹配行内容
count:返回匹配数量
files_with_matches:返回命中文件列表
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
content 模式直接返回匹配内容。
如果没有匹配内容,就返回:
No matches found
如果有分页或截断信息,会追加:
[Showing results with pagination = limit: ..., offset: ...]
最后包装成 tool_result:
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: finalContent,
}
count 模式
if (mode === 'count') {
const limitInfo = formatLimitInfo(appliedLimit, appliedOffset)
const rawContent = content || 'No matches found'
const matches = numMatches ?? 0
const files = numFiles ?? 0
count 模式会在原始内容后追加总结:
const summary = `\n\nFound ${matches} total ${matches === 1 ? 'occurrence' : 'occurrences'} across ${files} ${files === 1 ? 'file' : 'files'}.${limitInfo ? ` with pagination = ${limitInfo}` : ''}`
最终内容类似:
Found 12 total occurrences across 3 files.
files_with_matches 模式
这是默认模式。
如果没有命中文件:
if (numFiles === 0) {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: 'No files found',
}
}
如果有命中文件:
const result = `Found ${numFiles} ${plural(numFiles, 'file')}${limitInfo ? ` ${limitInfo}` : ''}\n${filenames.join('\n')}`
结果大致是:
Found 3 files
src/a.ts
src/b.ts
src/c.ts
注释说明:
// head_limit has already been applied in call() method, so just show all filenames
也就是说,结果截断发生在 call() 阶段;这里仅负责把已经处理好的文件列表格式化成 tool_result。
这一段可以这样记:
Grep 内部 Output
-> 根据 mode 格式化
-> content / count / files_with_matches
-> 返回 user/tool_result
-> 下一轮模型继续判断要不要 Read 具体文件
它和 Phase2 / Phase3 的关系是:
Phase2:tool_result 是 user message 的 content block
Phase3:Tool 负责把内部结果映射成 tool_result
Phase4:Grep 根据不同搜索模式生成不同 tool_result 文本
call:把结构化输入转换成 ripgrep 参数
源码位置:src/tools/GrepTool/GrepTool.ts:310-441
这一段是 Grep.call() 的前半部分。
它负责把模型传入的结构化参数转换成 ripgrep 参数,并接入忽略规则、权限上下文和工程排除规则。
入口解构输入:
async call(
{
pattern,
path,
glob,
type,
output_mode = 'files_with_matches',
'-B': context_before,
'-A': context_after,
'-C': context_c,
context,
'-n': show_line_numbers = true,
'-i': case_insensitive = false,
head_limit,
offset = 0,
multiline = false,
},
{ abortController, getAppState },
)
默认值:
output_mode 默认 files_with_matches
-n 默认 true
-i 默认 false
offset 默认 0
multiline 默认 false
搜索路径:
const absolutePath = path ? expandPath(path) : getCwd()
如果传入 path,就展开路径;否则搜索当前工作目录。
初始化 ripgrep 参数:
const args = ['--hidden']
--hidden 表示隐藏文件也纳入搜索。
但源码会主动排除版本控制目录:
for (const dir of VCS_DIRECTORIES_TO_EXCLUDE) {
args.push('--glob', `!${dir}`)
}
这些目录包括:
.git
.svn
.hg
.bzr
.jj
.sl
限制单行长度:
args.push('--max-columns', '500')
这是为了避免 base64、minified JS 等超长行污染搜索结果。
多行搜索只在显式请求时开启:
if (multiline) {
args.push('-U', '--multiline-dotall')
}
默认不开多行模式,因为它更重,也更容易产生大结果。
忽略大小写:
if (case_insensitive) {
args.push('-i')
}
输出模式转换成 ripgrep 参数:
if (output_mode === 'files_with_matches') {
args.push('-l')
} else if (output_mode === 'count') {
args.push('-c')
}
对应关系:
files_with_matches -> rg -l
count -> rg -c
content -> 不加 -l / -c,返回匹配内容
内容模式下才显示行号:
if (show_line_numbers && output_mode === 'content') {
args.push('-n')
}
上下文行参数也只在 content 模式下生效:
if (output_mode === 'content') {
if (context !== undefined) {
args.push('-C', context.toString())
} else if (context_c !== undefined) {
args.push('-C', context_c.toString())
} else {
if (context_before !== undefined) {
args.push('-B', context_before.toString())
}
if (context_after !== undefined) {
args.push('-A', context_after.toString())
}
}
}
优先级是:
context
-> -C
-> -B / -A
搜索 pattern 的特殊处理:
if (pattern.startsWith('-')) {
args.push('-e', pattern)
} else {
args.push(pattern)
}
如果 pattern 以 - 开头,ripgrep 可能把它误认为命令行参数。
所以源码用 -e pattern 明确告诉 ripgrep:这是搜索表达式。
文件类型过滤:
if (type) {
args.push('--type', type)
}
例如:
--type ts
--type py
--type rust
glob 过滤:
if (glob) {
const globPatterns: string[] = []
const rawPatterns = glob.split(/\s+/)
...
for (const globPattern of globPatterns.filter(Boolean)) {
args.push('--glob', globPattern)
}
}
这里支持多个 glob pattern。
如果 pattern 里有 {},例如 *.{ts,tsx},就不按逗号拆,避免破坏 brace pattern。
没有 brace 的普通 pattern 可以按逗号拆分。
读取忽略规则来自权限上下文:
const ignorePatterns = normalizePatternsToPath(
getFileReadIgnorePatterns(appState.toolPermissionContext),
getCwd(),
)
然后转换成 ripgrep 的排除 glob:
const rgIgnorePattern = ignorePattern.startsWith('/')
? `!${ignorePattern}`
: `!**/${ignorePattern}`
args.push('--glob', rgIgnorePattern)
这里的 ! 表示排除。
如果不是绝对路径,就加 **/,让 ripgrep 能在任意层级排除这些路径。
还会排除 orphaned plugin cache:
for (const exclusion of await getGlobExclusionsForPluginCache(
absolutePath,
)) {
args.push('--glob', exclusion)
}
最后执行 ripgrep:
const results = await ripGrep(args, absolutePath, abortController.signal)
传入三部分:
args:构造好的 rg 参数数组
absolutePath:搜索路径
abortController.signal:取消信号
可以这样记:
Grep.call()
= 把 Tool 输入参数安全翻译成 ripgrep argv
+ 加上默认排除规则
+ 加上权限 ignore 规则
+ 执行 ripGrep()
关键点是:源码不是拼接 shell 字符串,而是构造参数数组。
这更适合做结构化工具调用,也更容易避免命令注入式的问题。
call:解析 ripgrep 结果并构造 Output
源码位置:src/tools/GrepTool/GrepTool.ts:443-577
这一段是 Grep.call() 的后半部分。ripGrep() 已经返回原始结果,源码根据 output_mode 把它整理成内部 Output。
三种模式分别处理:
content:匹配内容行
count:匹配数量
files_with_matches:命中文件列表
content 模式
if (output_mode === 'content') {
content 模式下,results 是实际匹配行。
源码先应用 head_limit 和 offset:
const { items: limitedResults, appliedLimit } = applyHeadLimit(
results,
head_limit,
offset,
)
这里先截断,再做路径相对化。
原因是路径相对化是逐行处理,如果搜索结果很多,先截断可以避免处理马上会被丢弃的行。
然后把每一行开头的绝对路径转成相对路径:
const finalLines = limitedResults.map(line => {
const colonIndex = line.indexOf(':')
if (colonIndex > 0) {
const filePath = line.substring(0, colonIndex)
const rest = line.substring(colonIndex)
return toRelativePath(filePath) + rest
}
return line
})
ripgrep 原始结果可能类似:
/abs/path/src/file.ts:123:content
处理后变成:
src/file.ts:123:content
这样可以减少 token,也让模型更容易阅读。
最后构造输出:
const output = {
mode: 'content' as const,
numFiles: 0,
filenames: [],
content: finalLines.join('\n'),
numLines: finalLines.length,
...(appliedLimit !== undefined && { appliedLimit }),
...(offset > 0 && { appliedOffset: offset }),
}
count 模式
if (output_mode === 'count') {
count 模式下,ripgrep 返回类似:
/abs/path/file.ts:3
表示某个文件匹配了 3 次。
同样先应用分页限制:
const { items: limitedResults, appliedLimit } = applyHeadLimit(
results,
head_limit,
offset,
)
然后把路径转成相对路径:
const finalCountLines = limitedResults.map(line => {
const colonIndex = line.lastIndexOf(':')
if (colonIndex > 0) {
const filePath = line.substring(0, colonIndex)
const count = line.substring(colonIndex)
return toRelativePath(filePath) + count
}
return line
})
这里用 lastIndexOf(':'),因为最后一个冒号后面才是 count 数字。
接着统计总匹配数和命中文件数:
let totalMatches = 0
let fileCount = 0
for (const line of finalCountLines) {
const colonIndex = line.lastIndexOf(':')
if (colonIndex > 0) {
const countStr = line.substring(colonIndex + 1)
const count = parseInt(countStr, 10)
if (!isNaN(count)) {
totalMatches += count
fileCount += 1
}
}
}
最后构造输出:
const output = {
mode: 'count' as const,
numFiles: fileCount,
filenames: [],
content: finalCountLines.join('\n'),
numMatches: totalMatches,
...(appliedLimit !== undefined && { appliedLimit }),
...(offset > 0 && { appliedOffset: offset }),
}
files_with_matches 模式
这是默认模式。results 是命中文件路径。
源码先读取每个文件的修改时间:
const stats = await Promise.allSettled(
results.map(_ => getFsImplementation().stat(_)),
)
这里使用 Promise.allSettled(),是因为文件可能在 ripgrep 扫描后、stat 前被删除。
单个文件 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
})
正常环境下:
最近修改的文件排前面;
修改时间相同,则按文件名排序。
测试环境下:
只按文件名排序,保证测试稳定。
然后应用 head_limit / offset:
const { items: finalMatches, appliedLimit } = applyHeadLimit(
sortedMatches,
head_limit,
offset,
)
再把绝对路径转成相对路径:
const relativeMatches = finalMatches.map(toRelativePath)
最后构造输出:
const output = {
mode: 'files_with_matches' as const,
filenames: relativeMatches,
numFiles: relativeMatches.length,
...(appliedLimit !== undefined && { appliedLimit }),
...(offset > 0 && { appliedOffset: offset }),
}
这一段可以这样记:
ripgrep 原始结果
-> 按 output_mode 分支处理
-> 应用 head_limit / offset
-> 绝对路径转相对路径
-> count 模式汇总匹配数
-> files_with_matches 模式按修改时间排序
-> 返回 Grep Output
Grep 工具的整体链路是:
模型给出 pattern/path/glob/type/output_mode
-> Grep.call() 构造 ripgrep args
-> ripGrep() 执行本地搜索
-> 根据模式整理 Output
-> mapToolResultToToolResultBlockParam()
-> user/tool_result 回填给模型
getDescription:告诉模型优先使用 Grep 而不是 Bash 搜索
源码位置:src/tools/GrepTool/prompt.ts:1-18
这一段定义 Grep 工具给模型看的说明。
它的重点不是执行逻辑,而是工具使用策略。
首先引入工具名:
import { AGENT_TOOL_NAME } from '../AgentTool/constants.js'
import { BASH_TOOL_NAME } from '../BashTool/toolName.js'
这说明 Grep 的说明会明确比较:
Grep
Bash
Agent
工具名:
export const GREP_TOOL_NAME = 'Grep'
核心说明由 getDescription() 返回:
export function getDescription(): string {
return `A powerful search tool built on ripgrep
最重要的一条规则是:
- ALWAYS use ${GREP_TOOL_NAME} for search tasks. NEVER invoke `grep` or `rg` as a ${BASH_TOOL_NAME} command. The ${GREP_TOOL_NAME} tool has been optimized for correct permissions and access.
也就是说:
搜索任务应该使用 Grep 工具。
不要通过 Bash 去执行 grep 或 rg。
原因是 Grep 工具已经接入了 Claude Code 的工具系统能力:
权限检查
ignore patterns
路径规范化
结果截断
相对路径压缩
abort signal
tool_result 格式化
如果模型绕过 Grep,直接用 Bash 调 rg,这些工具层保护就可能失效。
Grep 支持完整正则:
- Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+")
可以用 glob 或 type 过滤文件:
- Filter files with glob parameter (e.g., "*.js", "**/*.tsx") or type parameter (e.g., "js", "py", "rust")
两者区别可以这样记:
glob:自定义文件名模式,比如 "**/*.tsx"
type:标准语言类型,比如 "js"、"py"、"rust"
输出模式:
- Output modes: "content" shows matching lines, "files_with_matches" shows only file paths (default), "count" shows match counts
对应 GrepTool.ts 里的三种模式:
content:返回匹配内容
files_with_matches:返回命中文件路径
count:返回匹配次数
开放式搜索使用 Agent:
- Use ${AGENT_TOOL_NAME} tool for open-ended searches requiring multiple rounds
也就是说:
明确搜索目标:用 Grep。
开放式、多轮探索:用 Agent。
pattern 语法基于 ripgrep:
- Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping ...
这提醒模型:底层不是传统 grep,而是 ripgrep。
多行匹配默认不开启:
- Multiline matching: By default patterns match within single lines only. For cross-line patterns like `struct \{[\s\S]*?field`, use `multiline: true`
如果要跨行搜索,必须显式传:
multiline: true
这一段可以这样记:
Grep prompt = 搜索工具使用策略
它告诉模型:
搜索用 Grep,不要用 Bash 调 rg
pattern 是 ripgrep 正则
用 glob/type 缩小范围
根据需求选择 content/files/count
开放式多轮搜索交给 Agent
跨行搜索要显式 multiline=true
Phase 4 总结:文件读取与代码搜索工具
Read / Glob / Grep:Agent 观察代码库的三种能力
Phase4 解决的核心问题是:
AI coding agent 如何观察本地代码库?
模型本身不能直接读取文件系统。
它只能发出结构化工具请求:
Read(file_path)
Glob(pattern, path)
Grep(pattern, path, output_mode)
本地 runtime 执行工具,再把结果作为 tool_result 回填给模型。
三个工具的分工可以这样记:
Glob:按文件名 / 路径模式找文件。
Grep:按文件内容 / 正则搜索。
Read:读取具体文件内容。
典型使用链路是:
Glob 找候选文件
-> Grep 找具体符号或文本
-> Read 读取命中文件上下文
-> 模型基于真实源码继续推理
Glob 适合:
知道文件名模式,但不知道具体路径。
例如:
**/*.ts
src/**/*.tsx
**/package.json
Grep 适合:
知道要找的函数名、字符串、错误信息或正则模式。
例如:
createUserMessage
tool_result
checkReadPermissionForTool
Read 适合:
已经知道具体文件路径,需要读取源码上下文。
三个工具都是只读工具:
isReadOnly() {
return true
}
也都可以并发:
isConcurrencySafe() {
return true
}
但只读不等于无权限。Read、Glob、Grep 都会接入 read permission,因为它们会暴露用户文件内容或项目结构。
三个工具也都在控制返回规模:
Read:
maxSizeBytes + maxTokens,避免文件内容撑爆上下文。
Glob:
默认最多返回 100 个文件,结果过多时提示缩小 pattern/path。
Grep:
默认 head_limit=250,支持 offset 分页,并把绝对路径转相对路径节省 token。
这说明 Claude Code 的工具设计有一个共同原则:
工具结果必须足够有用,但不能无限进入上下文。
Phase4 和前三阶段的关系是:
Phase1:query loop 知道什么时候执行工具。
Phase2:message 结构知道 tool_result 如何回填。
Phase3:Tool 抽象定义工具如何校验、执行、映射结果。
Phase4:Read / Glob / Grep 是 coding agent 观察代码库的具体工具。
完整链路可以这样记:
assistant/tool_use
name: Grep
input: { pattern, path, output_mode }
-> runtime 找到 GrepTool
-> inputSchema 校验
-> validateInput
-> checkPermissions
-> call() 执行 ripgrep
-> mapToolResultToToolResultBlockParam()
-> user/tool_result
-> 下一轮模型根据搜索结果继续决定是否 Read
Phase4 的核心抽象是:
Codebase observation tools =
文件发现 Glob
内容搜索 Grep
精确读取 Read
它们让模型不靠猜,而是基于当前工作区的真实文件、真实内容和真实行号进行推理。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)