# 文件读写与代码修改

> 源码基线：`eec0843ce422`。本章中的参数和文件名是教学用的**典型调用**，用于串起已核对的源码分支，不是一次真实运行录屏。

## 0. 本章学习目标

学完本章，你应该能：

- 画出 `read / edit / write` 与权限、文件系统、格式化、事件、LSP 的关系图。
- 解释为什么“模型生成了正确文本”仍不等于“修改可靠”。
- 从一次 `edit` 典型调用追到替换、diff、审批、写入、格式化和诊断。
- 区分 `edit` 与 `write` 的使用边界，并说出二者共享的安全闸门。
- 识别文件不存在、匹配不唯一、越过工作区、审批拒绝和诊断失败等分支。
- 把“先计算、再授权、后反馈”的做法迁移到自己的 coding agent。

## 1. 一句话讲明白

文件工具不是三个 `fs` 包装器，而是一条受控变更管线：**先确认目标和预期改动，再经过权限闸门落盘，最后把格式化与诊断结果反馈给下一轮 Agent。**

本章只追一个问题：

> 模型说“把一个旧代码片段换成新片段”之后，OpenCode 怎样避免改错文件、改错位置，并让模型知道改完是否仍有错误？

## 2. 先看全图：文件工具在 Agent 中的位置

```text
模型产生 tool call
        |
        v
SessionTools 提供 Tool.Context
        |  ask / metadata / abort / session 信息
        v
+---------------- 文件工具 ----------------+
| read        edit              write       |
| 读或列目录   定位旧片段并替换    全量写入    |
+------------------+------------------------+
                   |
        +----------+-----------+
        |          |           |
        v          v           v
   Permission   AppFileSystem  Format
   路径/操作审批   真正读写       可选格式化
                   |
                   v
             文件事件 + LSP
                   |
                   v
          tool result 回到 Agent loop
```

边界要先分清：

- **通用内核**：解析目标、校验前置条件、授权、执行、报告结果。
- **OpenCode 产品层**：`Tool.Context`、Effect service、`File.Event.Edited`、formatter registry、LSP diagnostics。
- **不在本章解决**：模型为什么选择 `edit`，以及 tool result 如何驱动下一轮模型；它们分别属于 Tool 系统和 Agent loop。

`SessionTools.resolve` 负责把工具包装给模型，并在 `Tool.Context` 中接好 `ask` 与 `metadata`。文件工具只消费这个上下文，不直接关心 Provider。见 `packages/opencode/src/session/tools.ts:42-73`。

## 3. 最小机制：一次可靠修改至少有六步

先忽略 Effect、BOM、事件等细节，最小骨架只有这些：

```ts
async function safeEdit(input, ctx) {
  const file = resolvePath(input.filePath)
  await checkExternalBoundary(file, ctx)

  const before = await readExistingFile(file)
  const after = replaceUniquely(before, input.oldString, input.newString)
  const diff = makeDiff(before, after)

  await ctx.ask({ permission: "edit", pattern: relative(file), diff })
  await write(file, after)
  await formatIfAvailable(file)
  return await reportDiagnostics(file)
}
```

顺序很重要。若先写后审批，审批就失去意义；若不先计算 `after` 和 diff，用户也不知道自己批准了什么；若写完不反馈，Agent 只能把“系统调用成功”误当成“代码正确”。

## 4. 三种工具不是重复接口

| 工具 | 输入意图 | 主要前置条件 | 输出重点 | 典型用途 |
| --- | --- | --- | --- | --- |
| `read` | 看文件或目录 | 路径、外部目录、`read` 权限 | 带行号内容、分页提示或附件 | 建立修改前事实 |
| `edit` | 用 `oldString` 替换为 `newString` | 文件存在、目标可定位、`edit` 权限 | diff、增删行统计、当前文件诊断 | 小而明确的局部修改 |
| `write` | 用完整内容创建或覆盖文件 | `edit` 权限 | diff、当前及有限数量的项目诊断 | 新文件或整体重写 |

`write` 的参数描述要求绝对路径，但实现仍兼容相对路径并相对 instance directory 解析。这里应以执行代码为准：`packages/opencode/src/tool/write.ts:20-25`、`packages/opencode/src/tool/write.ts:38-44`。

Java 类比可以帮助定位职责：`AppFileSystem` 像基础设施 adapter，`ctx.ask` 像可异步等待的授权拦截器，`Format.Service` 像保存后 hook，`LSP.Service` 像 IDE 诊断服务。类比到此为止：OpenCode 用 Effect 管理依赖与失败，不是 Spring AOP 自动包住所有文件调用。

## 5. 一条具体源码旅程：局部替换一个配置值

假设模型发出下面的典型调用：

```json
{
  "filePath": "src/config.ts",
  "oldString": "const timeout = 30",
  "newString": "const timeout = 60"
}
```

这不是本章实际执行过的命令。我们只用它回答：源码会按什么顺序处理？

### 5.1 参数先挡住无意义调用

`EditTool` 暴露 `filePath / oldString / newString / replaceAll`。执行入口会拒绝空路径，也会拒绝新旧文本完全相同。见 `packages/opencode/src/tool/edit.ts:47-56`、`packages/opencode/src/tool/edit.ts:69-77`。

随后，相对路径基于 `instance.directory` 解析，并先检查是否越过 instance/worktree 边界：

```ts
const filePath = path.isAbsolute(params.filePath)
  ? params.filePath
  : path.join(instance.directory, params.filePath)
yield* assertExternalDirectoryEffect(ctx, filePath)
```

路径：`packages/opencode/src/tool/edit.ts:79-84`

外部目录检查并不直接“禁止一切外部路径”。它把目标父目录转成 glob，再申请 `external_directory` 权限。工作区内路径直接通过。见 `packages/opencode/src/tool/external-directory.ts:16-45`。

### 5.2 同一文件的 edit 被串行化

`EditTool` 用规范化后的文件路径作为 key，为每个文件维护一个单许可 `Semaphore`：

```ts
const locks = new Map<string, Semaphore.Semaphore>()

function lock(filePath: string) {
  const resolvedFilePath = AppFileSystem.resolve(filePath)
  // 同一路径复用一个 semaphore
}
```

路径：`packages/opencode/src/tool/edit.ts:35-45`

真正的读取、替换、审批和写入都包在 `withPermits(1)` 内，见 `packages/opencode/src/tool/edit.ts:85-89`。这减少同一进程内两个 edit 对同一文件交错执行的风险。

证据边界：源码证明了 **EditTool 内部**的进程内串行化；它不等于跨进程文件锁，也不证明 `WriteTool` 与 `EditTool` 之间互斥。

### 5.3 替换前保留原文件的文本约定

文件不存在或目标是目录时会立即失败。读取时保留 BOM，并检测原文件使用 LF 还是 CRLF，再把工具输入转换成相同换行风格：

```ts
const ending = detectLineEnding(contentOld)
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
const replacement = convertToLineEnding(normalizeLineEndings(params.newString), ending)
```

路径：`packages/opencode/src/tool/edit.ts:119-130`

这不是装饰性细节。若忽略换行和 BOM，模型看似只改一行，落盘后却可能让整个文件产生无意义 diff。

### 5.4 “找到旧文本”不是简单 `String.replace`

`replace` 依次尝试精确、行裁剪、块锚点、空白归一化、缩进弹性等 replacer。只改一次时，它最终要求候选唯一；找不到会报错，多处匹配又没有 `replaceAll` 也会报错。见 `packages/opencode/src/tool/edit.ts:674-710`。

这里体现一个重要取舍：

- 只做精确匹配最可预测，但模型容易因空白差异失败。
- 允许模糊匹配更有容错性，但必须保留“唯一候选”闸门，否则可能静默改错位置。

因此，OpenCode 选择“多级容错定位 + 最终歧义失败”，而不是“永远精确”或“随便找一个最像的”。具体相似度算法在 `packages/opencode/src/tool/edit.ts:213-636`，第一次阅读不必逐行展开。

### 5.5 diff 是审批对象，不只是结果展示

新内容算出后，工具先生成 diff，再把它放进权限请求的 metadata：

```ts
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
yield* ctx.ask({
  permission: "edit",
  patterns: [path.relative(instance.worktree, filePath)],
  always: ["*"],
  metadata: { filepath: filePath, diff },
})
```

路径：`packages/opencode/src/tool/edit.ts:133-149`

<!-- source-ref path="packages/opencode/src/tool/edit.ts" lines="119-168" title="EditTool 的计算、审批与写入" note="注意 diff 在写文件之前生成，审批完成后才落盘。" -->

`ctx.ask` 返回，才会 `writeWithDirs`。如果规则 deny 或用户 reject，控制流在这里失败，后面的落盘、格式化和 LSP 都不会执行。

### 5.6 落盘后，格式化可能改变最终 diff

写入后调用 `format.file(filePath)`；如果有匹配 formatter，工具会重新同步文件内容，再次计算最终 diff。见 `packages/opencode/src/tool/edit.ts:151-167`。

formatter 根据扩展名选择，进程执行失败会记日志而不是直接让整次编辑失败。见 `packages/opencode/src/format/index.ts:56-120`。因此审批时看到的是**格式化前的计划 diff**，tool metadata 中返回的 `diff` 可能包含 formatter 造成的最终变化。

这是一个真实取舍：保存后自动格式化降低风格噪声，但也意味着最终落盘内容不一定逐字等于模型提交的 `newString`。

### 5.7 事件告诉系统“文件真的变了”

修改完成后发布两个事件：

- `File.Event.Edited`：表达 OpenCode 已编辑文件。
- `FileWatcher.Event.Updated`：表达文件是 `change`，创建分支则可能是 `add`。

路径：`packages/opencode/src/tool/edit.ts:111-115`、`packages/opencode/src/tool/edit.ts:155-159`。

这比让所有消费者轮询文件更清晰。事件是 OpenCode 的产品层，不是可靠文件编辑的通用必需条件。

### 5.8 LSP 把“写成功”与“代码正确”分开

最后，`EditTool` 通知 LSP 并读取诊断：

```ts
yield* lsp.touchFile(filePath, "document")
const diagnostics = yield* lsp.diagnostics()
const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])
```

路径：`packages/opencode/src/tool/edit.ts:192-207`

有 error 时，诊断被追加到 tool output，下一轮模型能继续修。没有 error 只能说明“当前已连接的语言服务没有报告这里的 error”，不能推导为测试通过或业务正确。LSP 的完整机制留到第 08 章。

## 6. 回头看 ReadTool：可靠编辑从可靠读取开始

`ReadTool` 的主路径是：解析路径 → 确认 reference → stat → 外部目录审批 → `read` 权限 → 按类型读取。见 `packages/opencode/src/tool/read.ts:200-234`。

它不只处理普通文本：

- 目录会排序、分页并返回 entry 列表：`packages/opencode/src/tool/read.ts:236-261`。
- 图片和 PDF 会作为附件返回：`packages/opencode/src/tool/read.ts:264-289`。
- 已识别的二进制文件会失败：`packages/opencode/src/tool/read.ts:153-198`、`packages/opencode/src/tool/read.ts:291-293`。
- 文本默认最多 2000 行，同时受 50 KB 和单行 2000 字符限制：`packages/opencode/src/tool/read.ts:14-18`、`packages/opencode/src/tool/read.ts:108-150`。
- 输出被截断时明确给出下一次 `offset`：`packages/opencode/src/tool/read.ts:295-315`。

所以 read 的截断是上下文保护，不是“已经读完整个文件”。模型若忽略 `truncated` 或 continuation 提示，后续 edit 可能建立在不完整事实之上。

另一个失败分支很值得学：文件不存在时，`miss` 会在父目录里找最多三个相近名称作为提示；父目录本身读取失败则退化为普通 not found。见 `packages/opencode/src/tool/read.ts:48-71`。

## 7. WriteTool：同一安全骨架，不同变更语义

`WriteTool` 不定位旧片段，而是把完整 `content` 与现有内容做 diff。它仍然遵循：

```text
解析路径
 -> 外部目录检查
 -> 读取旧内容并保留 BOM
 -> 生成 diff
 -> ctx.ask(permission = edit)
 -> writeWithDirs
 -> formatter
 -> 文件事件
 -> LSP diagnostics
```

源码主干：`packages/opencode/src/tool/write.ts:38-100`。

为什么 `write` 申请的是 `edit` 权限？因为权限描述的是副作用类别，不必与工具名一一对应。创建和覆盖文件都会改变工作区，所以统一归入 `edit`。

`WriteTool` 与 `EditTool` 的诊断范围略有不同：前者除了当前文件，还最多报告五个有 error 的其他文件；后者只把当前文件 error 拼进文本输出。见 `packages/opencode/src/tool/write.ts:18-18`、`packages/opencode/src/tool/write.ts:74-90`、`packages/opencode/src/tool/edit.ts:192-197`。

## 8. 失败路径：在哪一步停，留下什么

| 失败 | 发生位置 | 是否已写文件 | 给 Agent 的信号 |
| --- | --- | --- | --- |
| `filePath` 缺失或新旧文本相同 | edit 参数校验 | 否 | 明确 error |
| 工作区外路径未获授权 | external-directory ask | 否 | permission error |
| 文件不存在或路径是目录 | edit stat | 否 | 明确 error |
| `oldString` 找不到 | replacer | 否 | 要求匹配真实文本 |
| `oldString` 多处匹配 | replacer | 否 | 要求提供更多上下文或显式 `replaceAll` |
| `edit` 权限 deny/reject | `ctx.ask` | 否 | permission error |
| formatter 启动/执行失败 | `Format.file` | **文件已写** | 主要记录日志，编辑继续 |
| LSP 无 client、启动失败或等待异常 | `touchFile` | **文件已写** | best-effort；可能没有诊断文本 |

最后两行尤其重要：后置检查失败不能自动回滚已经完成的文件写入。源码也没有在这里展示事务式 rollback，因此不要把整条管线称为数据库意义上的事务。

## 9. OpenCode 的选择：可靠性与可用性的取舍

### 选择一：文本替换，而不是让模型直接提交任意 patch

`oldString/newString` 让“预期旧状态”成为前置条件，天然带一点乐观并发控制的味道；代价是复杂替换需要更长上下文，容错匹配本身也更复杂。

### 选择二：审批在落盘前，格式化在落盘后

这样用户能看到模型计划的 diff，同时 formatter 又能统一最终风格；代价是审批 diff 与最终 diff 可能不同。工具通过重新计算 final diff 缩小这个信息差，但并没有第二次审批。

### 选择三：LSP 是 best-effort 后置反馈

语言服务坏掉时仍保留已完成编辑，避免“没有 IDE 能力就完全不能改文件”；代价是成功结果不能保证包含最新诊断。

## 10. 可以带走的方法

### 方法一：把副作用拆成“计划—授权—提交—验证”

适用于文件修改、数据库迁移、部署和外部 API 写入。验证问题：**用户批准时，是否能看到足够具体的将要发生的变化？**

### 方法二：把前置失败和后置失败分开报告

前置失败意味着副作用未发生；后置检查失败意味着副作用已经发生，只是验证不完整。验证问题：**错误信息能否让调用者判断是否需要回滚？**

### 方法三：容错定位必须配歧义闸门

可以允许空白、缩进差异，但多候选时宁可失败也不静默猜测。验证问题：**模糊匹配会不会把“不确定”伪装成“成功”？**

## 11. 费曼复述与练习

先合上源码，用 90 秒复述：

1. `edit` 为什么在写文件前同时需要旧内容、目标新内容和 diff？
2. `external_directory` 与 `edit` 是哪两个不同维度的权限？
3. formatter 或 LSP 失败时，文件可能处于什么状态？
4. 为什么“LSP 没报错”不等于“修改正确”？

如果答不清，按下面的梯子重走一遍：

1. **定位题**：在 `edit.ts` 找到 `ctx.ask`，确认它位于 `writeWithDirs` 之前。
2. **解释题**：说明 `replaceAll=false` 时多候选为什么必须失败。
3. **对比题**：列出 `edit` 与 `write` 的一个共同点和两个差异。
4. **实现题**：为 mini agent 写一个 `planEdit()`，只返回 `{ before, after, diff }`，暂不落盘。
5. **故障题**：让 formatter 抛错，设计一个结果结构，明确区分 `writeSucceeded` 与 `verificationSucceeded`。

## 12. 最后复盘：文件能改了，命令能直接跑吗？

本章的最小闭环是：

```text
读取事实 -> 计算唯一修改 -> 展示 diff 并授权 -> 落盘 -> 格式化 -> 诊断反馈
```

你现在知道，coding agent 的文件能力不是“会调 `fs.writeFile`”，而是能让副作用保持可见、可拒绝、可追踪，并把后果重新放回推理上下文。

但文件工具的输入结构很受控；Shell 允许模型提交一整段命令，里面可能有管道、变量、重定向、外部路径和长时间进程。下一章的问题因此更难：**在命令真正启动前，runtime 能从一段 shell 文本中判断出哪些风险？**
