文件工具
Read 的字符预算与续读、编码与行尾处理、Edit 的唯一性校验、Grep / Glob 的 ripgrep 集成与敏感文件过滤。
文件工具是 coding agent 用得最多的一组,也是最容易出微妙 bug 的地方。现役实现在 agent-core-v2/src/agent/tools/ 下:os/read、os/write、os/glob、os/grep、edit、read-media-file。
Read:从三重上限到字符预算
v0.29 的 Read 有三重硬上限:1000 行、单行 2000 字符、100 KB。v0.43 换成了单一的字符预算:
export const DEFAULT_MAX_CHARS = 100_000;
export const DEFAULT_MAX_CHARS_LIMIT = 500_000;
export const TRANSCODE_MAX_BYTES: number = 10 * 1024 * 1024;- 默认每次最多返回 10 万字符(含行号和状态块),调用方可以用
max_chars要到 50 万;两个数都能在read配置段里调。没有固定的行数上限。 - 结果末尾附一个
<system>...</system>状态块:实际返回的行范围、总行数、有效预算、区间是否读完、是否到了 EOF。没读完就给出Next Read: {...},模型照抄参数即可无缝续读。 - 一行长到单页放不下时返回片段,用
column_offset在同一行接着读——压缩过的 JS、超长 JSON 不再被硬截断。 - 负数
line_offset从尾部读;需要二次扫描时,读完会比对size、mtimeMs、ino,文件中途变了就报File changed while reading its tail。 - Read 自己分页,所以返回
spillExempt: true,不会被通用的 5 万字符预算再切一刀。
读之前的检查
- 路径:敏感文件(
.env、SSH 私钥、云凭据等,.env.example和公钥例外)在resolveExecution()阶段就被PathSecurityError挡掉。 - 普通文件:v0.29 用 POSIX 模式位
S_IFMT/S_IFREG手工判断,现在直接看文件系统抽象的stat()返回的isFile。 - 类型嗅探:
detectFileType()读前MEDIA_SNIFF_BYTES字节,图片、视频直接报错,说明只能读文本(媒体交给ReadMediaFile)。 - 编码:UTF-8 直接读;UTF-16 LE/BE(有无 BOM 均可)先严格解码,失败再有损解码并在每页附警告,超过 10 MB 的 UTF-16 文件拒绝转码;GBK 等其他编码被拒绝并提示先用
iconv转成 UTF-8;二进制和含 NUL 的文件同样拒绝。
行尾:一个必须显式处理的坑
line-endings.ts 从 v0.29 的 tools/builtin/file/ 挪到了 _base/text/,类型原样保留:
export type LineEndingStyle = 'lf' | 'crlf' | 'mixed';
export interface ModelTextView {
text: string;
lineEndingStyle: LineEndingStyle;
}问题还是那个:文件是 CRLF,模型生成 old_string 时几乎一定用 LF,直接去找永远匹配不上。现在分两种情况处理:
- 纯 CRLF:Read 按 LF 显示;Edit 先用
toModelTextView()把原文也转成 LF 视图再匹配,写回时由materializeModelText()统一还原成 CRLF。 - 混用或孤立的 CR:Read 用
makeCarriageReturnsVisible()把\r显示出来,并在状态块里提醒:这些位置的old_string要写真实的\r转义。
mixed 这个状态必须存在——真实仓库里混用行尾的文件并不罕见。
Edit:唯一性即安全
Edit 拆成了两层:agent/tools/edit/editTool.ts 负责路径解析、审批规则和展示;读改写在 App 作用域的 IFileEditService(app/edit/)里完成,核心是 EditService.apply():
apply(model: TextModel, input: EditApplyInput): EditApplyResult {
if (input.replace_all) {
const { text, count } = model.replaceAll(input.old_string, input.new_string);
if (count === 0) return { ok: false, error: notFoundMessage(input.path) };
return { ok: true, rawContent: model.materialize(text), count };
}
const count = model.countOccurrences(input.old_string);
if (count === 0) return { ok: false, error: notFoundMessage(input.path) };
if (count > 1) return { ok: false, error: notUniqueMessage(input.path, count) };
const text = model.replaceOnce(input.old_string, input.new_string);
return { ok: true, rawContent: model.materialize(text), count: 1 };
}两条错误信息沿用 v0.29 的措辞,仍然带修复指引:找不到 → 内容可能过期,请用 Read 重新加载;不唯一 → 给出两条出路(replace_all=true,或在 old_string 里带上更多上下文)。错误信息是给模型的下一步提示,写得越具体,自我纠错越快。
「不唯一就报错」是这类工具的安全底线:模型想改第 3 处,默认替换第 1 处会静默改错地方,而且往往很久以后才被发现。
其他变化:
old_string与new_string相同时,现在返回isError: true。old_string仍由 schema 的.min(1)强制非空。v0.29 在注释里解释过原因;v2 引擎是「无注释区」,注释没了,风险还在——TextModel.countOccurrences()遇到空串同样会原地打转,这条约束不能删。- 读文件用
readText(path, { errors: 'strict' }),不是合法 UTF-8 就报错,不会有损改写。 - 声明
ToolAccesses.readWriteFile(path):同一文件的多个 Edit 会按顺序串行,但edit.md提醒模型,串行并不能让过期的old_string重新生效。
Write
write.ts 支持 overwrite(默认)与 append 两种模式,缺失的父目录自动创建。它原样写入,不经过 Read / Edit 的行尾视图:\n 就是 LF,\r\n 就是 CRLF。描述里明令禁止用 Write 做增量修改;内容太大时先 overwrite 第一块、再 append 后续块。声明 ToolAccesses.writeFile(path),与同路径的任何读写串行。
Grep 与 Glob:包一层 ripgrep
两者都经 os/backends/node-local/tools/runRg.ts 调用 rg,rgLocator.ts 依次在系统 PATH、随包的 vendor 目录、缓存目录里找,都没有就下载。runRgOnce() 的三条硬约束:
export const DEFAULT_TIMEOUT_MS = 20_000;
export const SIGTERM_GRACE_MS = 5_000;
export const MAX_OUTPUT_BYTES = 10 * 1024 * 1024;- 中止要真正杀掉子进程:超时(20 秒)或外部 abort 都先发 SIGTERM,5 秒后仍未退出就 SIGKILL——否则用户中断后,一个搜遍磁盘的
rg还在后台跑。 - 输出有上限:rg 输出超过 10 MB 就截断,结果里明说结果集不完整。
- 敏感文件双重过滤:rg 参数里先用
--glob排除.env、SSH 私钥、云凭据等模式,结果回来后再用isSensitiveFile()过滤一遍(Grep 还会报告滤掉了几个)。.git等 VCS 目录始终跳过。
Glob 用 rg --files --hidden --sortr=modified,只列文件、按修改时间倒序,默认 100 条,可用 offset 翻页;Grep 默认 files_with_matches 模式,head_limit 默认 250。两者都声明 ToolAccesses.searchTree(path)——搜索与读互不冲突,多个 Glob、Grep、Read 可以完全并行。
选择包 ripgrep 而非自己实现,理由很实际——本站 Grok Build 专栏里 xAI 的选择也一样:并行遍历、gitignore 语义、编码嗅探,自己写不划算。
ReadMediaFile
read-media-file/readMediaFileTool.ts(538 行)读图片和视频,单文件上限 100 MB。图片默认交给 agent/media/image-compress.ts(795 行)的 compressImageForModel() 压到模型限额内,并在 <system> 块里报告原始尺寸;要看细节可以用 region 裁剪原图,或用 full_resolution 要原始分辨率。压不下去就报错,不会偷偷发送原图——这是媒体降级在工具侧的入口。
路径策略
所有文件工具都在 resolveExecution() 里走同一个入口,比如 Edit:
const path = resolvePathAccessPath(args.path, {
env,
workspace,
operation: 'write',
});工作区包括会话的附加目录和 Skill 根目录;路径在解析阶段就规范化,敏感文件、工作区外的相对路径直接抛 PathSecurityError,执行器把它原样变成工具错误。统一入口保证新增文件工具时不会忘记接策略。ToolAccesses 管并发调度,path-access 管能不能碰,二者仍是两回事。
下一篇:Shell 工具。