文件读写与代码修改
eec0843ce422
Agent 生成档案
Section titled “Agent 生成档案”- 章节 ID:
06-file-editing - 章节摘要:沿一次局部 edit,理解路径边界、进程内文件锁、容错匹配、diff 审批、落盘、格式化与 LSP 反馈。
- 教程版本:
eec0843c - 源码基线:
eec0843ce42298080569ca31a6455bc3f699d213 - 章节元数据:/versions/eec0843c/data/chapters.json
- 源码映射:/versions/eec0843c/data/source-map.json
主要源码路径
Section titled “主要源码路径”packages/opencode/src/tool/read.tspackages/opencode/src/tool/edit.tspackages/opencode/src/tool/write.tspackages/opencode/src/file/packages/opencode/src/format/packages/opencode/src/lsp/lsp.ts
源码基线:
eec0843ce422。本章中的参数和文件名是教学用的典型调用,用于串起已核对的源码分支,不是一次真实运行录屏。
0. 本章学习目标
Section titled “0. 本章学习目标”学完本章,你应该能:
- 画出
read / edit / write与权限、文件系统、格式化、事件、LSP 的关系图。 - 解释为什么“模型生成了正确文本”仍不等于“修改可靠”。
- 从一次
edit典型调用追到替换、diff、审批、写入、格式化和诊断。 - 区分
edit与write的使用边界,并说出二者共享的安全闸门。 - 识别文件不存在、匹配不唯一、越过工作区、审批拒绝和诊断失败等分支。
- 把“先计算、再授权、后反馈”的做法迁移到自己的 coding agent。
1. 一句话讲明白
Section titled “1. 一句话讲明白”文件工具不是三个 fs 包装器,而是一条受控变更管线:先确认目标和预期改动,再经过权限闸门落盘,最后把格式化与诊断结果反馈给下一轮 Agent。
本章只追一个问题:
模型说“把一个旧代码片段换成新片段”之后,OpenCode 怎样避免改错文件、改错位置,并让模型知道改完是否仍有错误?
2. 先看全图:文件工具在 Agent 中的位置
Section titled “2. 先看全图:文件工具在 Agent 中的位置”模型产生 tool call | vSessionTools 提供 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。
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 只能把“系统调用成功”误当成“代码正确”。
4. 三种工具不是重复接口
Section titled “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。
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}
这不是本章实际执行过的命令。我们只用它回答:源码会按什么顺序处理?
5.1 参数先挡住无意义调用
Section titled “5.1 参数先挡住无意义调用”EditTool 暴露 filePath / oldString / newString / replaceAll。执行入口会拒绝空路径,也会拒绝新旧文本完全相同。见 packages/opencode/src/tool/edit.ts:47-56、packages/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})
5.2 同一文件的 edit 被串行化
Section titled “5.2 同一文件的 edit 被串行化”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 内部的进程内串行化;它不等于跨进程文件锁,也不证明 WriteTool 与 EditTool 之间互斥。
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-115、packages/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-261236 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-289264 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-198、packages/opencode/src/tool/read.ts:291-293。packages/opencode/src/tool/read.ts
packages/opencode/src/tool/read.ts:153-198153 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-293291 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-18、packages/opencode/src/tool/read.ts:108-150。packages/opencode/src/tool/read.ts
packages/opencode/src/tool/read.ts:14-1814const 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-150108 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 }返回给上一层。 -
输出被截断时明确给出下一次
offset:packages/opencode/src/tool/read.ts:295-315。packages/opencode/src/tool/read.ts
packages/opencode/src/tool/read.ts:295-315295 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。
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。
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 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 的选择:可靠性与可用性的取舍
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 能力就完全不能改文件”;代价是成功结果不能保证包含最新诊断。
10. 可以带走的方法
Section titled “10. 可以带走的方法”方法一:把副作用拆成“计划—授权—提交—验证”
Section titled “方法一:把副作用拆成“计划—授权—提交—验证””适用于文件修改、数据库迁移、部署和外部 API 写入。验证问题:用户批准时,是否能看到足够具体的将要发生的变化?
方法二:把前置失败和后置失败分开报告
Section titled “方法二:把前置失败和后置失败分开报告”前置失败意味着副作用未发生;后置检查失败意味着副作用已经发生,只是验证不完整。验证问题:错误信息能否让调用者判断是否需要回滚?
方法三:容错定位必须配歧义闸门
Section titled “方法三:容错定位必须配歧义闸门”可以允许空白、缩进差异,但多候选时宁可失败也不静默猜测。验证问题:模糊匹配会不会把“不确定”伪装成“成功”?
11. 费曼复述与练习
Section titled “11. 费曼复述与练习”先合上源码,用 90 秒复述:
edit为什么在写文件前同时需要旧内容、目标新内容和 diff?external_directory与edit是哪两个不同维度的权限?- formatter 或 LSP 失败时,文件可能处于什么状态?
- 为什么“LSP 没报错”不等于“修改正确”?
如果答不清,按下面的梯子重走一遍:
- 定位题:在
edit.ts找到ctx.ask,确认它位于writeWithDirs之前。 - 解释题:说明
replaceAll=false时多候选为什么必须失败。 - 对比题:列出
edit与write的一个共同点和两个差异。 - 实现题:为 mini agent 写一个
planEdit(),只返回{ before, after, diff },暂不落盘。 - 故障题:让 formatter 抛错,设计一个结果结构,明确区分
writeSucceeded与verificationSucceeded。
12. 最后复盘:文件能改了,命令能直接跑吗?
Section titled “12. 最后复盘:文件能改了,命令能直接跑吗?”本章的最小闭环是:
读取事实 -> 计算唯一修改 -> 展示 diff 并授权 -> 落盘 -> 格式化 -> 诊断反馈你现在知道,coding agent 的文件能力不是“会调 fs.writeFile”,而是能让副作用保持可见、可拒绝、可追踪,并把后果重新放回推理上下文。
但文件工具的输入结构很受控;Shell 允许模型提交一整段命令,里面可能有管道、变量、重定向、外部路径和长时间进程。下一章的问题因此更难:在命令真正启动前,runtime 能从一段 shell 文本中判断出哪些风险?