# 文件工具

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

- 作者：David（道雾轩）
- 专栏：Kimi Code 源码解读（https://daiw.org/manual/kimi-code.md）
- 最后更新：2026-09-19
- 原文：https://daiw.org/manual/kimi-code/file-tools
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

文件工具是 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 换成了单一的**字符预算**：

```ts
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 万字符预算](https://daiw.org/manual/kimi-code/tool-contract)再切一刀。

## 读之前的检查

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/`，类型原样保留：

```ts
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()`：

```ts
  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()` 的三条硬约束：

```ts
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 专栏](https://daiw.org/manual/grok-build)里 xAI 的选择也一样：并行遍历、gitignore 语义、编码嗅探，自己写不划算。

## ReadMediaFile

`read-media-file/readMediaFileTool.ts`（538 行）读图片和视频，单文件上限 100 MB。图片默认交给 `agent/media/image-compress.ts`（795 行）的 `compressImageForModel()` 压到模型限额内，并在 `<system>` 块里报告原始尺寸；要看细节可以用 `region` 裁剪原图，或用 `full_resolution` 要原始分辨率。压不下去就报错，不会偷偷发送原图——这是[媒体降级](https://daiw.org/manual/kimi-code/turn-convergence)在工具侧的入口。

## 路径策略

所有文件工具都在 `resolveExecution()` 里走同一个入口，比如 Edit：

```ts
    const path = resolvePathAccessPath(args.path, {
      env,
      workspace,
      operation: 'write',
    });
```

工作区包括会话的附加目录和 Skill 根目录；路径在解析阶段就规范化，敏感文件、工作区外的相对路径直接抛 `PathSecurityError`，执行器把它原样变成工具错误。统一入口保证新增文件工具时不会忘记接策略。`ToolAccesses` 管**并发调度**，`path-access` 管**能不能碰**，二者仍是两回事。

下一篇：[Shell 工具](https://daiw.org/manual/kimi-code/shell-tools)。
