跳转到内容

文件读写与代码修改

v1.18.16 源码提交 a3647eb025c7 · 官方 Release
状态已完成
难度中等
预计阅读40 分钟
  • packages/opencode/src/tool/read.ts
  • packages/opencode/src/tool/edit.ts
  • packages/opencode/src/tool/write.ts
  • packages/core/src/filesystem/watcher.ts
  • packages/opencode/src/format/
  • packages/opencode/src/lsp/lsp.ts

源码基线:v1.18.16(提交 a3647eb025c7)。本章中的参数和文件名是教学用的典型调用,用于串起已核对的源码分支,不是一次真实运行录屏。

学完本章,你应该能:

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

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

本章只追一个问题:

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

  • 文件基础设施已下沉到 @opencode-ai/core:工具依赖 FSUtil.ServiceFileSystem.Event 与共享 Watcher.Event,旧版的本地 src/file/ 目录不再是证据入口。
  • edit 增加按绝对路径归一化的 semaphore,同一文件的并发修改被串行化;同时保留 BOM 与原始换行风格。
  • edit/write 都先计算 diff 并审批,落盘后再格式化、发布文件事件、触发 LSP;这是“先计算、再授权、后反馈”在最新版中的真实顺序。
文件锁、新文件审批与事件 packages/opencode/src/tool/edit.ts:35-120

锁和审批发生在写入之前,Watcher 事件发生在写入之后。

35const locks = new Map<string, Semaphore.Semaphore>()3637function lock(filePath: string) {定义一段可复用逻辑。38  const resolvedFilePath = FSUtil.resolve(filePath)39  const hit = locks.get(resolvedFilePath)40  if (hit) return hit按条件进入分支。4142  const next = Semaphore.makeUnsafe(1)43  locks.set(resolvedFilePath, next)44  return next返回给上一层。45}4647export const Parameters = Schema.Struct({定义并校验数据形状。48  filePath: Schema.String.annotate({ description: "The absolute path to the file to modify" }),定义并校验数据形状。49  oldString: Schema.String.annotate({ description: "The text to replace" }),定义并校验数据形状。50  newString: Schema.String.annotate({定义并校验数据形状。51    description: "The text to replace it with (must be different from oldString)",52  }),53  replaceAll: Schema.optional(Schema.Boolean).annotate({定义并校验数据形状。54    description: "Replace all occurrences of oldString (default false)",55  }),56})5758export const EditTool = Tool.define(声明可调用工具。59  "edit",60  Effect.gen(function* () {Effect 异步工作流。61    const lsp = yield* LSP.Service处理语言服务诊断。62    const afs = yield* FSUtil.Service等待 Effect 结果。63    const format = yield* Format.Service等待 Effect 结果。64    const events = yield* EventV2Bridge.Service等待 Effect 结果。6566    return {返回给上一层。67      description: DESCRIPTION,68      parameters: Parameters,69      execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>定义并校验数据形状。70        Effect.gen(function* () {Effect 异步工作流。71          if (!params.filePath) {按条件进入分支。72            throw new Error("filePath is required")失败时抛出错误。73          }7475          if (params.oldString === params.newString) {按条件进入分支。76            throw new Error("No changes to apply: oldString and newString are identical.")准备修改文件内容。77          }7879          const instance = yield* InstanceState.context等待 Effect 结果。80          const filePath = path.isAbsolute(params.filePath)81            ? params.filePath82            : path.join(instance.directory, params.filePath)83          yield* assertExternalDirectoryEffect(ctx, filePath)等待 Effect 结果。8485          let diff = ""86          let contentOld = ""87          let contentNew = ""88          yield* lock(filePath).withPermits(1)(等待 Effect 结果。89            Effect.gen(function* () {Effect 异步工作流。90              if (params.oldString === "") {按条件进入分支。91                const existed = yield* afs.existsSafe(filePath)读写本地文件。92                if (existed) {按条件进入分支。93                  throw new Error(失败时抛出错误。94                    "oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.",准备修改文件内容。95                  )96                }97                const next = Bom.split(params.newString)98                const desiredBom = next.bom99                contentOld = ""100                contentNew = next.text101                diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))准备修改文件内容。102                yield* ctx.ask({进入权限审批。103                  permission: "edit",104                  patterns: [path.relative(instance.worktree, filePath)],105                  always: ["*"],106                  metadata: {107                    filepath: filePath,108                    diff,109                  },110                })111                yield* afs.writeWithDirs(filePath, Bom.join(contentNew, desiredBom))准备修改文件内容。112                if (yield* format.file(filePath)) {等待 Effect 结果。113                  contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)等待 Effect 结果。114                }115                yield* events.publish(FileSystem.Event.Edited, { file: filePath })广播状态变化。116                yield* events.publish(Watcher.Event.Updated, {广播状态变化。117                  file: filePath,118                  event: "add",119                })120                return返回给上一层。
现有文件的 diff、写入与诊断 packages/opencode/src/tool/edit.ts:137-201

最终 diff 会按格式化后的内容重新计算。

137              diff = trimDiff(138                createTwoFilesPatch(准备修改文件内容。139                  filePath,140                  filePath,141                  normalizeLineEndings(contentOld),142                  normalizeLineEndings(contentNew),143                ),144              )145              yield* ctx.ask({进入权限审批。146                permission: "edit",147                patterns: [path.relative(instance.worktree, filePath)],148                always: ["*"],149                metadata: {150                  filepath: filePath,151                  diff,152                },153              })154155              yield* afs.writeWithDirs(filePath, Bom.join(contentNew, desiredBom))准备修改文件内容。156              if (yield* format.file(filePath)) {等待 Effect 结果。157                contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)等待 Effect 结果。158              }159              yield* events.publish(FileSystem.Event.Edited, { file: filePath })广播状态变化。160              yield* events.publish(Watcher.Event.Updated, {广播状态变化。161                file: filePath,162                event: "change",163              })164              diff = trimDiff(165                createTwoFilesPatch(准备修改文件内容。166                  filePath,167                  filePath,168                  normalizeLineEndings(contentOld),169                  normalizeLineEndings(contentNew),170                ),171              )172            }).pipe(Effect.orDie),Effect 异步工作流。173          )174175          let additions = 0176          let deletions = 0177          for (const change of diffLines(contentOld, contentNew)) {遍历集合。178            if (change.added) additions += change.count || 0按条件进入分支。179            if (change.removed) deletions += change.count || 0按条件进入分支。180          }181          const filediff: Snapshot.FileDiff = {182            file: filePath,183            patch: diff,准备修改文件内容。184            additions,185            deletions,186          }187188          yield* ctx.metadata({等待 Effect 结果。189            metadata: {190              diff,191              filediff,192              diagnostics: {},处理语言服务诊断。193            },194          })195196          let output = "Edit applied successfully."197          yield* lsp.touchFile(filePath, "document")等待 Effect 结果。198          const diagnostics = yield* lsp.diagnostics()处理语言服务诊断。199          const normalizedFilePath = FSUtil.normalizePath(filePath)200          const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])处理语言服务诊断。201          if (block) output += `\n\nLSP errors detected in this file, please fix:\n${block}`处理语言服务诊断。

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

Section titled “2. 先看全图:文件工具在 Agent 中的位置”
模型产生 tool call
|
v
SessionTools 提供 Tool.Context
| ask / metadata / abort / session 信息
v
+---------------- 文件工具 ----------------+
| read edit write |
| 读或列目录 定位旧片段并替换 全量写入 |
+------------------+------------------------+
|
+----------+-----------+
| | |
v v v
Permission FSUtil/FileSystem Format
路径/操作审批 真正读写 可选格式化
|
v
文件事件 + LSP
|
v
tool result 回到 Agent loop

边界要先分清:

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

SessionTools.resolve 负责把工具包装给模型,并在 Tool.Context 中接好 askmetadata。文件工具只消费这个上下文,不直接关心 Provider。见 packages/opencode/src/session/tools.ts

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

Section titled “3. 最小机制:一次可靠修改至少有六步”

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

1async function safeEdit(input, ctx) {定义一段可复用逻辑。2  const file = resolvePath(input.filePath)3  await checkExternalBoundary(file, ctx)45  const before = await readExistingFile(file)6  const after = replaceUniquely(before, input.oldString, input.newString)7  const diff = makeDiff(before, after)89  await ctx.ask({ permission: "edit", pattern: relative(file), diff })进入权限审批。10  await write(file, after)11  await formatIfAvailable(file)12  return await reportDiagnostics(file)处理语言服务诊断。13}

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

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

write 的参数描述要求绝对路径,但实现仍兼容相对路径并相对 instance directory 解析。这里应以执行代码为准:packages/opencode/src/tool/write.tspackages/opencode/src/tool/write.ts

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

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

Section titled “5. 一条具体源码旅程:局部替换一个配置值”

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

1{2  "filePath": "src/config.ts",3  "oldString": "const timeout = 30",4  "newString": "const timeout = 60"5}

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

EditTool 暴露 filePath / oldString / newString / replaceAll。执行入口会拒绝空路径,也会拒绝新旧文本完全相同。见 packages/opencode/src/tool/edit.tspackages/opencode/src/tool/edit.ts

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

1const filePath = path.isAbsolute(params.filePath)2  ? params.filePath3  : path.join(instance.directory, params.filePath)4yield* assertExternalDirectoryEffect(ctx, filePath)等待 Effect 结果。

路径:packages/opencode/src/tool/edit.ts

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

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

1const locks = new Map<string, Semaphore.Semaphore>()23function lock(filePath: string) {定义一段可复用逻辑。4  const resolvedFilePath = FSUtil.resolve(filePath)5  // 同一路径复用一个 semaphore6}

路径:packages/opencode/src/tool/edit.ts

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

证据边界:源码证明了 EditTool 内部的进程内串行化;它不等于跨进程文件锁,也不证明 WriteToolEditTool 之间互斥。

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

Section titled “5.3 替换前保留原文件的文本约定”

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

1const ending = detectLineEnding(contentOld)2const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)3const replacement = convertToLineEnding(normalizeLineEndings(params.newString), ending)

路径:packages/opencode/src/tool/edit.ts

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

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

Section titled “5.4 “找到旧文本”不是简单 String.replace”

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

这里体现一个重要取舍:

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

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

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

Section titled “5.5 diff 是审批对象,不只是结果展示”

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

1diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))2yield* ctx.ask({进入权限审批。3  permission: "edit",4  patterns: [path.relative(instance.worktree, filePath)],5  always: ["*"],6  metadata: { filepath: filePath, diff },7})

路径:packages/opencode/src/tool/edit.ts

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

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

Section titled “5.6 落盘后,格式化可能改变最终 diff”

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

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

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

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

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

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

  • FileSystem.Event.Edited:表达 OpenCode 已编辑文件。
  • Watcher.Event.Updated:把 add/change 变化交给共享 watcher 消费者。
  • FileWatcher.Event.Updated:表达文件是 change,创建分支则可能是 add

路径:packages/opencode/src/tool/edit.tspackages/opencode/src/tool/edit.ts

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

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

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

最后,EditTool 通知 LSP 并读取诊断:

1yield* lsp.touchFile(filePath, "document")等待 Effect 结果。2const diagnostics = yield* lsp.diagnostics()处理语言服务诊断。3const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])处理语言服务诊断。

路径:packages/opencode/src/tool/edit.ts

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

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

Section titled “6. 回头看 ReadTool:可靠编辑从可靠读取开始”

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

它不只处理普通文本:

  • 目录会排序、分页并返回 entry 列表:packages/opencode/src/tool/read.ts
  • 图片和 PDF 会作为附件返回:packages/opencode/src/tool/read.ts
  • 已识别的二进制文件会失败:packages/opencode/src/tool/read.tspackages/opencode/src/tool/read.ts
  • 文本默认最多 2000 行,同时受 50 KB 和单行 2000 字符限制:packages/opencode/src/tool/read.tspackages/opencode/src/tool/read.ts
  • 输出被截断时明确给出下一次 offsetpackages/opencode/src/tool/read.ts

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

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

7. WriteTool:同一安全骨架,不同变更语义

Section titled “7. WriteTool:同一安全骨架,不同变更语义”

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

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

源码主干:packages/opencode/src/tool/write.ts

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

WriteToolEditTool 的诊断范围略有不同:前者除了当前文件,还最多报告五个有 error 的其他文件;后者只把当前文件 error 拼进文本输出。见 packages/opencode/src/tool/write.tspackages/opencode/src/tool/write.tspackages/opencode/src/tool/edit.ts

8. 失败路径:在哪一步停,留下什么

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

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

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

Section titled “9. OpenCode 的选择:可靠性与可用性的取舍”

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

Section titled “选择一:文本替换,而不是让模型直接提交任意 patch”

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

选择二:审批在落盘前,格式化在落盘后

Section titled “选择二:审批在落盘前,格式化在落盘后”

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

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

Section titled “选择三:LSP 是 best-effort 后置反馈”

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

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

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

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

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

Section titled “方法二:把前置失败和后置失败分开报告”

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

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

Section titled “方法三:容错定位必须配歧义闸门”

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

先合上源码,用 90 秒复述:

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

如果答不清,按下面的梯子重走一遍:

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

12. 最后复盘:文件能改了,命令能直接跑吗?

Section titled “12. 最后复盘:文件能改了,命令能直接跑吗?”

本章的最小闭环是:

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

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

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