开源编码 Agent opencode 找代码三件套:读取、按名找、按内容找

2026-08-04

本文基于 opencode 仓库 commit 7fe9938(2026-08-04)梳理,具体行为以仓库 https://github.com/anomalyco/opencode 最新代码与文档为准。

先做个消歧:这里说的 opencode 不是泛指「开源代码」,也不是哪个同名模型,而是 GitHub 上 anomalyco/opencode 这个仓库里的 AI 编码 Agent,README 里的自我介绍就一句「The open source AI coding agent」,采用 MIT 许可证。仓库里既有终端 TUI 包,也有桌面端包,本文只看它内部的检索工具怎么写。

Agent 找代码烧的不是搜索本身的算力,而是搜索结果原封不动灌回对话里的那部分 token。 opencode 把这件事拆成了三个独立工具:read 按行取内容、glob 按文件名模式找路径、grep 按正则搜内容。三个工具解决的是三类不同的「我不知道代码在哪」,而它们真正花心思的地方不在怎么搜,在返回值怎么裁。

站内另外几篇讲的是别的层面:工具返回值该怎么设计 讲的是通用原则,上下文预算怎么分配 讲的是全局账本,混合检索 讲的是向量加关键词那条路线。这篇不重复它们,只做一件事:把 opencode 仓库里这三个工具的源码摊开,看这条不给代码建索引、把检索全押在 ripgrep 上的路线是怎么落地的。

一、三个工具各解决哪一类「不知道」

你让 Agent 改一个 bug,它面对的信息缺口通常是三种之一:路径已经贴给它了,只差把内容拿进来;知道文件大概叫什么,但不知道确切路径;只知道代码里有某个函数名或某段字符串,完全不知道在哪个文件。opencode 没有把这三种缺口塞进一个万能搜索工具,而是拆成三个入口,各自的参数表就写清了边界。

组成部分它负责什么对应仓库位置你什么时候会碰到它
read 工具按行读文件、也能列目录,带 offset / limitpackages/opencode/src/tool/read.ts路径已知,要把内容拿进对话
glob 工具按文件名模式返回路径清单packages/opencode/src/tool/glob.ts只知道文件长什么样
grep 工具按正则搜内容,返回路径加行号加整行文本packages/opencode/src/tool/grep.ts只知道代码里有某个符号
工具描述文本喂给模型看的那段工具说明packages/opencode/src/tool/grep.txtglob.txtread.txt想调模型的检索取向
ripgrep 适配层拼命令行参数、解析 JSON 行、按 limit 截流packages/core/src/ripgrep.ts排查「搜不到」或「搜太多」
越界目录闸门目标落在工作目录外时发起权限询问packages/opencode/src/tool/external-directory.ts读仓库外的文件被拦下来
通用截断兜底工具没自己声明截断时统一裁输出packages/opencode/src/tool/truncate.tstool.ts别的工具输出过长

这张表里最容易被忽略的是第四行。工具描述是纯文本文件,grep.ts 里一句 import DESCRIPTION from "./grep.txt" 就把它挂上去了。模型看到的不是源码,是这段文本。grep.txt 里写着一句很实在的分流建议:如果要统计匹配数量,去用 Bash 直接跑 rg,不要用这个工具;如果是开放式搜索、可能要反复 glob 加 grep 好几轮,去用 Task 工具。也就是说,检索策略的一部分是写在提示词里、由模型自己执行的,不全在代码里。

glob.txt 还额外交代了一句:可以在一次回复里发多个工具调用,与其一次一次试,不如把几个可能有用的搜索一起投出去。这是把并发决策也下放给模型。

二、按名找:glob 只回路径,一个字符的内容都不带

glob 的参数只有两个:pattern 必填,path 选填。path 的描述里专门写了一句提醒,说不需要这个参数就直接省略,不要填 undefinednull 这种字符串——这是被模型的实际行为逼出来的防御。

拿到参数之后的流程是:先发权限询问,再解析目录,然后交给 ripgrep。有个细节值得留意,如果解析出来的路径指向的是一个文件而不是目录,它直接抛错:

if (info?.type === "File") {
  throw new Error(`glob path must be a directory: ${search}`)
}

不做「那我就搜它所在的目录吧」这种自作主张的兜底。错了就报错,让模型自己纠正,这比猜一个用户没要求的行为要干净。

真正的检索是拼一条 ripgrep 命令。在 packages/core/src/ripgrep.ts 里,glob 走的是 --files 模式,也就是只列文件不搜内容,另外固定加了一条 --glob=!**/.git/** 把版本库内部目录排掉。

返回值的裁剪逻辑很直白:limit 写死 100,输出就是一行一个绝对路径,没有文件大小、没有修改时间、没有内容预览。命中数量等于 100 时判定为被截断,末尾追加一句提示,让模型换更具体的路径或模式再来一次。

const limit = 100
const files = yield* ripgrep.glob({ cwd: search, pattern: params.pattern, limit })
const truncated = files.length === limit

这个设计的取舍在于:glob 的输出永远是可预测的小体量。100 条路径撑死几千个 token,模型看完能立刻决定下一步读哪个。代价是你没法用它做「找出最近改过的文件」这类需求,那些元数据它一概不给。

顺带一个能省掉半小时排查的事实:适配层的 glob 分支只有在调用方传了 hidden 时才加 --hidden,而 glob.ts 没传。所以隐藏文件默认不出现在 glob 结果里。

三、按内容找:grep 的三段裁剪

grep 的参数是三个:pattern 必填,pathinclude 选填,include 用来限定文件类型,描述里给的例子是 "*.js""*.{ts,tsx}"

export const Parameters = Schema.Struct({
  pattern: Schema.String.annotate({ description: "The regex pattern to search for in file contents" }),
  path: Schema.optional(Schema.String).annotate({
    description: "The directory to search in. Defaults to the current working directory.",
  }),
  include: Schema.optional(Schema.String).annotate({
    description: 'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
  }),
})

这里有个和 glob 不同的处理:如果 path 指向的是文件而不是目录,grep 不报错,而是取它的父目录当 cwd,同时在拼接结果绝对路径时也按文件的父目录来解析。行为上更宽容一点。

裁剪发生在三个层次,逐层收窄。

最底下一层在 ripgrep 适配层。命令行是这样拼的:

args: [
  "--no-config",
  "--json",
  "--hidden",
  "--no-messages",
  ...(input.include ? [`--glob=${input.include}`] : []),
  "--glob=!**/.git/**",
  "--",
  input.pattern,
  input.file ?? ".",
]

--json 让 ripgrep 按行吐 JSON 记录,适配层逐行解析。单条 JSON 记录超过 64 KB 直接判失败,避免一行超长内容把内存打爆;每条匹配的 submatches 最多保留 100 个;匹配行文本超过 2000 字符就截到 2000 再补三个点。这三道限制都在进入上层之前完成,上层拿到的已经是修剪过的结构。

中间一层是条数。适配层用 Stream.take(limit + 1) 多取一条,靠「实际取到的条数是否超过 limit」来判断有没有被截断,然后把多出来那条丢掉。这个小技巧的价值是不用扫完全部结果就能知道结果被裁了。

最上一层是渲染成给模型看的文本。limit 同样是 100,输出头一行是 Found N matches,命中被截断时补一句 more matches available;正文按文件分组,文件路径单独一行,下面每条匹配写成 Line 42: 内容。命中为零时不返回空字符串,返回 No files found 这个明确的字面量,同时 metadata 里 matches 记 0。给模型一个确定的「没找到」信号,比给它一段空白要好得多。

值得对照的是,grep 分支在命令行里是固定带 --hidden 的。隐藏文件在 grep 里搜得到,在 glob 里默认列不到,两个工具在这一点上并不对称。这个不对称有直接的安全后果,后面会讲。

四、读取:read 的四道闸门与三种特殊文件

read 是三个里最厚的一个,因为它面对的输入形态最杂。参数是 filePathoffsetlimit,后两个是非负整数。源码里留了一段注释,说明后两个参数原本会做运行时的字符串转数字,后来去掉了——模型走工具调用路径发的是有类型的 JSON,转换没意义,改完模型看到的 JSON Schema 完全没变,只是从命令行调用时必须传真数字。这种把「谁受影响」写清楚的注释,比改动本身更值得抄。

常量集中在文件开头:

const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
const MAX_BYTES = 50 * 1024
const SAMPLE_BYTES = 4096

四道闸门对应下来是这样。行数闸门:默认最多 2000 行,可以用 limit 调。单行闸门:任何一行超过 2000 字符就截断,并在行尾补一句说明被截到了多少字符——这一条专门治压缩过的 JS 文件和塞了大段 base64 的配置文件,没有它,一行就能把整个上下文吃掉。字节闸门:累计输出超过 50 KB 就停,读取过程中实时累加每行的 UTF-8 字节数,一旦超了就停掉上游的文件流。范围闸门:offset 超过文件总行数时直接报错,把文件实际有多少行一并告诉模型。这道闸门留了一个例外,空文件配上默认的 offset 不算越界,照常走正常返回,模型拿到的是一句「文件结束、共 0 行」而不是一条错误——空文件和读越界是两回事,混成同一个错会让模型误以为路径写错了。

三种收尾提示各不相同,这是给模型的导航信号:撞字节上限时说明输出被限制在 50 KB、显示了哪一段、用哪个 offset 继续;只是行数没读完时给出已显示区间和总行数;读完整个文件时明确说文件结束、共多少行。第三种最关键,模型看到它就知道不用再翻了。

特殊文件有三种走法。目录:列出条目,子目录名后面补斜杠,按本地化规则排序,超出部分提示用 offset 继续。图片和 PDF:先读 4096 字节做嗅探判定 MIME,命中则把整个文件读进来转成 base64 的 data URL,作为附件返回,正文只留一句读取成功。二进制:先按扩展名匹配一批常见格式,再看采样字节里有没有 NUL、不可打印字符占比是否超过三成,判定是二进制就直接失败,不往上传。

还有两个容易被漏掉的行为。一是读不到文件时不是简单报错,而是去父目录列一遍,找出名字互相包含的候选,最多三个,拼成「你是不是想找这些」一起返回——把一次失败变成一次有效信息投喂。二是读成功之后会异步碰一下 LSP 的 touchFile 做预热,而且这个调用是忽略失败并 fork 出去的,注释里写明了理由:预热是可选的,不能让后台的问题拖垮一次本来成功的读取。

五、边界与代价:这套设计明确不管什么

先说它放弃了什么。

没有语义检索。三个工具全部建立在字面匹配上,正则和 glob 模式都要求你或者模型能说出准确的字符串。你问「处理登录失败的逻辑在哪」,这套工具答不了,得先由模型自己把问题翻译成一个具体的符号名再来搜。这是和向量检索路线的根本分岔,不是优劣问题,是不同的成本结构:这条路不需要建索引、不需要嵌入模型、不需要在文件改动后重算,代价是把「问题到关键词」这一步完全押在模型身上。

没有跨轮次的结果记忆。每次调用都是独立的一次 ripgrep 进程,上一次搜过什么、命中了哪些文件,工具层不留痕。去重和收敛靠对话历史本身。

没有相关性排序。100 条上限是按 ripgrep 吐出的顺序截的,不是按「哪条更可能是你要的」截的。所以当你搜一个在仓库里出现上千次的通用词,拿到的 100 条大概率是无效的前 100 条,工具只会礼貌地提示你换个更具体的模式。

再说它明确不管的事。

grepglob 的返回值里,metadata 都自己写了 truncated 字段。这不是巧合——在通用工具定义层 tool.ts 里有这么一段:

if (result.metadata.truncated !== undefined) {
  return result
}

工具只要自己声明了截断状态,就跳过统一的截断兜底。也就是说,这三个检索工具的输出长度由它们自己全权负责,配置里那套通用的输出上限对它们不生效。你要调它们的 limit,改配置是没用的。

安全边界这块必须说透。这类工具跑在你的机器上,读到的内容会原样发给模型服务商。grep 固定带 --hidden,排除的只有版本库内部目录,所以放在仓库里的隐藏配置文件是能被搜到并把命中行整行回传的。一次「搜一下 token 相关的代码」,就有可能把密钥的字面值带进对话记录。工作目录之外的路径会触发一次单独的权限询问,但询问一旦按「以后都允许」放行,授予的是那个父目录下的通配模式,不是单个文件。权限模型的通用讨论可以看 Agent 权限该给多大,这里只强调一点:检索工具的权限比编辑工具更容易被顺手放开,因为它感觉上「只是读」,而外泄面恰恰主要在读这一侧。

六、上手与避坑清单

别把 grep 当计数器用。 会踩是因为「搜一下有多少处引用」是个太自然的需求,而工具看起来正好能干。但它的 limit 是硬编码的 100,返回的 matches 只是本次拿到的条数,不是仓库里的真实总数,你据此判断「只有 87 处,可以放心重命名」就会漏改。避法:需要准确计数时按 grep.txt 自己给的建议,用命令行直接跑 ripgrep 拿数字,检索工具只用来定位。要留意的是这条建议等于把动作换到了执行 shell 的那条链路上,那是另一套风险:命令由模型拼、在你的机器上真跑,参数拼错就可能读到不该读的路径,甚至误触带副作用的命令。把计数这种只读动作交出去时,也该跟看待改文件一样看它的权限范围,而不是因为「只是查个数」就一路放行。

看到截断提示别直接翻页,先收窄模式。 会踩是因为习惯了分页思维,觉得再来一次就能拿到第 101 条。但这两个工具没有 offset 参数,你没法翻页,重复调用只会拿到一模一样的前 100 条,白烧一轮 token。避法:截断提示里那句「换更具体的路径或模式」是唯一出路,加 include 限定文件类型,或者把 path 收到子目录。

隐藏文件的搜索行为在两个工具之间不一致。 会踩是因为你用 glob 列了一遍确认没有敏感文件,就以为 grep 也搜不到。实际是 grep 那条命令行固定带 --hidden。避法:判断外泄面时以 grep 的行为为准,真正不想被读到的东西不要留在工作目录里,靠工具的默认值兜底是靠不住的。

读大文件时不要连着发小切片。 会踩是因为担心一次读太多爆上下文,于是三十行三十行地要。read.txt 里有一条专门劝阻这种用法,原话是别做「三十行一块」的反复小切片、需要更多上下文就一次读一个大点的窗口,但没往下解释为什么。按工具调用的实际成本推,理由不难猜:每次调用都要重新付一遍参数、返回头和模型重新组织回复的开销,切太碎反而更贵。避法:一次给一个大一点的窗口,靠 50 KB 字节闸门自动兜住上限,然后看收尾提示决定还要不要继续。

别指望配置能调这三个工具的输出上限。 会踩是因为项目里确实有一套通用的工具输出截断配置,看起来应该全局生效。但如前所述,自己声明了 truncated 的工具会跳过那套兜底。避法:想改就得改代码,或者接受 100 条这个默认值,转而在提示词层面引导模型写更精确的模式。

结尾:三个自检问题

把这套设计搬回你自己的 Agent 之前,先回答三个问题。第一,你的检索工具在返回值上有没有一个硬上限,而不是「一般不会太多」?没有的话,第一次撞上超大仓库就会把上下文打穿。第二,命中为零和调用失败,在返回文本上是不是两个能区分的信号?模型分不清这两者时的典型表现,就是反复用同一个模式重试。第三,工具被截断时有没有告诉模型下一步该怎么做?只说「结果太多」不给出路,等于把模型留在原地打转。

想继续往下读代码,顺序建议是:先看 packages/opencode/src/tool/grep.ts,它最短且三层结构最完整;再跳到 packages/core/src/ripgrep.ts 看命令行怎么拼、JSON 怎么解析;最后回到 packages/opencode/src/tool/read.ts 看四道闸门,那段按字节实时累加并主动停掉上游流的写法最值得抄。工具描述那三份 txt 也别跳过,检索策略有相当一部分写在自然语言里、由模型执行,只看 TypeScript 会漏掉。

这套「工具自带上限、由模型多轮收窄」的取向,和框架层面怎么切分工具边界是同一个话题的两面,可以对照 Agent 工具怎么设计 一起看。项目采用 MIT 许可证,仓库地址是 https://github.com/anomalyco/opencodepackages/ 下有 32 个包,光工具目录里就有 25 个 .ts 和 15 个 .txt,本文拆的只是其中三对。

本篇属于一个把开源AI 编程 Agent 项目 opencode逐层拆开讲的系列,整体地图见 opencode 是什么:终端里的开源 AI 编程 Agent 全景地图;沿着这条线往下,还可以看 开源终端 Agent opencode 怎么改你的代码:覆盖、替换、打补丁三条路径的失手点开源项目 opencode 的 shell 工具:命令怎么解析、哪些会被权限层拦下

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。