跳转到内容

文件读写与代码修改

旧版 eec0843c 源码提交 eec0843ce422
状态已完成
难度中等
预计阅读40 分钟
  • packages/opencode/src/tool/read.ts
  • packages/opencode/src/tool/edit.ts
  • packages/opencode/src/tool/write.ts
  • packages/opencode/src/file/
  • packages/opencode/src/format/
  • packages/opencode/src/lsp/lsp.ts

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

学完本章,你应该能:

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

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

本章只追一个问题:

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

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

Section titled “2. 先看全图:文件工具在 Agent 中的位置”
模型产生 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 中接好 askmetadata。文件工具只消费这个上下文,不直接关心 Provider。见 packages/opencode/src/session/tools.ts:42-73

packages/opencode/src/session/tools.ts packages/opencode/src/session/tools.ts:42-73
42  const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({43    sessionID: input.session.id,44    abort: options.abortSignal!,45    messageID: input.processor.message.id,46    callID: options.toolCallId,47    extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps },选择模型或 provider。48    agent: input.agent.name,49    messages: input.messages,50    metadata: (val) =>51      input.processor.updateToolCall(options.toolCallId, (match) => {52        if (!["running", "pending"].includes(match.state.status)) return match按条件进入分支。53        return {返回给上一层。54          ...match,55          state: {56            title: val.title,57            metadata: val.metadata,58            status: "running",59            input: args,60            time: { start: Date.now() },61          },62        }63      }),64    ask: (req) =>65      permission66        .ask({67          ...req,68          sessionID: input.session.id,69          tool: { messageID: input.processor.message.id, callID: options.toolCallId },70          ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),71        })72        .pipe(Effect.orDie),Effect 异步工作流。73  })

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.ts:20-25packages/opencode/src/tool/write.ts:38-44

packages/opencode/src/tool/write.ts packages/opencode/src/tool/write.ts:20-25
20export const Parameters = Schema.Struct({定义并校验数据形状。21  content: Schema.String.annotate({ description: "The content to write to the file" }),定义并校验数据形状。22  filePath: Schema.String.annotate({定义并校验数据形状。23    description: "The absolute path to the file to write (must be absolute, not relative)",24  }),25})
packages/opencode/src/tool/write.ts packages/opencode/src/tool/write.ts:38-44
38      execute: (params: { content: string; filePath: string }, ctx: Tool.Context) =>39        Effect.gen(function* () {Effect 异步工作流。40          const instance = yield* InstanceState.context等待 Effect 结果。41          const filepath = path.isAbsolute(params.filePath)42            ? params.filePath43            : path.join(instance.directory, params.filePath)44          yield* assertExternalDirectoryEffect(ctx, filepath)等待 Effect 结果。

Java 类比可以帮助定位职责:AppFileSystem 像基础设施 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.ts:47-56packages/opencode/src/tool/edit.ts:69-77

packages/opencode/src/tool/edit.ts packages/opencode/src/tool/edit.ts:47-56
47export 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})
packages/opencode/src/tool/edit.ts packages/opencode/src/tool/edit.ts:69-77
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          }

随后,相对路径基于 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:79-84

packages/opencode/src/tool/edit.ts packages/opencode/src/tool/edit.ts:79-84
79          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 结果。84

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

packages/opencode/src/tool/external-directory.ts packages/opencode/src/tool/external-directory.ts:16-45
16export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirectory")(function* (Effect 异步工作流。17  ctx: Tool.Context,18  target?: string,19  options?: Options,20) {21  if (!target) return按条件进入分支。2223  if (options?.bypass) return按条件进入分支。2425  const ins = yield* InstanceState.context等待 Effect 结果。26  const full = process.platform === "win32" ? AppFileSystem.normalizePath(target) : target读写本地文件。27  if (containsPath(full, ins)) return按条件进入分支。2829  const kind = options?.kind ?? "file"30  const dir = kind === "directory" ? full : path.dirname(full)31  const glob =32    process.platform === "win32"33      ? AppFileSystem.normalizePathPattern(path.join(dir, "*"))读写本地文件。34      : path.join(dir, "*").replaceAll("\\", "/")3536  yield* ctx.ask({进入权限审批。37    permission: "external_directory",38    patterns: [glob],39    always: [glob],40    metadata: {41      filepath: full,42      parentDir: dir,43    },44  })45})

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

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

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

packages/opencode/src/tool/edit.ts packages/opencode/src/tool/edit.ts:35-45
35const locks = new Map<string, Semaphore.Semaphore>()3637function lock(filePath: string) {定义一段可复用逻辑。38  const resolvedFilePath = AppFileSystem.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}

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

packages/opencode/src/tool/edit.ts packages/opencode/src/tool/edit.ts:85-89
85          let diff = ""86          let contentOld = ""87          let contentNew = ""88          yield* lock(filePath).withPermits(1)(等待 Effect 结果。89            Effect.gen(function* () {Effect 异步工作流。

证据边界:源码证明了 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:119-130

packages/opencode/src/tool/edit.ts packages/opencode/src/tool/edit.ts:119-130
119              const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))读写本地文件。120              if (!info) throw new Error(`File ${filePath} not found`)按条件进入分支。121              if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`)按条件进入分支。122              const source = yield* Bom.readFile(afs, filePath)读写本地文件。123              contentOld = source.text124125              const ending = detectLineEnding(contentOld)126              const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)127              const replacement = convertToLineEnding(normalizeLineEndings(params.newString), ending)128129              const next = Bom.split(replace(contentOld, old, replacement, params.replaceAll))130              const desiredBom = source.bom || next.bom

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

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

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

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

packages/opencode/src/tool/edit.ts packages/opencode/src/tool/edit.ts:674-710
674export function replace(content: string, oldString: string, newString: string, replaceAll = false): string {对外暴露模块成员。675  if (oldString === newString) {按条件进入分支。676    throw new Error("No changes to apply: oldString and newString are identical.")准备修改文件内容。677  }678679  let notFound = true680681  for (const replacer of [遍历集合。682    SimpleReplacer,683    LineTrimmedReplacer,684    BlockAnchorReplacer,685    WhitespaceNormalizedReplacer,686    IndentationFlexibleReplacer,687    EscapeNormalizedReplacer,688    TrimmedBoundaryReplacer,689    ContextAwareReplacer,690    MultiOccurrenceReplacer,691  ]) {692    for (const search of replacer(content, oldString)) {遍历集合。693      const index = content.indexOf(search)694      if (index === -1) continue按条件进入分支。695      notFound = false696      if (replaceAll) {按条件进入分支。697        return content.replaceAll(search, newString)返回给上一层。698      }699      const lastIndex = content.lastIndexOf(search)700      if (index !== lastIndex) continue按条件进入分支。701      return content.substring(0, index) + newString + content.substring(index + search.length)返回给上一层。702    }703  }704705  if (notFound) {按条件进入分支。706    throw new Error(失败时抛出错误。707      "Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings.",708    )709  }710  throw new Error("Found multiple matches for oldString. Provide more surrounding context to make the match unique.")失败时抛出错误。

这里体现一个重要取舍:

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

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

packages/opencode/src/tool/edit.ts packages/opencode/src/tool/edit.ts:213-636
213export type Replacer = (content: string, find: string) => Generator<string, void, unknown>定义数据结构约束。214215// Similarity thresholds for block anchor fallback matching216const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0217const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3218219/**220 * Levenshtein distance algorithm implementation221 */222function levenshtein(a: string, b: string): number {定义一段可复用逻辑。223  // Handle empty strings224  if (a === "" || b === "") {按条件进入分支。225    return Math.max(a.length, b.length)返回给上一层。226  }227  const matrix = Array.from({ length: a.length + 1 }, (_, i) =>228    Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),229  )230231  for (let i = 1; i <= a.length; i++) {遍历集合。232    for (let j = 1; j <= b.length; j++) {遍历集合。233      const cost = a[i - 1] === b[j - 1] ? 0 : 1234      matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost)235    }236  }237  return matrix[a.length][b.length]返回给上一层。238}239240export const SimpleReplacer: Replacer = function* (_content, find) {对外暴露模块成员。241  yield find242}243244export const LineTrimmedReplacer: Replacer = function* (content, find) {对外暴露模块成员。245  const originalLines = content.split("\n")246  const searchLines = find.split("\n")247248  if (searchLines[searchLines.length - 1] === "") {按条件进入分支。249    searchLines.pop()250  }251252  for (let i = 0; i <= originalLines.length - searchLines.length; i++) {遍历集合。253    let matches = true254255    for (let j = 0; j < searchLines.length; j++) {遍历集合。256      const originalTrimmed = originalLines[i + j].trim()257      const searchTrimmed = searchLines[j].trim()258259      if (originalTrimmed !== searchTrimmed) {按条件进入分支。260        matches = false261        break262      }263    }264265    if (matches) {按条件进入分支。266      let matchStartIndex = 0267      for (let k = 0; k < i; k++) {遍历集合。268        matchStartIndex += originalLines[k].length + 1269      }270271      let matchEndIndex = matchStartIndex272      for (let k = 0; k < searchLines.length; k++) {遍历集合。273        matchEndIndex += originalLines[i + k].length274        if (k < searchLines.length - 1) {按条件进入分支。275          matchEndIndex += 1 // Add newline character except for the last line276        }277      }278279      yield content.substring(matchStartIndex, matchEndIndex)280    }281  }282}283284export const BlockAnchorReplacer: Replacer = function* (content, find) {对外暴露模块成员。285  const originalLines = content.split("\n")286  const searchLines = find.split("\n")287288  if (searchLines.length < 3) {按条件进入分支。289    return返回给上一层。290  }291292  if (searchLines[searchLines.length - 1] === "") {按条件进入分支。293    searchLines.pop()294  }295296  const firstLineSearch = searchLines[0].trim()297  const lastLineSearch = searchLines[searchLines.length - 1].trim()298  const searchBlockSize = searchLines.length299300  // Collect all candidate positions where both anchors match301  const candidates: Array<{ startLine: number; endLine: number }> = []302  for (let i = 0; i < originalLines.length; i++) {遍历集合。303    if (originalLines[i].trim() !== firstLineSearch) {按条件进入分支。304      continue305    }306307    // Look for the matching last line after this first line308    for (let j = i + 2; j < originalLines.length; j++) {遍历集合。309      if (originalLines[j].trim() === lastLineSearch) {按条件进入分支。310        candidates.push({ startLine: i, endLine: j })311        break // Only match the first occurrence of the last line312      }313    }314  }315316  // Return immediately if no candidates317  if (candidates.length === 0) {按条件进入分支。318    return返回给上一层。319  }320321  // Handle single candidate scenario (using relaxed threshold)322  if (candidates.length === 1) {按条件进入分支。323    const { startLine, endLine } = candidates[0]324    const actualBlockSize = endLine - startLine + 1325326    let similarity = 0327    let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only328329    if (linesToCheck > 0) {按条件进入分支。330      for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {遍历集合。331        const originalLine = originalLines[startLine + j].trim()332        const searchLine = searchLines[j].trim()333        const maxLen = Math.max(originalLine.length, searchLine.length)334        if (maxLen === 0) {按条件进入分支。335          continue336        }337        const distance = levenshtein(originalLine, searchLine)338        similarity += (1 - distance / maxLen) / linesToCheck339340        // Exit early when threshold is reached341        if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {按条件进入分支。342          break343        }344      }345    } else {346      // No middle lines to compare, just accept based on anchors347      similarity = 1.0348    }349350    if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {按条件进入分支。351      let matchStartIndex = 0352      for (let k = 0; k < startLine; k++) {遍历集合。353        matchStartIndex += originalLines[k].length + 1354      }355      let matchEndIndex = matchStartIndex356      for (let k = startLine; k <= endLine; k++) {遍历集合。357        matchEndIndex += originalLines[k].length358        if (k < endLine) {按条件进入分支。359          matchEndIndex += 1 // Add newline character except for the last line360        }361      }362      yield content.substring(matchStartIndex, matchEndIndex)363    }364    return返回给上一层。365  }366367  // Calculate similarity for multiple candidates368  let bestMatch: { startLine: number; endLine: number } | null = null369  let maxSimilarity = -1370371  for (const candidate of candidates) {遍历集合。372    const { startLine, endLine } = candidate373    const actualBlockSize = endLine - startLine + 1374375    let similarity = 0376    let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only377378    if (linesToCheck > 0) {按条件进入分支。379      for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {遍历集合。380        const originalLine = originalLines[startLine + j].trim()381        const searchLine = searchLines[j].trim()382        const maxLen = Math.max(originalLine.length, searchLine.length)383        if (maxLen === 0) {按条件进入分支。384          continue385        }386        const distance = levenshtein(originalLine, searchLine)387        similarity += 1 - distance / maxLen388      }389      similarity /= linesToCheck // Average similarity390    } else {391      // No middle lines to compare, just accept based on anchors392      similarity = 1.0393    }394395    if (similarity > maxSimilarity) {按条件进入分支。396      maxSimilarity = similarity397      bestMatch = candidate398    }399  }400401  // Threshold judgment402  if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) {按条件进入分支。403    const { startLine, endLine } = bestMatch404    let matchStartIndex = 0405    for (let k = 0; k < startLine; k++) {遍历集合。406      matchStartIndex += originalLines[k].length + 1407    }408    let matchEndIndex = matchStartIndex409    for (let k = startLine; k <= endLine; k++) {遍历集合。410      matchEndIndex += originalLines[k].length411      if (k < endLine) {按条件进入分支。412        matchEndIndex += 1413      }414    }415    yield content.substring(matchStartIndex, matchEndIndex)416  }417}418419export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {对外暴露模块成员。420  const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()421  const normalizedFind = normalizeWhitespace(find)422423  // Handle single line matches424  const lines = content.split("\n")425  for (let i = 0; i < lines.length; i++) {遍历集合。426    const line = lines[i]427    if (normalizeWhitespace(line) === normalizedFind) {按条件进入分支。428      yield line429    } else {430      // Only check for substring matches if the full line doesn't match431      const normalizedLine = normalizeWhitespace(line)432      if (normalizedLine.includes(normalizedFind)) {按条件进入分支。433        // Find the actual substring in the original line that matches434        const words = find.trim().split(/\s+/)435        if (words.length > 0) {按条件进入分支。436          const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")437          try {开始保护性执行。438            const regex = new RegExp(pattern)439            const match = line.match(regex)440            if (match) {按条件进入分支。441              yield match[0]442            }443          } catch {444            // Invalid regex pattern, skip445          }446        }447      }448    }449  }450451  // Handle multi-line matches452  const findLines = find.split("\n")453  if (findLines.length > 1) {按条件进入分支。454    for (let i = 0; i <= lines.length - findLines.length; i++) {遍历集合。455      const block = lines.slice(i, i + findLines.length)456      if (normalizeWhitespace(block.join("\n")) === normalizedFind) {按条件进入分支。457        yield block.join("\n")458      }459    }460  }461}462463export const IndentationFlexibleReplacer: Replacer = function* (content, find) {对外暴露模块成员。464  const removeIndentation = (text: string) => {465    const lines = text.split("\n")466    const nonEmptyLines = lines.filter((line) => line.trim().length > 0)467    if (nonEmptyLines.length === 0) return text按条件进入分支。468469    const minIndent = Math.min(470      ...nonEmptyLines.map((line) => {471        const match = line.match(/^(\s*)/)472        return match ? match[1].length : 0返回给上一层。473      }),474    )475476    return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n")返回给上一层。477  }478479  const normalizedFind = removeIndentation(find)480  const contentLines = content.split("\n")481  const findLines = find.split("\n")482483  for (let i = 0; i <= contentLines.length - findLines.length; i++) {遍历集合。484    const block = contentLines.slice(i, i + findLines.length).join("\n")485    if (removeIndentation(block) === normalizedFind) {按条件进入分支。486      yield block487    }488  }489}490491export const EscapeNormalizedReplacer: Replacer = function* (content, find) {对外暴露模块成员。492  const unescapeString = (str: string): string => {493    return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => {返回给上一层。494      switch (capturedChar) {495        case "n":496          return "\n"返回给上一层。497        case "t":498          return "\t"返回给上一层。499        case "r":500          return "\r"返回给上一层。501        case "'":502          return "'"返回给上一层。503        case '"':504          return '"'返回给上一层。505        case "`":506          return "`"返回给上一层。507        case "\\":508          return "\\"返回给上一层。509        case "\n":510          return "\n"返回给上一层。511        case "$":512          return "$"返回给上一层。513        default:514          return match返回给上一层。515      }516    })517  }518519  const unescapedFind = unescapeString(find)520521  // Try direct match with unescaped find string522  if (content.includes(unescapedFind)) {按条件进入分支。523    yield unescapedFind524  }525526  // Also try finding escaped versions in content that match unescaped find527  const lines = content.split("\n")528  const findLines = unescapedFind.split("\n")529530  for (let i = 0; i <= lines.length - findLines.length; i++) {遍历集合。531    const block = lines.slice(i, i + findLines.length).join("\n")532    const unescapedBlock = unescapeString(block)533534    if (unescapedBlock === unescapedFind) {按条件进入分支。535      yield block536    }537  }538}539540export const MultiOccurrenceReplacer: Replacer = function* (content, find) {对外暴露模块成员。541  // This replacer yields all exact matches, allowing the replace function542  // to handle multiple occurrences based on replaceAll parameter543  let startIndex = 0544545  while (true) {持续循环到退出条件。546    const index = content.indexOf(find, startIndex)547    if (index === -1) break按条件进入分支。548549    yield find550    startIndex = index + find.length551  }552}553554export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {对外暴露模块成员。555  const trimmedFind = find.trim()556557  if (trimmedFind === find) {按条件进入分支。558    // Already trimmed, no point in trying559    return返回给上一层。560  }561562  // Try to find the trimmed version563  if (content.includes(trimmedFind)) {按条件进入分支。564    yield trimmedFind565  }566567  // Also try finding blocks where trimmed content matches568  const lines = content.split("\n")569  const findLines = find.split("\n")570571  for (let i = 0; i <= lines.length - findLines.length; i++) {遍历集合。572    const block = lines.slice(i, i + findLines.length).join("\n")573574    if (block.trim() === trimmedFind) {按条件进入分支。575      yield block576    }577  }578}579580export const ContextAwareReplacer: Replacer = function* (content, find) {对外暴露模块成员。581  const findLines = find.split("\n")582  if (findLines.length < 3) {按条件进入分支。583    // Need at least 3 lines to have meaningful context584    return返回给上一层。585  }586587  // Remove trailing empty line if present588  if (findLines[findLines.length - 1] === "") {按条件进入分支。589    findLines.pop()590  }591592  const contentLines = content.split("\n")593594  // Extract first and last lines as context anchors595  const firstLine = findLines[0].trim()596  const lastLine = findLines[findLines.length - 1].trim()597598  // Find blocks that start and end with the context anchors599  for (let i = 0; i < contentLines.length; i++) {遍历集合。600    if (contentLines[i].trim() !== firstLine) continue按条件进入分支。601602    // Look for the matching last line603    for (let j = i + 2; j < contentLines.length; j++) {遍历集合。604      if (contentLines[j].trim() === lastLine) {按条件进入分支。605        // Found a potential context block606        const blockLines = contentLines.slice(i, j + 1)607        const block = blockLines.join("\n")608609        // Check if the middle content has reasonable similarity610        // (simple heuristic: at least 50% of non-empty lines should match when trimmed)611        if (blockLines.length === findLines.length) {按条件进入分支。612          let matchingLines = 0613          let totalNonEmptyLines = 0614615          for (let k = 1; k < blockLines.length - 1; k++) {遍历集合。616            const blockLine = blockLines[k].trim()617            const findLine = findLines[k].trim()618619            if (blockLine.length > 0 || findLine.length > 0) {按条件进入分支。620              totalNonEmptyLines++621              if (blockLine === findLine) {按条件进入分支。622                matchingLines++623              }624            }625          }626627          if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) {按条件进入分支。628            yield block629            break // Only match the first occurrence630          }631        }632        break633      }634    }635  }636}

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:133-149

packages/opencode/src/tool/edit.ts packages/opencode/src/tool/edit.ts:133-149
133              diff = trimDiff(134                createTwoFilesPatch(准备修改文件内容。135                  filePath,136                  filePath,137                  normalizeLineEndings(contentOld),138                  normalizeLineEndings(contentNew),139                ),140              )141              yield* ctx.ask({进入权限审批。142                permission: "edit",143                patterns: [path.relative(instance.worktree, filePath)],144                always: ["*"],145                metadata: {146                  filepath: filePath,147                  diff,148                },149              })
EditTool 的计算、审批与写入 packages/opencode/src/tool/edit.ts:119-168

注意 diff 在写文件之前生成,审批完成后才落盘。

119              const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))读写本地文件。120              if (!info) throw new Error(`File ${filePath} not found`)按条件进入分支。121              if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`)按条件进入分支。122              const source = yield* Bom.readFile(afs, filePath)读写本地文件。123              contentOld = source.text124125              const ending = detectLineEnding(contentOld)126              const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)127              const replacement = convertToLineEnding(normalizeLineEndings(params.newString), ending)128129              const next = Bom.split(replace(contentOld, old, replacement, params.replaceAll))130              const desiredBom = source.bom || next.bom131              contentNew = next.text132133              diff = trimDiff(134                createTwoFilesPatch(准备修改文件内容。135                  filePath,136                  filePath,137                  normalizeLineEndings(contentOld),138                  normalizeLineEndings(contentNew),139                ),140              )141              yield* ctx.ask({进入权限审批。142                permission: "edit",143                patterns: [path.relative(instance.worktree, filePath)],144                always: ["*"],145                metadata: {146                  filepath: filePath,147                  diff,148                },149              })150151              yield* afs.writeWithDirs(filePath, Bom.join(contentNew, desiredBom))准备修改文件内容。152              if (yield* format.file(filePath)) {等待 Effect 结果。153                contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)等待 Effect 结果。154              }155              yield* bus.publish(File.Event.Edited, { file: filePath })广播状态变化。156              yield* bus.publish(FileWatcher.Event.Updated, {广播状态变化。157                file: filePath,158                event: "change",159              })160              diff = trimDiff(161                createTwoFilesPatch(准备修改文件内容。162                  filePath,163                  filePath,164                  normalizeLineEndings(contentOld),165                  normalizeLineEndings(contentNew),166                ),167              )168            }).pipe(Effect.orDie),Effect 异步工作流。

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:151-167

packages/opencode/src/tool/edit.ts packages/opencode/src/tool/edit.ts:151-167
151              yield* afs.writeWithDirs(filePath, Bom.join(contentNew, desiredBom))准备修改文件内容。152              if (yield* format.file(filePath)) {等待 Effect 结果。153                contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)等待 Effect 结果。154              }155              yield* bus.publish(File.Event.Edited, { file: filePath })广播状态变化。156              yield* bus.publish(FileWatcher.Event.Updated, {广播状态变化。157                file: filePath,158                event: "change",159              })160              diff = trimDiff(161                createTwoFilesPatch(准备修改文件内容。162                  filePath,163                  filePath,164                  normalizeLineEndings(contentOld),165                  normalizeLineEndings(contentNew),166                ),167              )

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

packages/opencode/src/format/index.ts packages/opencode/src/format/index.ts:56-120
56        async function getFormatter(ext: string) {定义一段可复用逻辑。57          const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext))58          const checks = await Promise.all(并行等待多个任务。59            matching.map(async (item) => {60              log.info("checking", { name: item.name, ext })61              const cmd = await getCommand(item)62              if (cmd) {按条件进入分支。63                log.info("enabled", { name: item.name, ext })64              }65              return {返回给上一层。66                item,67                cmd,68              }69            }),70          )71          return checks返回给上一层。72            .filter((x): x is { item: Formatter.Info; cmd: string[] } => x.cmd !== false)处理命令执行。73            .map((x) => ({ item: x.item, cmd: x.cmd }))处理命令执行。74        }7576        function formatFile(filepath: string) {定义一段可复用逻辑。77          return Effect.gen(function* () {Effect 异步工作流。78            log.info("formatting", { file: filepath })79            const formatters = yield* Effect.promise(() => getFormatter(path.extname(filepath)))Effect 异步工作流。8081            if (!formatters.length) return false按条件进入分支。8283            for (const { item, cmd } of formatters) {遍历集合。84              log.info("running", { command: cmd })处理命令执行。85              const replaced = cmd.map((x) => x.replace("$FILE", filepath))86              const dir = yield* InstanceState.directory等待 Effect 结果。87              const result = yield* appProcess等待 Effect 结果。88                .run(89                  ChildProcess.make(replaced[0]!, replaced.slice(1), {90                    cwd: dir,91                    env: item.environment,92                    extendEnv: true,93                    stdin: "ignore",94                    stdout: "ignore",95                    stderr: "ignore",96                  }),97                )98                .pipe(99                  Effect.catch((error) =>Effect 异步工作流。100                    Effect.sync(() => {Effect 异步工作流。101                      log.error("failed to format file", {102                        error: "spawn failed",103                        command: cmd,处理命令执行。104                        ...item.environment,105                        file: filepath,106                        cause: errorMessage(error.cause ?? error),107                      })108                      return undefined返回给上一层。109                    }),110                  ),111                )112              if (result && result.exitCode !== 0) {按条件进入分支。113                log.error("failed", {114                  command: cmd,处理命令执行。115                  ...item.environment,116                })117              }118            }119120            return true返回给上一层。

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

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

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

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

  • File.Event.Edited:表达 OpenCode 已编辑文件。
  • FileWatcher.Event.Updated:表达文件是 change,创建分支则可能是 add

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

packages/opencode/src/tool/edit.ts packages/opencode/src/tool/edit.ts:111-115
111                yield* bus.publish(File.Event.Edited, { file: filePath })广播状态变化。112                yield* bus.publish(FileWatcher.Event.Updated, {广播状态变化。113                  file: filePath,114                  event: existed ? "change" : "add",115                })
packages/opencode/src/tool/edit.ts packages/opencode/src/tool/edit.ts:155-159
155              yield* bus.publish(File.Event.Edited, { file: filePath })广播状态变化。156              yield* bus.publish(FileWatcher.Event.Updated, {广播状态变化。157                file: filePath,158                event: "change",159              })

这比让所有消费者轮询文件更清晰。事件是 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:192-207

packages/opencode/src/tool/edit.ts packages/opencode/src/tool/edit.ts:192-207
192          let output = "Edit applied successfully."193          yield* lsp.touchFile(filePath, "document")等待 Effect 结果。194          const diagnostics = yield* lsp.diagnostics()处理语言服务诊断。195          const normalizedFilePath = AppFileSystem.normalizePath(filePath)读写本地文件。196          const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])处理语言服务诊断。197          if (block) output += `\n\nLSP errors detected in this file, please fix:\n${block}`处理语言服务诊断。198199          return {返回给上一层。200            metadata: {201              diagnostics,处理语言服务诊断。202              diff,203              filediff,204            },205            title: `${path.relative(instance.worktree, filePath)}`,206            output,207          }

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

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

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

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

packages/opencode/src/tool/read.ts packages/opencode/src/tool/read.ts:200-234
200    const run = Effect.fn("ReadTool.execute")(function* (Effect 异步工作流。201      params: Schema.Schema.Type<typeof Parameters>,定义并校验数据形状。202      ctx: Tool.Context,203    ) {204      const instance = yield* InstanceState.context等待 Effect 结果。205      let filepath = params.filePath206      if (!path.isAbsolute(filepath)) {按条件进入分支。207        filepath = path.resolve(instance.directory, filepath)208      }209      if (process.platform === "win32") {按条件进入分支。210        filepath = AppFileSystem.normalizePath(filepath)读写本地文件。211      }212      yield* reference.ensure(filepath)等待 Effect 结果。213      const title = path.relative(instance.worktree, filepath)214215      const stat = yield* fs.stat(filepath).pipe(读写本地文件。216        Effect.catchIf(Effect 异步工作流。217          (err) => "reason" in err && err.reason._tag === "NotFound",218          () => Effect.succeed(undefined),Effect 异步工作流。219        ),220      )221222      yield* assertExternalDirectoryEffect(ctx, filepath, {等待 Effect 结果。223        bypass: Boolean(ctx.extra?.["bypassCwdCheck"]) || (yield* reference.contains(filepath)),等待 Effect 结果。224        kind: stat?.type === "Directory" ? "directory" : "file",225      })226227      yield* ctx.ask({进入权限审批。228        permission: "read",229        patterns: [path.relative(instance.worktree, filepath)],230        always: ["*"],231        metadata: {},232      })233234      if (!stat) return yield* miss(filepath)等待 Effect 结果。

它不只处理普通文本:

  • 目录会排序、分页并返回 entry 列表:packages/opencode/src/tool/read.ts:236-261

    packages/opencode/src/tool/read.ts packages/opencode/src/tool/read.ts:236-261
    236      if (stat.type === "Directory") {按条件进入分支。237        const items = yield* list(filepath)等待 Effect 结果。238        const limit = params.limit ?? DEFAULT_READ_LIMIT239        const offset = params.offset || 1240        const start = offset - 1241        const sliced = items.slice(start, start + limit)242        const truncated = start + sliced.length < items.length243244        return {返回给上一层。245          title,246          output: [247            `<path>${filepath}</path>`,248            `<type>directory</type>`,249            `<entries>`,250            sliced.join("\n"),251            truncated252              ? `\n(Showing ${sliced.length} of ${items.length} entries. Use 'offset' parameter to read beyond entry ${offset + sliced.length})`253              : `\n(${items.length} entries)`,254            `</entries>`,255          ].join("\n"),256          metadata: {257            preview: sliced.slice(0, 20).join("\n"),258            truncated,259            loaded: [] as string[],260          },261        }
  • 图片和 PDF 会作为附件返回:packages/opencode/src/tool/read.ts:264-289

    packages/opencode/src/tool/read.ts packages/opencode/src/tool/read.ts:264-289
    264      const loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID)等待 Effect 结果。265      const sample = yield* readSample(filepath, Number(stat.size), SAMPLE_BYTES)等待 Effect 结果。266267      const mime = sniffAttachmentMime(sample, AppFileSystem.mimeType(filepath))读写本地文件。268      const isImage = SUPPORTED_IMAGE_MIMES.has(mime)269270      if (isImage || isPdfAttachment(mime)) {按条件进入分支。271        const bytes = yield* fs.readFile(filepath)读写本地文件。272        const msg = isPdfAttachment(mime) ? "PDF read successfully" : "Image read successfully"273        return {返回给上一层。274          title,275          output: msg,276          metadata: {277            preview: msg,278            truncated: false,279            loaded: loaded.map((item) => item.filepath),280          },281          attachments: [282            {283              type: "file" as const,284              mime,285              url: `data:${mime};base64,${Buffer.from(bytes).toString("base64")}`,286            },287          ],288        }289      }
  • 已识别的二进制文件会失败:packages/opencode/src/tool/read.ts:153-198packages/opencode/src/tool/read.ts:291-293

    packages/opencode/src/tool/read.ts packages/opencode/src/tool/read.ts:153-198
    153    const isBinaryFile = (filepath: string, bytes: Uint8Array) => {154      const ext = path.extname(filepath).toLowerCase()155      switch (ext) {156        case ".zip":157        case ".tar":158        case ".gz":159        case ".exe":160        case ".dll":161        case ".so":162        case ".class":163        case ".jar":164        case ".war":165        case ".7z":166        case ".doc":167        case ".docx":168        case ".xls":169        case ".xlsx":170        case ".ppt":171        case ".pptx":172        case ".odt":173        case ".ods":174        case ".odp":175        case ".bin":176        case ".dat":177        case ".obj":178        case ".o":179        case ".a":180        case ".lib":181        case ".wasm":182        case ".pyc":183        case ".pyo":184          return true返回给上一层。185      }186187      if (bytes.length === 0) return false按条件进入分支。188189      let nonPrintableCount = 0190      for (let i = 0; i < bytes.length; i++) {遍历集合。191        if (bytes[i] === 0) return true按条件进入分支。192        if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) {按条件进入分支。193          nonPrintableCount++194        }195      }196197      return nonPrintableCount / bytes.length > 0.3返回给上一层。198    }
    packages/opencode/src/tool/read.ts packages/opencode/src/tool/read.ts:291-293
    291      if (isBinaryFile(filepath, sample)) {按条件进入分支。292        return yield* Effect.fail(new Error(`Cannot read binary file: ${filepath}`))Effect 异步工作流。293      }
  • 文本默认最多 2000 行,同时受 50 KB 和单行 2000 字符限制:packages/opencode/src/tool/read.ts:14-18packages/opencode/src/tool/read.ts:108-150

    packages/opencode/src/tool/read.ts packages/opencode/src/tool/read.ts:14-18
    14const DEFAULT_READ_LIMIT = 200015const MAX_LINE_LENGTH = 200016const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`17const MAX_BYTES = 50 * 102418const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
    packages/opencode/src/tool/read.ts packages/opencode/src/tool/read.ts:108-150
    108    const lines = Effect.fn("ReadTool.lines")(function* (filepath: string, opts: { limit: number; offset: number }) {Effect 异步工作流。109      const start = opts.offset - 1110      const raw: string[] = []111      const flags = { bytes: 0, count: 0, cut: false, more: false, done: false }112113      // Note: prefer manual TextDecoder over Stream.decodeText — when the source stream114      // ends without flushing, decodeText drops the final unterminated line. We also115      // avoid Stream.runForEachWhile (it currently swallows the final unterminated116      // line of the upstream splitLines pipeline) and use a tagged error to stop the117      // upstream file stream as soon as the byte cap is reached.118      const decoder = new TextDecoder("utf-8")119      yield* fs.stream(filepath).pipe(读写本地文件。120        Stream.map((bytes) => decoder.decode(bytes, { stream: true })),121        Stream.splitLines,122        Stream.runForEach((text) =>123          Effect.gen(function* () {Effect 异步工作流。124            if (flags.done) return yield* new ReadStop()等待 Effect 结果。125            flags.count += 1126            if (flags.count <= start) return按条件进入分支。127128            if (raw.length >= opts.limit) {按条件进入分支。129              flags.more = true130              return返回给上一层。131            }132133            const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text134            const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)135            if (flags.bytes + size <= MAX_BYTES) {按条件进入分支。136              raw.push(line)137              flags.bytes += size138              return返回给上一层。139            }140141            flags.cut = true142            flags.more = true143            flags.done = true144            return yield* new ReadStop()等待 Effect 结果。145          }),146        ),147        Effect.catchTag("ReadStop", () => Effect.void),Effect 异步工作流。148      )149150      return { raw, count: flags.count, cut: flags.cut, more: flags.more, offset: opts.offset }返回给上一层。
  • 输出被截断时明确给出下一次 offsetpackages/opencode/src/tool/read.ts:295-315

    packages/opencode/src/tool/read.ts packages/opencode/src/tool/read.ts:295-315
    295      const file = yield* lines(filepath, { limit: params.limit ?? DEFAULT_READ_LIMIT, offset: params.offset || 1 })等待 Effect 结果。296      if (file.count < file.offset && !(file.count === 0 && file.offset === 1)) {按条件进入分支。297        return yield* Effect.fail(Effect 异步工作流。298          new Error(`Offset ${file.offset} is out of range for this file (${file.count} lines)`),299        )300      }301302      let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>\n"].join("\n")303      output += file.raw.map((line, i) => `${i + file.offset}: ${line}`).join("\n")304305      const last = file.offset + file.raw.length - 1306      const next = last + 1307      const truncated = file.more || file.cut308      if (file.cut) {按条件进入分支。309        output += `\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${file.offset}-${last}. Use offset=${next} to continue.)`310      } else if (file.more) {311        output += `\n\n(Showing lines ${file.offset}-${last} of ${file.count}. Use offset=${next} to continue.)`312      } else {313        output += `\n\n(End of file - total ${file.count} lines)`314      }315      output += "\n</content>"

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

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

packages/opencode/src/tool/read.ts packages/opencode/src/tool/read.ts:48-71
48    const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {Effect 异步工作流。49      const dir = path.dirname(filepath)50      const base = path.basename(filepath)51      const items = yield* fs.readDirectory(dir).pipe(读写本地文件。52        Effect.map((items) =>Effect 异步工作流。53          items54            .filter(55              (item) =>56                item.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(item.toLowerCase()),57            )58            .map((item) => path.join(dir, item))59            .slice(0, 3),60        ),61        Effect.catch(() => Effect.succeed([] as string[])),Effect 异步工作流。62      )6364      if (items.length > 0) {按条件进入分支。65        return yield* Effect.fail(Effect 异步工作流。66          new Error(`File not found: ${filepath}\n\nDid you mean one of these?\n${items.join("\n")}`),67        )68      }6970      return yield* Effect.fail(new Error(`File not found: ${filepath}`))Effect 异步工作流。71    })

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:38-100

packages/opencode/src/tool/write.ts packages/opencode/src/tool/write.ts:38-100
38      execute: (params: { content: string; filePath: string }, ctx: Tool.Context) =>39        Effect.gen(function* () {Effect 异步工作流。40          const instance = yield* InstanceState.context等待 Effect 结果。41          const filepath = path.isAbsolute(params.filePath)42            ? params.filePath43            : path.join(instance.directory, params.filePath)44          yield* assertExternalDirectoryEffect(ctx, filepath)等待 Effect 结果。4546          const exists = yield* fs.existsSafe(filepath)读写本地文件。47          const source = exists ? yield* Bom.readFile(fs, filepath) : { bom: false, text: "" }读写本地文件。48          const next = Bom.split(params.content)49          const desiredBom = source.bom || next.bom50          const contentOld = source.text51          const contentNew = next.text5253          const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew))54          yield* ctx.ask({进入权限审批。55            permission: "edit",56            patterns: [path.relative(instance.worktree, filepath)],57            always: ["*"],58            metadata: {59              filepath,60              diff,61            },62          })6364          yield* fs.writeWithDirs(filepath, Bom.join(contentNew, desiredBom))读写本地文件。65          if (yield* format.file(filepath)) {等待 Effect 结果。66            yield* Bom.syncFile(fs, filepath, desiredBom)等待 Effect 结果。67          }68          yield* bus.publish(File.Event.Edited, { file: filepath })广播状态变化。69          yield* bus.publish(FileWatcher.Event.Updated, {广播状态变化。70            file: filepath,71            event: exists ? "change" : "add",72          })7374          let output = "Wrote file successfully."75          yield* lsp.touchFile(filepath, "document")等待 Effect 结果。76          const diagnostics = yield* lsp.diagnostics()处理语言服务诊断。77          const normalizedFilepath = AppFileSystem.normalizePath(filepath)读写本地文件。78          let projectDiagnosticsCount = 0处理语言服务诊断。79          for (const [file, issues] of Object.entries(diagnostics)) {处理语言服务诊断。80            const current = file === normalizedFilepath81            if (!current && projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue处理语言服务诊断。82            const block = LSP.Diagnostic.report(current ? filepath : file, issues)处理语言服务诊断。83            if (!block) continue按条件进入分支。84            if (current) {按条件进入分支。85              output += `\n\nLSP errors detected in this file, please fix:\n${block}`处理语言服务诊断。86              continue87            }88            projectDiagnosticsCount++处理语言服务诊断。89            output += `\n\nLSP errors detected in other files:\n${block}`处理语言服务诊断。90          }9192          return {返回给上一层。93            title: path.relative(instance.worktree, filepath),94            metadata: {95              diagnostics,处理语言服务诊断。96              filepath,97              exists: exists,98            },99            output,100          }

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

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

packages/opencode/src/tool/write.ts packages/opencode/src/tool/write.ts:18-18
18const MAX_PROJECT_DIAGNOSTICS_FILES = 5
packages/opencode/src/tool/write.ts packages/opencode/src/tool/write.ts:74-90
74          let output = "Wrote file successfully."75          yield* lsp.touchFile(filepath, "document")等待 Effect 结果。76          const diagnostics = yield* lsp.diagnostics()处理语言服务诊断。77          const normalizedFilepath = AppFileSystem.normalizePath(filepath)读写本地文件。78          let projectDiagnosticsCount = 0处理语言服务诊断。79          for (const [file, issues] of Object.entries(diagnostics)) {处理语言服务诊断。80            const current = file === normalizedFilepath81            if (!current && projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue处理语言服务诊断。82            const block = LSP.Diagnostic.report(current ? filepath : file, issues)处理语言服务诊断。83            if (!block) continue按条件进入分支。84            if (current) {按条件进入分支。85              output += `\n\nLSP errors detected in this file, please fix:\n${block}`处理语言服务诊断。86              continue87            }88            projectDiagnosticsCount++处理语言服务诊断。89            output += `\n\nLSP errors detected in other files:\n${block}`处理语言服务诊断。90          }
packages/opencode/src/tool/edit.ts packages/opencode/src/tool/edit.ts:192-197
192          let output = "Edit applied successfully."193          yield* lsp.touchFile(filePath, "document")等待 Effect 结果。194          const diagnostics = yield* lsp.diagnostics()处理语言服务诊断。195          const normalizedFilePath = AppFileSystem.normalizePath(filePath)读写本地文件。196          const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])处理语言服务诊断。197          if (block) output += `\n\nLSP errors detected in this file, please fix:\n${block}`处理语言服务诊断。

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 文本中判断出哪些风险?