opencode 的 code mode 支线:让模型写程序串工具的代价
本文基于 opencode 仓库 commit 7fe9938(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/anomalyco/opencode 最新代码与文档为准。
**这条支线真正换掉的不是”调用方式”,而是”谁来做编排”:工具调用的循环从模型与 Agent 之间的多轮往返,搬进了一段由模型生成、在宿主进程内跑的受限程序里。**省下的是往返次数和上下文里的工具目录,付出的是一整套新的失败面——语言子集写错、权限批准的粒度变粗、中间结果不再逐条出现在对话里。
opencode 是一个跑在终端里的开源编码 Agent,采用 MIT 许可证(仓库根目录 LICENSE,Copyright 2025 opencode),单仓 packages/ 下有 32 个包。本文只讲其中一条支线:packages/codemode 这个独立包,以及它在主程序里的接入点。
站内已经写过协议层与工具层的对比——MCP 与 function calling 的分工讲的是”工具怎么被描述和调起来”,工具数量膨胀之后怎么办讲的是”目录太大时的通用治理思路”,大模型工具调用的基本机制讲的是”模型这一侧发生了什么”;本篇不重复这些,只钉在一个具体开源实现上,看它把编排权交给生成代码之后,代码、权限、诊断分别长成了什么样。
一、它想省掉的到底是哪一笔开销
packages/codemode/codemode.md 这份设计文档把目标写得很直白,一共四条:减少大工具目录占用的模型上下文;避免每两个有依赖关系的工具调用之间都插一次 Agent 往返;把体积大的中间结果留在程序内部而不是送进模型上下文;只给生成的代码宿主明确提供的那点权限。
把这四条翻译成你每天能感知到的场景:假设你接了几个 MCP server,一次任务需要先查一批条目、再对每个条目调一次详情、最后按某个字段筛出三条。传统路径下这是”一次调用一次往返”,条目有几十个就是几十轮,而且每一轮的原始返回都要过一遍模型上下文。code mode 的路径是模型写一段程序:循环、并发、筛选都在程序里完成,最后 return 你要的那三条。
代价的第一层也在这里就能看出来——中间那几十条原始返回,模型看不到了。它只看到程序的返回值。这既是省 token 的机制,也意味着”程序筛错了字段”这类问题不会在对话里自然暴露。
二、这条支线在仓库里的位置
先把地图摆出来,后面每一节都能对回具体文件。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| CodeMode 门面 | 定义 execute / make 两个入口、执行限额、结果与诊断的 schema | packages/codemode/src/codemode.ts | 想知道一次执行能配哪些参数时 |
| 工具运行时 | 工具树解析、目录生成、搜索索引、数据边界拷贝、调用记账与钩子 | packages/codemode/src/tool-runtime.ts | 排查工具找不到、输入输出被拒时 |
| 解释器 | 不用 eval 的树遍历执行器,含并发信号量、console 捕获、输出截断 | packages/codemode/src/interpreter/runtime.ts | 想确认某个语法到底支不支持时 |
| 工具定义 | Tool.make,接受 Effect Schema 或只用于渲染签名的 JSON Schema | packages/codemode/src/tool.ts | 自己往工具树里加东西时 |
| opencode 接入层 | 把连接上的 MCP 工具组装成工具树,暴露成一个名为 execute 的 Agent 工具 | packages/opencode/src/tool/code-mode.ts | 想知道权限、附件、取消怎么落地时 |
| 开关 | 读 OPENCODE_EXPERIMENTAL_CODE_MODE 这个环境变量 | packages/opencode/src/effect/runtime-flags.ts | 想打开或确认它是否生效时 |
| 工具注册 | 开关打开时才动态导入接入层,并把目录说明拼进 execute 的描述 | packages/opencode/src/tool/registry.ts | 好奇模型看到的描述里到底有什么时 |
| 会话工具装配 | 开关打开时提前返回,MCP 工具不再逐个注册给模型 | packages/opencode/src/session/tools.ts | 发现 MCP 工具”消失”了的时候 |
最后两行是这条支线最容易被忽略的地方。packages/opencode/src/session/tools.ts 里有一句提前返回:这个实验开关打开时,后面那段把每个 MCP 工具逐个转换、逐个注册成模型可见工具的循环根本不会执行。也就是说这不是”多了一个工具”,而是”MCP 工具的入口被换了”——模型手里只剩一个 execute。
packages/opencode/src/tool/registry.ts 里的做法同样是条件式的:只有开关打开才会去动态导入 ./code-mode;生成 execute 的描述时,会调用接入层的目录描述函数,如果当前 Agent 在权限规则集下一个 MCP 工具都看不到,描述返回空,execute 这个工具本身就被过滤掉不呈现。
三、模型写的那段程序,实际能写什么
接入层里这个工具的参数只有一个字段 code,字段说明是”Script body executed by the confined interpreter.”,工具自身的说明是”Run a confined orchestration script with access to connected MCP tools.”。措辞用的是 confined 而不是 sandbox,这个用词是准确的:它约束的是语言与可达工具,不是操作系统层面的进程隔离。
工具树按 MCP server 分组:接入层把每个工具的键名按已知 server 名切开,形成 server.tool 这样的两段路径,程序里就写成 tools.<server>.<tool>(...)。包自带的 README.md 里给的最小例子长这样:
const runtime = CodeMode.make({
tools: {
orders: {
lookup: lookupOrder,
},
},
})
const result =
yield *
runtime.execute(`
const order = await tools.orders.lookup({ id: "order_42" })
return { id: order.id, needsAttention: order.status !== "complete" }
`)
语言面被砍得很干净。按 README.md 的”Supported Programs”一节,支持的是数据字面量、解构、if/switch/各种循环、箭头函数与闭包、可选链、模板串、展开、try/catch,以及常见的数组、字符串、Object、Math、JSON 操作,另加 Date、RegExp、Map、Set、URL、URLSearchParams。不支持的一串同样明确:模块与 import、类、生成器、定时器、fetch、eval、原型访问、以及 promise 链式调用(.then/.catch/.finally)——异步只有 await 加 try/catch 这一种写法。落在语言子集之外的语法会返回 UnsupportedSyntax 诊断,可能带源码位置。
有两个内部常量不是可配置项,但会直接影响你看到的行为:同时最多 8 个工具调用在跑;跨数据边界的值最多嵌套 32 层,更深会变成 InvalidDataValue 诊断——文件里的注释说明这么做只是因为它比原生栈溢出报错更好读。
工具目录用的是渐进披露:所有命名空间永远列出来并带工具数,完整调用签名则按一个估算 token 预算(字符数除以 4,默认 2000)内联,选取方式是跨命名空间轮转——每一轮里,每个还有工具没内联的命名空间尝试放入它下一条最便宜的签名,放不下的那个命名空间出局、其余继续,所以不会出现一个大命名空间把预算吃光的情况。说明文本会写清楚这份列表是完整的还是部分的。没被内联的部分靠一个保留命名空间下的搜索工具找回来,宿主不允许自己占用这个保留命名空间,tool-runtime.ts 里有一处显式断言在拦这件事。
搜索的排序是确定性的加权求和:路径或路径末段精确匹配算 20 分,路径子串 8 分,描述子串 4 分,可搜索文本(含输入字段名与字段描述)2 分;查询词会额外生成朴素的去复数变体。这一点值得留意——它不是向量检索,是可预测的字符串打分,所以查询里带上工具名里的关键名词比写自然语言长句更有效。
四、边界与代价:它明确不管的事
README.md 里有一节标题就叫 Authority Boundary,把责任切得很硬:认证与授权、工具选择与不可变作用域、凭据与网络客户端、持久化与幂等与审批、日志与脱敏策略,这些都归宿主;解释、schema 边界、数据拷贝、资源限额与诊断归这个包。同一份文档的 Non-Goals 一节还补了几条明确不做的:通用的权限提示或审批流、可持久的暂停与恢复与重放、外部副作用的恰好一次、以及”给任意 JavaScript 提供文件系统或进程沙箱”。
这几句话拆开看,对你意味着这些事:
它不是安全边界的全部。 程序拿不到文件系统、进程、环境变量、网络与模块,但它能调用宿主放进工具树里的任何工具。如果你接的 MCP server 本身有写文件、跑命令、访问内网的能力,那这段生成的代码就有同样的能力。文档里那句”不要暴露一个宽泛的工具然后指望提示词去限制它”,是这条支线唯一诚实的说法。这块的通用判断可以对照权限给太大的后果一起看。
批准的粒度变了。 接入层里每一次嵌套的 MCP 调用仍然会走一次权限询问,权限键是这个 MCP 工具的注册名——但询问时带的匹配模式和”总是允许”的模式都是通配。也就是说,你一旦对某个工具点了”总是允许”,放行的是这个工具的全部调用,而不是眼前这一组参数。原本一次一问的节奏,在一段程序里可能连着触发好几次,而你审的是工具粒度,不是这一次的参数粒度。
外泄面没有缩小,只是换了形状。 这类工具本来就会把代码内容发给模型服务商;code mode 把中间结果留在程序里,确实少送了一些原始返回,但程序里 console.* 的输出会被解释器捕获成日志,最终拼到 execute 的输出后面回到模型上下文。生成的代码如果顺手打印了一整个配置对象或凭据字段,它就进上下文了。相关的边界划法参见 MCP 的安全边界。
限额在这个接入里没设。 包本身提供三个旋钮——超时、最大工具调用数、模型可见输出的最大字节数,且三个都没有默认值,README.md 说明这是刻意的:预算属于宿主策略。而 opencode 的接入层构造运行时时只传了工具树和两个观测钩子,没有传限额,依赖的是用户取消:一次执行与中断信号在赛跑,取消时返回一条执行已取消的结果。这带来的实际后果是,一段写坏了的程序不会自己撞到调用次数上限停下来,得你去按取消。
二进制内容不进解释器。 MCP 返回里的图片、音频、以及带二进制体的资源块,被宿主侧收集成附件挂在外层结果上;程序拿到的是结构化内容,或者是一句”有几个文件已附加到结果”的占位文本。想在程序里对图片本身做判断,这条路走不通。
不适用的场景也值得先想清楚。 只有一两个工具、每步都需要人看一眼再决定下一步、或者每次调用都有不可逆副作用需要逐次确认——这三类场景放进一段程序里跑,省下的往返远不如失去的可控性值钱。
五、上手与避坑清单
先确认开关和入口。 这条支线由 OPENCODE_EXPERIMENTAL_CODE_MODE 控制,读取位置在 packages/opencode/src/effect/runtime-flags.ts,同一份定义里它还挂在一个总的实验开关之下——总开关打开时,没有单独设值的实验项会跟着生效。会踩的坑是:你以为只打开了某一个实验特性,实际上一并打开了这一条。确认方法是看模型手里还有没有单独的 MCP 工具,只剩一个 execute 就说明它生效了。
别把 MCP 工具消失当成故障。 前面说过,开关打开时 packages/opencode/src/session/tools.ts 会提前返回,MCP 工具不再逐个注册。会踩的原因是这个变化没有报错、没有提示,看起来就像 MCP 连接坏了。避法是先去 execute 的工具描述里找目录——描述是注册时现拼的,包含当前权限规则集下可见的那份工具清单。
工具路径要照抄,不要顺手规范化。 生成给模型的指令里有一条硬规则,让它只用列出来或搜索返回的精确签名。会踩的原因是 MCP 工具名里常有连字符,模型容易自作主张改成驼峰;路径写错的结果是 UnknownTool 诊断。避法是按签名里给的形式写,非标识符的段用方括号加引号的形式访问。
别忘了 await。 一个没有 await 的工具调用是个 promise 值,它一旦要跨数据边界(进入最终返回值、或者作为另一个工具调用的参数)就会被拒绝,并且给出一条明确提示要求先 await。会踩的原因是这个子集不支持 promise 链式写法,习惯写 .then 的人容易漏掉 await 又没有链式调用兜底。避法是所有工具调用一律 const x = await ...,需要并发就整批交给 Promise.all。
不要指望日期和集合类型原样穿过边界。 在程序内部这些实例是活的,但一到宿主边界——最终结果、工具参数、JSON.stringify——序列化规则和 JSON.stringify 完全一致:日期与 URL 变字符串,正则、Map、Set、URLSearchParams 变成空对象。会踩的原因是程序里用 Map 做完聚合,直接 return 出来就成了 {},看起来像数据丢了。避法是返回前转成数组或普通对象。
审权限时按工具粒度想,不要按这一次想。 前面提过,“总是允许”放行的是整个工具。会踩的原因是这个选择在一段自动化程序里被触发时,你脑子里想的往往还是眼前这一次调用。避法是把带写入或外发能力的 MCP server 从这个 Agent 的权限规则集里摘出去,而不是靠每次弹窗时的临场判断。
读诊断的类别名,不要只读消息。 失败被设计成数据而不是异常,类别包括解析失败、语法不受支持、未知工具、工具输入或输出不合法、数据不合法、超出调用上限、超时、工具失败、执行失败。会踩的原因是这些类别在返回给模型时被拼成了一段文本,看起来都像”报错了”。避法是先认类别再看消息:语法类的要改写法,未知工具类的要改路径,工具失败类的问题在被调的那一端。
该从哪个文件继续读
要判断这条支线值不值得在你的场景里打开,有三件事可以自己核一遍:你的 MCP 工具是不是多到目录本身就在吃上下文;你的任务里有没有”一批条目逐个调详情再筛选”这种天然适合写循环的形状;以及你能不能接受权限批准从”一次一问”变成”一个工具一放行”。三条都成立,收益才明显。
继续读的顺序建议是:先看 packages/codemode/README.md,它把支持与不支持的语法、限额语义、诊断类别都列全了;再看 packages/codemode/codemode.md 的决策表,那里记的是每个取舍背后的理由;最后回到 packages/opencode/src/tool/code-mode.ts,这个文件不长,权限询问、插件钩子、附件收集、取消赛跑全在里面,是这条支线里唯一真正碰到你机器的那一段。
本篇属于一个把开源AI 编程 Agent 项目 opencode逐层拆开讲的系列,整体地图见 opencode 是什么:终端里的开源 AI 编程 Agent 全景地图;沿着这条线往下,还可以看 opencode 的 plan 模式在拦什么:模式切换本质是改工具可用面 和 终端编码 Agent opencode 的语言服务器集成:拉起、诊断回灌与失效表现。