文件工具:读、写、改

read 的分页、嗅探与嵌套指令文件,edit 的九级匹配级联,write 与 apply_patch 的写入流程,以及基于 ripgrep 的 glob 与 grep。

作者 David更新于 第 14 篇(共 38 篇)

文件类工具共用一副骨架:相对路径按实例目录解析 → external_directory 检查 → 用 ctx.ask 询问 read、edit、glob 或 grep 权限 → 干活。写类工具最后还要发布文件事件、跑格式化器、收集 LSP 诊断。

read:分页、嗅探与附件

几条硬上限写在文件顶部:

const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
const MAX_BYTES = 50 * 1024
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
const SAMPLE_BYTES = 4096

(packages/opencode/src/tool/read.ts:13)

参数只有 filePath、offset(从 1 开始的行号)和 limit。输出包在 <path>、<type>、<content> 标签里,每行加上“行号、冒号、空格”前缀;末尾给出续读指引,例如 (Showing lines 1-2000 of 5231. Use offset=2001 to continue.),撞上 50 KB 时则提示 Output capped at 50 KB。read 自己设置了 metadata.truncated,通用截断不会再截它。

  • 嗅探:先读前 4096 字节,按魔数识别 PNG、JPEG、GIF、BMP、PDF、WebP。JPEG、PNG、GIF、WebP 与 PDF 整个读出,作为 base64 附件返回;其余文件若扩展名在黑名单里(zip、exe、class、wasm、pyc 等),或样本中出现 NUL 字节、不可打印字符超过 30%,就报 Cannot read binary file。
  • 找不到文件:在同目录做一次宽松的文件名匹配,最多给出 3 个 Did you mean one of these? 候选。
  • 目录:同样能读,条目排序、子目录带 /,按 offset / limit 分页。
  • LSP 预热:读完文本后在后台 touchFile,失败不影响读取。

最容易被忽略的是嵌套指令文件。Instruction.resolve 会从被读文件所在目录向上走到项目根(不含根目录),沿途寻找 AGENTS.md(及 CLAUDE.md、已弃用的 CONTEXT.md),把尚未进入系统提示、此前也没加载过的指令文件追加到输出末尾的 <system-reminder> 里。子目录的约定因此是“读到才加载”,不必一开始就塞进系统提示(见系统提示词与指令文件)。

读权限的模式是相对路径,默认规则对 *.env、*.env.* 询问、对 *.env.example 放行,见权限系统。

edit:九级匹配级联

参数是 filePath、oldString、newString、replaceAll。真正替换之前有三道处理:

  • oldString 为空只用于新建文件,文件已存在时报错,要求改用 write;
  • 同一路径的编辑经进程内信号量串行(edit.ts 里的 locks),防止并行调用互相覆盖;
  • 文件含 CRLF 时,先把 oldString、newString 转成 CRLF 再匹配;UTF-8 BOM 读时剥离、写回时恢复。

核心是 replace()。文件头注释写明这些策略参考了 Cline 的 diff-apply 评测代码和 Gemini CLI 的 editCorrector.ts。九个 Replacer 依次上阵:

顺序Replacer匹配方式
1SimpleReplacer原样查找
2LineTrimmedReplacer逐行比较去掉首尾空白后的内容
3BlockAnchorReplacer3 行以上时以首尾行为锚,中间行按 Levenshtein 相似度(阈值 0.65)择优
4WhitespaceNormalizedReplacer连续空白压成一个空格后比较
5IndentationFlexibleReplacer去掉公共缩进后比较
6EscapeNormalizedReplacer把 \n、\t、引号等转义还原后比较
7TrimmedBoundaryReplacer去掉整段首尾空白后比较
8ContextAwareReplacer首尾行锚定、行数相同、中间非空行至少一半一致
9MultiOccurrenceReplacer枚举全部精确出现,配合 replaceAll

每个 Replacer 产出候选片段,循环这样消费它们(删减后):

for (const search of replacer(content, oldString)) {
  const index = content.indexOf(search)
  if (index === -1) continue
  notFound = false
  if (isDisproportionateMatch(search, oldString)) {
    // ...
  }
  if (replaceAll) {
    return content.replaceAll(search, newString)
  }
  const lastIndex = content.lastIndexOf(search)
  if (index !== lastIndex) continue
  return content.substring(0, index) + newString + content.substring(index + search.length)
}

(packages/opencode/src/tool/edit.ts:705)

候选必须真在原文里;非 replaceAll 模式还要求它唯一,否则换下一个候选、下一级策略。全部失败时区分两种错误:从没找到报 Could not find oldString in the file...,找到过但都不唯一报 Found multiple matches for oldString...。

isDisproportionateMatch 是模糊匹配的保险:匹配片段的行数达到“oldString 行数加 3”与“两倍行数”中的较大者,或者(多行时)字符数比 oldString 多出 500 以上且超过 4 倍,就拒绝替换、要求模型重读文件。宽松匹配省掉了大量“空白不一致”导致的失败重试,这道闸则防止锚点匹配一口吞掉半个文件。

写入之后:格式化器若改动了文件,按原 BOM 状态回写;发布 FileSystem.Event.Edited 与 Watcher.Event.Updated;ctx.metadata 推送 diff 和增删行数;最后只报告本文件的错误级 LSP 诊断,每个文件最多 20 条。权限询问发生在写入前,元数据里带着完整 diff,TUI 的授权框据此渲染差异视图。

write:整文件覆盖

write 同样先算 diff 供授权,BOM 取“旧文件有或新内容有”。它与 edit 的区别在诊断范围:除本文件外,还附上最多 5 个其他文件的 LSP 错误(MAX_PROJECT_DIAGNOSTICS_FILES)。

write.txt 与 edit.txt 都写着“没先用 Read 读过文件就会失败”,但 v1.18.30 的 write.ts、edit.ts 中没有对应检查,这句话目前只起引导作用。

apply_patch:给 GPT 系列的补丁格式

上一篇提到,GPT 系列模型拿到的是 apply_patch 而不是 edit 与 write。补丁是面向文件的精简 diff(节选自 packages/opencode/src/tool/apply_patch.txt:18):

*** Begin Patch
*** Add File: hello.txt
+Hello world
*** Update File: src/app.py
*** Move to: src/main.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
*** Delete File: obsolete.txt
*** End Patch

解析在 packages/opencode/src/patch/index.ts,类型注释称与“Rust 实现”对齐。解析器相当宽容,外面包了 cat <<'EOF' 一类 heredoc 也能剥掉。执行分两段:

  1. 校验:逐个 hunk 计算新内容和 diff。Update 先用 @@ 后的上下文行定位,再用 seekSequence 寻找旧行,匹配逐级放宽:精确、忽略行尾空白、忽略首尾空白、把弯引号、各种破折号、省略号和不间断空格归一为 ASCII。任何一处失败都报 apply_patch verification failed,此时还没有写任何文件。
  2. 应用:一次 ask 同时询问全部文件的 edit 权限,元数据里带每个文件的 diff;通过后逐个写入、移动或删除,再格式化、发事件、收集诊断,输出以 A、M、D 开头的文件清单。

glob 与 grep:交给 ripgrep

两者都调用 core 的 Ripgrep 服务(packages/core/src/ripgrep.ts),结果上限都是 100 条:

globgrep
rg 参数--files 加 --glob--json --hidden --no-messages
隐藏文件不搜搜
排除**/.git/**同左
输出绝对路径列表按文件分组的 Line N: 文本,单行超过 2000 字符截断
权限模式glob 模式正则

两份说明都把“开放式、需要多轮搜索”的任务引向 task 工具,让子代理去探索;grep.txt 还叮嘱统计匹配数时用 Shell 直接跑 rg。

写类工具末尾的格式化与诊断由 Format、LSP 两个服务提供,配置与内置清单见 LSP 与格式化器。实验开关 OPENCODE_EXPERIMENTAL_LSP_TOOL 还会放出 lsp 工具,提供跳转定义、查找引用、hover、调用层级等 9 种操作。

下一篇:Shell 工具——bash 与 PowerShell 的选择、超时与输出截断,以及用 tree-sitter 解析命令做权限判定。

本页目录