opencode 工具层拆解:实现与描述分家,注册表决定模型看见什么
本文基于 opencode 仓库 commit 7fe9938(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/anomalyco/opencode 最新代码与文档为准。
opencode 这个跑在终端里的开源编码 Agent,把「工具能干什么」和「怎么跟模型描述它能干什么」当成两件事分开维护——前者是 TypeScript 代码,后者是一份纯文本文件;而模型这一轮到底看见哪几个工具,既不是代码里写死的常量,也不是配置里读出来的静态列表,是注册表在每次请求时现算出来的。 你如果只盯着某个工具的 .ts 文件读,会一直找不到它给模型的那段说明;你如果只看文档里的内置工具清单,会解释不了为什么自己这台机器上 websearch 压根没出现。
这两个疑问的答案都在 packages/opencode/src/tool/ 这个目录里。
一、工具层要解决的问题:一轮请求该给模型看几把刀
编码 Agent 的循环本质很简单:把当前对话、系统提示词和一份工具清单交给模型,模型挑一个工具、给一组参数,宿主执行完把结果塞回对话,再来一轮。真正麻烦的是那份「工具清单」。
它不能是固定的。同一个二进制,可能今天接的是某家的模型、明天换另一家;可能跑在 CLI 里、也可能被别的客户端当服务用;用户可能在配置里把改文件的权限直接关掉;还可能自己往项目里丢了几个自定义工具。这些差异如果全靠工具自己在 execute 里判断「我该不该干活」,那就晚了——模型已经把它调出来了,token 已经烧掉了,用户还要看一句「此工具不可用」。
所以要在把清单交给模型之前就裁剪好。opencode 把这件事收拢在 packages/opencode/src/tool/registry.ts 一个文件里,对外只暴露四个方法:ids、all、named、tools。前三个是「有哪些工具」的静态视角,最后一个 tools 才是「这一轮给谁看什么」的动态视角,签名上要收 providerID、modelID、agent、permission 四样东西。
二、实现在 .ts,说明书在 .txt
先看单个工具长什么样。拿最短的 glob 举例,packages/opencode/src/tool/glob.ts 里第一件事是这样一行导入:
import DESCRIPTION from "./glob.txt"
然后整个工具就是一次 Tool.define 调用,返回一个对象:
export const GlobTool = Tool.define(
"glob",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service
return {
description: DESCRIPTION,
parameters: Parameters,
execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) =>
第一个参数 "glob" 是工具 ID,也就是模型看到的名字;description 直接指向那份文本文件;parameters 是一个 Schema 结构体,每个字段用 annotate({ description: ... }) 挂上字段级说明。
packages/opencode/src/tool/ 目录下现在有 25 个 .ts 和 15 个 .txt。.txt 的内容就是纯粹写给模型看的散文,比如 glob.txt 开头几行是:
- Fast file pattern matching tool that works with any codebase size
- Supports glob patterns like "**/*.js" or "src/**/*.ts"
- Returns matching file paths
- Use this tool when you need to find files by name patterns
这个拆法的好处很直接。工具描述是提示词工程,改它属于调模型行为,跟改执行逻辑完全是两种活儿、两种验证方式;分成两个文件之后,git diff 一眼就能看出这次改的是行为还是话术,评审时也不用在几百行 Effect 代码里翻那段被字符串拼接切碎的英文。反过来,字段级的说明仍然留在 .ts 的 Schema 注解里——因为它跟参数定义绑死,拆出去反而容易两边对不上。
不是每个工具都有 .txt。packages/opencode/src/tool/invalid.ts 的描述就是内联的一句 "Do not use",因为它本来就不该被调用。shell 工具更特殊:它的 ID 定义在 packages/opencode/src/tool/shell/id.ts 里,值是 "bash",描述在 packages/opencode/src/tool/shell/prompt.ts 里按平台拼装,那份 shell.txt 也放在同一个子目录下。
关于「一段工具描述该写成什么样」,站内另有一篇专门谈写法的 工具描述该怎么写才让模型用对。本篇不重复那些,只讲 opencode 把这段文字放在了哪、什么时候会被改写。
三、注册表启动时装配了两堆东西
registry.ts 里的状态结构只有四个字段:custom、builtin、task、read。后两个是被单独拎出来的引用,因为别处要直接用;真正的清单是前两个数组。
custom 那一堆是这么来的:注册表拿到配置里的目录列表,对每个目录跑一次 glob 扫描,模式是 {tool,tools}/*.{js,ts};扫到的每个文件按文件名取 namespace,用 pathToFileURL 转成 file:// 再动态 import(源码里的注释点明了这是为了让 Windows 上的 Node 能接受这个动态导入)。模块里每个导出,如果同时带 args、description、execute 三个字段就认作工具;默认导出用 namespace 当 ID,具名导出拼成 namespace_导出名。文档 packages/web/src/content/docs/custom-tools.mdx 里对应的说法是:文件名就是工具名,一个文件里多个导出会变成 <filename>_<exportname>,放在项目的 .opencode/tools/ 或用户级的 ~/.config/opencode/tools/ 下。插件提供的工具走同一条转换函数。
这条转换函数(fromPlugin)做的事值得单看一眼:插件对外仍然用 Zod 声明参数,注册表把它在边界上装箱——全是 Zod 类型就 z.object(args) 再转成 JSON Schema,否则退回一条把 entries 直接当 schema 定义拼的旧路径。代码注释里明确写了为什么要把缺失的 args 归一成 {}:早先写法在参数未定义时被静默容忍过,后来暴露成了线上问题。
builtin 那一堆是显式列出来的数组,顺序写死。里面有三处条件项,很值得记:
question工具只在客户端标识属于app、cli、desktop之一,或者显式打开了对应开关时才进列表;- LSP 工具挂在实验开关后面,文档
packages/web/src/content/docs/tools.mdx里给的对应环境变量是OPENCODE_EXPERIMENTAL_LSP_TOOL=true(或OPENCODE_EXPERIMENTAL=true); - plan 相关的那个工具要求实验开关打开并且客户端是
cli。
也就是说,同一份代码在桌面端和 CLI 里,内置工具的条数就已经不一样了。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 单个工具实现 | ID、参数 Schema、execute 的真实副作用 | packages/opencode/src/tool/glob.ts 等 25 个 .ts | 想改工具行为、加参数、调权限询问时 |
| 工具描述文本 | 交给模型的那段自然语言说明 | packages/opencode/src/tool/glob.txt 等 15 个 .txt | 模型老是用错某个工具、想调话术时 |
| 定义与包装层 | Tool.define / Tool.init、参数解码、输出截断、链路埋点 | packages/opencode/src/tool/tool.ts | 排查参数校验报错、排查输出被截断时 |
| 注册表 | 装配 builtin 与 custom、每轮裁剪并合成最终描述 | packages/opencode/src/tool/registry.ts | 某个工具「没出现」或「不该出现却出现了」时 |
| 会话侧接线 | 把注册表结果转成 AI SDK 的 tool 对象,再拼上 MCP 工具 | packages/opencode/src/session/tools.ts | 排查 MCP 工具、插件钩子触发时机时 |
| JSON Schema 归一 | 把参数 Schema 转成模型能吃的 JSON Schema 并做兼容修补 | packages/opencode/src/tool/json-schema.ts | 某家模型报 schema 不合法时 |
| 用户可见文档 | 内置工具清单与 permission 配置写法 | packages/web/src/content/docs/tools.mdx | 只是想关掉某个工具、不打算读源码时 |
四、每一轮的临场裁剪:三条硬分支加一个钩子
tools() 这个方法才是标题里那句话的落点。它对 all() 的结果连做两道过滤:第一道的判断条件有两组,第二道单独处理 code mode。三条规则加起来,每一条都是真在改模型看到的清单:
第一条,websearch 是否保留,交给同文件里导出的 webSearchEnabled 判断——条件是供应商 ID 等于该项目自家的那个,或者两个搜索相关的运行时开关之一被打开。文档里对应的说法是这个工具只在使用该项目自家 provider、或设置了 OPENCODE_ENABLE_EXA 环境变量时可用。
第二条最有意思,是按模型 ID 做的字符串判断:
const usePatch =
input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4")
if (tool.id === ApplyPatchTool.id) return usePatch
if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch
命中这条规则的模型看到的是 apply_patch 一把刀,看不到 edit 和 write;没命中的模型正好反过来。这是很硬的一条经验规则——不同模型家族对「改文件」这个动作的擅长形态不一样,与其在提示词里劝,不如干脆只给它一种改法。代价是这条判断写在代码里,模型 ID 一旦换命名风格就得跟着改,你自己接一个非主流命名的模型时也可能落进错误的分支。
第三条走的是单独那道过滤:code mode 那个工具只有当它对应的目录描述能生成出来时才留下——而目录描述又依赖当前可见的 MCP 工具集,一个都没有就直接返回空,工具随之消失。
过滤完还有两步合成。一是 task 工具的描述会被动态加长:注册表列出所有非 primary 模式的子 agent,逐个用权限规则评估、把 action 为 deny 的剔掉,剩下的按名字排序拼成 - 名字: 描述 的列表,再接在原描述后面。子 agent 没写描述时会兜一句「这个子 agent 只应由用户手动调用」。这意味着模型看到的 task 说明书是随你的 agent 配置变的。
二是插件钩子。每个工具在被交出去之前都会触发一次 tool.definition,带上 toolID,插件可以就地改 description、parameters、jsonSchema。这一步用 Effect.forEach 以 concurrency: "unbounded" 并发跑完。换句话说,工具描述有第三个来源:.txt 文件是底稿,注册表的动态拼接是第二层,插件改写是第三层。你排查「模型收到的描述跟文件里写的不一样」时,得按这个顺序找。
再往下一层,packages/opencode/src/session/tools.ts 把注册表的产物转成 AI SDK 的 tool 对象:参数经 ToolJsonSchema.fromTool 拿到 JSON Schema、再过一遍按模型做的 schema 变换;执行前后各触发一次 tool.execute.before / tool.execute.after 插件钩子。MCP 服务器提供的工具不走注册表,是在这里另起一段循环拼进同一张表的;若连接的 MCP 服务器声明了 resources 能力,还会额外挂上三个读取资源的工具。
工具执行本身的包装在 packages/opencode/src/tool/tool.ts:Tool.define 会把参数解码器提前编译一次(注释解释了原因——按调用现编闭包会给每次工具调用都多分配一个),解码失败抛的是 InvalidArgumentsError,它的 message 就是喂回给模型的那句话:告诉模型这次参数不合法、请按 schema 重写输入。执行成功后统一走截断,截断信息写进 metadata,超长时还会给出落盘路径。整条链路挂着名为 Tool.execute 的 span,属性里带工具名、会话 ID、消息 ID 和 call ID。
同一套「注册表决定这轮暴露什么」的思路,在别的项目里长得不太一样:pi 的工具层是怎么组织的 和 browser-use 的工具注册表 各有各的取舍,可以对照着看差异在哪一层。
五、边界与代价:这个设计明确不管什么
把描述抽成 .txt 是有代价的,不是纯赚。
描述与实现会漂移。 两个文件之间没有任何强制约束——你把 execute 的行为改了、忘了改 .txt,构建照过、测试可能也照过,只是模型开始按过时的说明书用这把刀。这类 bug 的表现是「模型莫名其妙用错工具」,很难归因。字段级说明留在 Schema 注解里之所以合理,就是因为那一层至少挨着参数定义。
描述是纯静态文本,不带条件分支。 需要按上下文变的部分(比如 task 要列出当前可用的子 agent)只能回到代码里拼字符串,或者交给插件在 tool.definition 里改。所以「描述都在 .txt 里」这句话本身就不完全成立,你得同时看三处。
按模型 ID 做子串匹配是权宜之计。 前面那段 usePatch 判断,胜在直接、败在脆。它不管模型真实能力如何,只认名字里的字符串;自建网关改过模型 ID、或者某个模型换了命名风格,分支就会走错,而错的表现是「这个模型突然不会改文件了」或者「一直用一种它不擅长的改法」。
注册表不管权限的最终执行。 它在裁剪时会用权限规则决定 task 描述里列哪些子 agent,但真正的「这次能不能干」是工具自己在 execute 里调 ctx.ask 问出来的,权限规则的匹配与显隐判断在权限模块里。规则里只有当一条规则的 pattern 是 * 且 action 是 deny 时,工具才会被整个藏起来;edit、write、apply_patch 三个共用 edit 这一个权限名,文档里也专门标注了这一点。你写 "write": "deny" 未必是你以为的效果。
自定义工具与插件是在你这台机器上直接跑的代码,注册表不做任何隔离。 扫描目录来自配置目录列表,其中包含从当前工作目录向上找到的 .opencode(除非用 OPENCODE_DISABLE_PROJECT_CONFIG 关掉项目级配置)。也就是说,你 clone 一个陌生仓库、它里面正好带着 .opencode/tools/ 目录,那些文件会在注册表初始化时被动态 import 执行,跟你手写的工具享有同样的权限——读文件、发网络请求、拉起子进程都不需要再问你一次。插件那条路更进一步:tool.definition 钩子可以改写任何工具交给模型的描述与参数 schema,被改过的说明书跟 .txt 里写的可以完全不是一回事。MCP 服务器同理,它提供的工具在会话侧被拼进同一张表,模型分不出哪个是内置、哪个来自第三方进程。所以对陌生来源的工具目录、插件和 MCP 服务器,该看的是它的源码和它能碰到什么,而不是它的 README 怎么写。
它也不管模型服务商侧的任何规则。 一次请求能带多少工具、描述占掉多少上下文、超了会怎样,各家规则不同且会调整,以官方最新说明为准。注册表只负责把清单算出来交出去。
最后一条,也是最该记住的:工具层管的是「给不给模型看」,不是「安不安全」。 这类终端 Agent 会在你的机器上真的执行 shell 命令、真的覆盖你的文件、真的把读到的代码内容发给模型服务商。工具在清单里,就意味着模型可以自主决定调用它。把权限放松成一律允许,等于把「误删一个目录」「把带密钥的配置文件读进对话再发出去」这两件事的概率交给了模型的判断力。真正的隔离要靠权限规则、工作目录约束和运行环境,参见 Agent 的最小权限设计。
六、上手与避坑清单
改了工具行为却忘了改描述。 会踩是因为两个文件不在一个 diff 视野里,改 .ts 时编辑器根本不会提醒你隔壁有个 .txt。怎么避:把「同名 .txt 是否需要跟着改」写进你自己的提交检查项;改参数含义时,顺手确认 Schema 注解里那句 description 还成立。
在 .txt 里找不到某个工具的描述。 会踩是因为不是所有工具都有 .txt——invalid 是内联字符串,shell 那把刀的描述在子目录里按平台拼装。怎么避:找描述先从 .ts 里的 description: 那一行反查,它指到哪就去哪。
排查「模型收到的描述不对」时只看文件。 会踩是因为描述有三层来源:文件底稿、注册表的动态拼接(task 会追加子 agent 列表)、插件在 tool.definition 钩子里的改写。怎么避:先确认有没有装插件改过这个 toolID,再看注册表有没有对这个工具做特判,最后才怀疑文件。
换了模型之后 Agent 突然不会改文件了。 会踩是因为 edit/write 与 apply_patch 是二选一的,选择依据是模型 ID 里的字符串。怎么避:换模型(尤其是走自建网关、模型 ID 被改写过)后,先确认这一轮暴露的到底是哪一组工具,再去怀疑提示词。
自定义工具没被加载。 会踩的点有几个:目录得是 tool 或 tools(配置目录下),扩展名得是 .js 或 .ts,导出对象必须同时具备 args、description、execute 三个字段才会被认出来,缺一个就被静默跳过。怎么避:先按文档确认放置位置,再确认导出结构完整;名字撞车也要留意——默认导出用文件名,具名导出会拼成 文件名_导出名。
以为在配置里 deny 掉就等于工具消失了。 会踩是因为只有 pattern 为 * 的 deny 规则才会让工具从清单里消失,而且 write 归 edit 这个权限名管。怎么避:想彻底不让模型看见,就用整体 deny;只想每次问一下,用 ask,别指望它顺带把工具藏起来。相关的权限收放尺度可以参考 Agent 权限给太大之后会发生什么。
MCP 工具和内置工具混在一起时名字冲突。 会踩是因为它们最终落在同一张表里,键名一冲突后者覆盖前者。怎么避:给 MCP 服务器起有区分度的名字,文档里也演示了用 "mymcp_*": "ask" 这类通配符统一管一整组。
收束:想自己验一遍,按这个顺序读
这套设计想清楚的其实是一件事——工具清单是每轮请求的一个计算结果,不是一份配置。理解到这一层,你排查「工具没出现」的思路就不再是翻配置文件,而是沿着计算过程往回走。
想自己把这条链走一遍,建议的读法是:先扫一眼 packages/opencode/src/tool/ 的目录列表,建立「.ts 与 .txt 成对」的直觉;再读 packages/opencode/src/tool/tool.ts 弄明白 Tool.define 包了哪几层;然后是 packages/opencode/src/tool/registry.ts,重点看 builtin 数组里那三个条件项和 tools() 里那两道过滤;最后到 packages/opencode/src/session/tools.ts,看清楚 MCP 工具是在哪一步才被拼进同一张表的。
读完之后给自己出一道题:在你当前这台机器、这个模型、这份配置下,模型这一轮到底看得见几个工具、少了哪几个、每一个是被上面哪条分支拿掉的。这道题能答完整,工具层这块就算过关了。
本篇属于一个把开源AI 编程 Agent 项目 opencode逐层拆开讲的系列,整体地图见 opencode 是什么:终端里的开源 AI 编程 Agent 全景地图;沿着这条线往下,还可以看 opencode 为什么备了 14 份系统提示词:开源终端编码 Agent 的提示词分家现实 和 开源终端 Agent opencode 怎么改你的代码:覆盖、替换、打补丁三条路径的失手点。