文件工具

Read 的字符预算与续读、编码与行尾处理、Edit 的唯一性校验、Grep / Glob 的 ripgrep 集成与敏感文件过滤。

作者 David更新于 第 13 篇(共 33 篇)

文件工具是 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 万字符预算再切一刀。

读之前的检查

  1. 路径:敏感文件(.env、SSH 私钥、云凭据等,.env.example 和公钥例外)在 resolveExecution() 阶段就被 PathSecurityError 挡掉。
  2. 普通文件:v0.29 用 POSIX 模式位 S_IFMT / S_IFREG 手工判断,现在直接看文件系统抽象的 stat() 返回的 isFile。
  3. 类型嗅探:detectFileType() 读前 MEDIA_SNIFF_BYTES 字节,图片、视频直接报错,说明只能读文本(媒体交给 ReadMediaFile)。
  4. 编码: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 工具。

本页目录