跳到主内容
创建时间:最后更新:作者:白川

Codemode:让模型写脚本调工具

用一段沙箱里的 JavaScript 代替 N 次工具调用

上一篇讲了 MCP,结论是「MCP 工具默认不声明给模型」。这一篇讲那个声明之外的路径:模型怎么用到这些工具。

答案是一个工具:codemode。模型调用它的时候,参数里放的不是 JSON 参数,而是一整段 JavaScript 源码。这段源码在一个 QuickJS WASM 沙箱里执行,脚本内部可以调用 Pi 的其它工具(包括全部 MCP 工具),而只有脚本的输出回到模型。

先给结论,因为它是理解后面所有细节的钥匙:

Codemode 用一次工具调用换掉了 N 次工具调用。中间结果在沙箱里过,不进 LLM 上下文。

这句话有两个可验证的推论:

  1. 上下文成本从「N 个工具结果」降到「1 个脚本输出」。 脚本里读八个文件,八份全文只在沙箱里活过一瞬。
  2. 工具调用本身也不进上下文。 嵌套调用发生在 agent loop 的工具管线里,但它们的返回值被脚本截留,只有脚本决定输出的那部分被模型看到。

而这也回答了上一篇留下的问题:正因为 MCP 默认暴露是 codemode,它才需要一个 codemode 作为执行宿主。 9.5 节展开这个闭环。

9.1 用脚本代替 N 次工具调用

9.1.1 输入是源码,不是 JSON

codemode 工具的 schema 极简,只有一个字段(packages/coding-agent/src/extensions/codemode/tool.ts:78-82):

typescript
export const codemodeSchema = Type.Object({
	code: Type.String({
		description: "Raw JavaScript source.",
	}),
});

code 是原始 JavaScript 源码。三件事要明确:

  • 不是 JSON。 虽然工具调用的参数在协议上是 JSON,但这个字段的值是一段 JS 文本,模型要写的是 const x = await tools.read({...}) 而不是 {"code": "..."}。
  • 不是 markdown 围栏。 不要写 ```javascript 这种围栏,模型写了就会是语法错误。
  • 是一段函数体。 它被当作一个 async 函数体执行,所以顶层 await 和 return 都能直接用,不需要包一层 IIFE。

官方描述里的原话是 "run as an async function body in a QuickJS sandbox: top-level await and return work"。

因为输入是自由文本而不是受限的 JSON,Pi 还给它配了 grammar 约束采样(tool.ts:387):

typescript
constrainedSampling: { type: "grammar", variants: { openai_lark: CODEMODE_SOURCE_GRAMMAR } },

语法本身不限制 JavaScript,只固定选项行的形状(见 9.4 节)。

9.1.2 一个完整的例子

假设你要读八个源文件,统计每个文件的行数。用 read 调八次,八份全文全部进上下文。用 codemode:

javascript
const paths = [
  "src/a.ts", "src/b.ts", "src/c.ts", "src/d.ts",
  "src/e.ts", "src/f.ts", "src/g.ts", "src/h.ts",
];

const results = await Promise.allSettled(paths.map((path) => tools.read({ path })));

return results.map((result, index) =>
  result.status === "fulfilled"
    ? { path: paths[index], lines: result.value.split("\n").length }
    : { path: paths[index], error: result.reason.message },
);

模型看到的只有八个 { path, lines }。八份文件全文在沙箱里被读进来、切行数、然后丢掉。

这段脚本同时用到了三个 codemode 特性:

特性在这段代码里
并发
Promise.allSettled 一次发八个请求,不是八轮往返
过滤
每份全文被切成行数,全文丢弃
顶层 await / return
直接 await 直接 return,没有包装层

用 Promise.allSettled 而不是 Promise.all 是有讲究的:codemode 文档明确说,失败的、被阻断的、参数非法的调用会 reject,一个失败会让 Promise.all 整个挂掉,allSettled 则把成功的那些留下。

9.1.3 输出长什么样

codemode 的结果是一段文本,开头固定:

text
Script completed
Wall time 0.4 seconds
Output:

失败时开头是 Script failed,后面跟着已产生的部分输出,再跟 Script error: 和错误详情。

有个细节对写脚本的人很重要:失败的脚本不会回滚已经发生的工具调用。 源码里的错误信息原话是 "Tool calls made before the failure (they are not undone)"。也就是说,脚本里已经调过的 tools.write() 确实写进去了。

输出项之间有分隔规则:

情况格式
只有一个文本项
直接是文本
多个文本项(text() 或 return 产生)
每项前面加 ==> text N/M <==
有 console 调用
所有其它输出之后,跟一个 <console_output> 块,每行一次调用

图片是独立的输出项,会插在文本中间。

9.1.4 和「直接调工具」的成本对比

把上一篇的 exposure 讨论接上:

模型直接调 8 次 read模型调 1 次 codemode
模型发出的工具调用
8 次
1 次
模型看到的工具定义
read 一次
read + codemode 一次
中间结果进上下文
8 份全文
0
往返轮次
8 轮
1 轮(沙箱内并发)
模型能自己控制筛选逻辑吗
能,但要先付出全文的代价
能,且在数据进上下文之前筛

最后一行是关键差别。模型用 read 时,筛选发生在之后——数据已经进上下文了。用 codemode 时,筛选发生在之前。

这正是 MCP 默认 exposure 选 codemode 的理由(上一篇 8.4.3 节展开过)。

9.1.5 嵌套调用仍然受管控

codemode 脚本里的工具调用不是特权通道。它们走的是 agent loop 的工具管线(ctx.executeTool),所以:

  • 参数校验照常生效;
  • tool_call / tool_result 扩展钩子照常触发,包括你自己写的权限门禁;
  • 工具的 outputSchema 决定脚本拿到什么。

这一点很重要,因为它堵死了一条明显的绕过路径:不能指望「模型发起的调用」和「脚本发起的调用」走不同的检查。 一个 codemode 脚本能调的工具,你的权限扩展都能看见。

细节在 扩展开发 和 工具系统与信任边界。

9.2 沙箱边界

9.2.1 沙箱里有什么,没有什么

codemode 脚本跑在 QuickJS(编译成 WASM)里。它不是 Node 环境,没有 require,没有 process。

能力在脚本里
Node API(fs、path、child_process…)
没有
文件系统
没有
网络
没有
定时器(setTimeout、setInterval)
没有
Date、Math、JSON、Promise 等标准内建
有(但被冻结,见 9.2.3)
tools.*
有——这是唯一的工具出口
models.*
有——这是唯一的模型出口
store() / load()
有——跨 codemode 调用的小状态

官方描述的原话是 "No Node, file system, network, or timers",以及 "scripts reach the outside world only through tools and models"。

这个边界是整个设计的核心。 脚本不能自己读文件,但可以调 tools.read();不能自己发 HTTP,但可以调 MCP 工具。所有副作用都因此必然经过工具管线——这是沙箱和权限系统能对齐的前提。

没有定时器还有一个直接后果:一个永远不 settle 的 promise 会让脚本立刻失败,而不是挂到超时。源码里有个专门的 stalled() 检查:

The script is waiting on a promise that can never settle: no tool call is pending, and timers do not exist here.

因为沙箱里没有 I/O 也没有定时器,除了正在等待某个工具调用之外,没有任何东西能让脚本继续推进。所以「挂住」这件事在 codemode 里是可检测的,而不是靠超时兜底。

9.2.2 每次执行一个 worker

每次脚本执行都有自己的 worker 线程 + QuickJS VM(packages/codemode/src/runtime/host.ts:13):

Each execution gets its own worker thread and QuickJS VM. A fresh worker per run keeps …

worker 的存在有两个理由:

  1. 中断隔离。 中断标志放在 SharedArrayBuffer 里,由 VM 轮询。脚本超时或被中止时,宿主设标志,VM 在下一个轮询点抛出不可捕获的错误。
  2. Bun 兼容。 Bun 的 terminate() 不能停一个不轮询标志的线程,所以在 Bun 上 worker 可能比脚本结果活得更久。这一点在官方文档的 Limits 段落里明说了:

On Bun, stopping a script or reaching its deadline ends the call promptly, but a built-in that does not poll QuickJS's interrupt handler can keep computing in its worker until it returns.

换句话说:在 Bun 上,超时是「不再等结果」,不是「保证计算立刻停」。 如果脚本在跑一个不检查中断标志的内建(比如长时间的字符串操作),它可能继续算下去。

worker 数量是整个 Pi 进程共享的,上限 2 个(见 9.4.3 节)。这是 codemode 最硬的资源约束之一。

9.2.3 prelude 会冻结内建对象

沙箱不是裸的 QuickJS。脚本运行前,先有一段 prelude 在 VM 里执行(packages/codemode/src/runtime/prelude-source.ts)。它做两件安全相关的事:

第一,冻结所有内建对象。 prelude 遍历 globalThis 上所有可达的对象,把它们 Object.freeze 掉,并且把全局变量本身改成不可写、不可配置。

原因写在注释里:

The prelude shares them with the script, so a script that patched one (for example Array.prototype.toJSON) could otherwise corrupt what the prelude reports to the host.

也就是说,如果不冻结,一个脚本改了 Array.prototype.toJSON,就能污染 prelude 向宿主报告的内容——这是一个真实的逃逸路径,不只是「防止脚本搞乱自己」。

冻结的实现很细致:constructor、name、message、toString、toLocaleString、valueOf、toJSON 这几个常被覆盖的属性被特殊处理成「可覆盖的访问器」,这样 class MyError extends Error { constructor() { super(); this.name = "MyError" } } 这种正常写法不会因为原型上的只读属性而抛错。

第二,桥接对象藏进闭包。 prelude 把宿主桥接函数保存在闭包里,脚本拿不到引用。脚本只能通过 tools、models 这些被显式挂上去的入口触达外部。

9.2.4 脚本不能启动另一个脚本

codemode 工具自己的 exposure 是 model-only:

typescript
// packages/coding-agent/src/extensions/codemode/tool.ts:383
exposure: "model-only",

model-only 的语义是「只对模型可见,其它工具永远调不到它」(对照表在 扩展开发 里)。而 getCodemodeCallableTools() 还会再过滤一次:

typescript
export function getCodemodeCallableTools(tools: readonly AgentTool<any>[]): AgentTool<any>[] {
	return tools.filter((tool) => tool.name !== CODEMODE_TOOL_NAME);
}

所以脚本里既拿不到 tools.codemode,也没法递归。

9.3 全局对象

9.3.1 完整清单

脚本里能用的全局对象(packages/coding-agent/docs/codemode.md 的 Globals 表):

全局作用
tools.<name>(args)
调一个工具
text(value)
往输出里加一个文本项。字符串原样加,其它值按 JSON 加
image(value)
往输出里加一张图片
console.log(...)
往 <console_output> 块里加一行
return value
顶层 return,等价于 text(value)
exit()
成功结束脚本
store(key, value) / load(key)
跨 codemode 调用保存小状态
ALL_TOOLS
所有可调用工具的 { name, description } 列表
searchTools(query, { limit?, namespace? })
按相关度排序搜索工具
describeTool(name)
取一个工具的描述和 TypeScript 声明
describeNamespace(name)
取一个命名空间(MCP 服务器)的说明和工具名
models
列举并运行非 LLM 模型

9.3.2 调工具

tools 上的每个方法对应会话能调的一个工具,方法名是标识符形式:非法标识符字符变成 _。所以 MCP 工具 mcp__dev-radius__search 在脚本里是:

javascript
const result = await tools.mcp__dev_radius__search({ query: "codemode" });

每个方法接受一个对象,就是那个工具的参数。

返回值取决于工具:

工具类型脚本拿到什么
声明了 outputSchema 的工具
structuredContent(结构化值)
MCP 工具
完整的 CallToolResult:content / structuredContent / isError
read 读文本文件
文件文本(字符串)
read 读图片
图片块 { type: "image", data, mimeType, note }
其它工具(如 edit、write)
文本输出

有两个例外值得记住:

bash 的输出不受模型侧截断。 模型看到的 bash 输出被截到 2000 行或 50 KB(哪个先到算哪个),但脚本拿到的 output 最多 1 MiB。超出的部分保留头尾各 512 KiB,中间留省略标记,truncated 置位,完整内容在 full_output_path 里。

MCP 的 isError 结果在脚本里会 resolve,不会 reject。 同一个结果直接调用时是错误结果。所以脚本里要自己判断:

javascript
const result = await tools.mcp__github__create_issue({ repo: "acme/app", title: "Bug" });
if (result.isError) {
	return `failed: ${result.content?.[0]?.text ?? "unknown error"}`;
}
return result.structuredContent?.number ?? "created";

而失败的、被阻断的、参数非法的调用会 reject,错误对象带着工具的错误文本。用 Promise.allSettled 保住成功的那些。

9.3.3 找到没列出来的工具

这是 codemode 最有意思的部分。codemode 自己的工具描述里只列一部分工具,剩下的要脚本自己找。

先看 ALL_TOOLS——它是所有可调用工具的 { name, description } 列表,包括描述里没列的那些。

更常用的是 searchTools():

javascript
const hits = await searchTools("create issue in jira");

它用 BM25 对工具元数据排序(工具名、描述、参数 schema 里的描述和字段名、命名空间信息),默认返回 8 条(DEFAULT_TOOL_SEARCH_LIMIT)。可以加 limit 和 namespace:

javascript
const hits = await searchTools("search code", { namespace: "mcp__github", limit: 3 });

namespace 接受四种写法。以服务器 dev-radius 为例,mcp__dev-radius、mcp__dev_radius、dev-radius、dev_radius 都能查到同一个命名空间。

describeTool(name) 返回一个工具的描述和 TypeScript 声明,describeNamespace(name) 返回命名空间的信息:

javascript
const ns = await describeNamespace("mcp__github");
// { name: "mcp__github", description: "...", instructions: "...", tools: ["mcp__github__search_code", ...] }

服务器 instructions 不进任何工具描述,只有 describeNamespace() 能拿到。这是给 MCP 服务器的「用法说明」留的专属通道。

完整的一个「找工具再调用」:

javascript
const [hit] = await searchTools("create issue in jira");
if (!hit) return "no matching tool";
const result = await tools[hit.name]({ project: "PI", title: "Codemode works" });
return result.structuredContent?.key ?? result.content?.[0]?.text;

Pi 里有两个工具搜索入口,它们共用同一个 BM25 排序器和同一份工具元数据(createToolSearchDocument()),但搜完之后做的事完全不同:

脚本内的 searchTools()模型侧的 tool_search
谁调用
脚本
模型
搜的范围
所有可调用工具
还没被激活、且暴露是 codemode / deferred 的工具
搜完之后
脚本自己决定调不调、返回什么
命中的工具被声明给下一次模型调用
工具定义进上下文吗
不进
进(出现在下一次请求的 tools 数组里)
代价
零额外上下文
一次搜索 + 被加载工具的定义

这正是上一篇 8.4 节里 codemode 和 deferred 两种 exposure 的差别在实现上的落点:

  • codemode 暴露:模型用 tool_search 也能搜到它,但一旦加载,它的定义就进了上下文。想让定义永远不进上下文,就用 searchTools() 在脚本里搜——搜的动作发生在沙箱里,只有脚本挑出来的结果回到模型。
  • deferred 暴露:官方文档写明这两种工具「可以通过任一种间接机制触达」——脚本能调,tool_search 也能加载。

tool_search 这个工具本身的 exposure 是 model-only,也就是说脚本搜不到它。它是给模型用的入口,不是给脚本用的。

9.3.5 描述的预算

codemode 工具的描述里列出的工具声明共享一个预算:codemode.inlineBudget,默认 3000 个估算 token(按「字符数 / 4」估算,packages/coding-agent/src/core/settings-defaults.ts:57-60)。

typescript
codemode: {
	mode: "on",
	inlineBudget: 3000,
},

放不下的工具被省略(不是截断)。源码里的选取算法(tool.ts:203-220)是分组轮转的:每一轮,每个分组(先是没命名空间的工具,再是按名字排的命名空间)放一个它剩下最便宜的工具;某个分组放不下就整个退出,其它分组继续。这样每个命名空间都会先被代表到,而不是让第一个命名空间把预算吃光。

这个预算存在的理由和 exposure 一样:描述也是每轮都付的上下文税。

codemode.mode 决定 codemode 激活时其它工具怎么呈现:

mode行为
on(默认)
声明给模型的工具保持声明,它们的描述里补一句「怎么从脚本调用」;codemode 描述里只列非 direct 的可调用工具
only
direct 工具从模型请求里去掉,改在 codemode 描述里列出;模型只能通过脚本调它们

only 是一种更激进的策略:把所有工具调用都收进脚本。代价是模型失去了「直接调一个简单工具」的最短路径。

9.3.6 models:跑非 LLM 模型

models 是第二个(也是唯一一个)通往外部的出口。它让脚本能调用分类器和图像模型——这两类模型在 agent 循环里不是一等公民,但放进批量脚本里非常有用。

方法作用
models.getModelsOfType(type, provider?)
某类型的全部模型
models.getAvailableOfType(type, provider?)
其中凭据可用的那些
models.getModelOfType(type, provider, id)
取一个具体的
models.classify(model, context)
对 JSON 状态回答结构化问题
models.generateImages(model, context)
生成图片

type 是 "chat"、"image"、"classifier" 三者之一。聊天模型会被列出来但不能从脚本里跑——脚本已经在一个 agent 循环里了,再套一层 LLM 调用没有意义。

一个分类的完整例子:

javascript
const jev = await models.getModelOfType("classifier", "typesafe", "jev-latest");

const results = await Promise.all(
  messages.map((message) =>
    models.classify(jev, {
      state: { message },
      questions: {
        sentiment: {
          type: "choice",
          instructions: "How does the user feel about the product?",
          criteria: { positive: "Satisfied or happy", negative: "Unhappy or frustrated", neutral: "Neither" },
        },
        urgency: {
          type: "score",
          instructions: "How urgently does this need a reply?",
          criteria: ["no reply needed", "reply this week", "reply today"],
        },
      },
    }),
  ),
);

return results.map((result, i) =>
  result.stopReason === "stop"
    ? { message: messages[i], sentiment: result.answers.sentiment.choice, urgency: result.answers.urgency.score }
    : { message: messages[i], error: result.errorMessage },
);

三个要注意的点:

  1. classify() 和 generateImages() 不抛异常。 供应商出问题时它们返回 stopReason: "error" 和 errorMessage。所以必须检查 stopReason。
  2. 每个脚本最多 4 个模型调用并发(MAX_CONCURRENT_MODEL_CALLS)。超出的排队等待,所以 Promise.all 覆盖很多条目是安全的。
  3. 模型调用的用量会计入会话成本,并且在结果里显示出来。

生成图片时,图片块必须用 image() 显示:

javascript
// @options: {"timeout_ms": 300000}
const painter = await models.getModelOfType("image", "openrouter", "google/gemini-2.5-flash-image");
const result = await models.generateImages(painter, {
  input: [{ type: "text", text: "A red fox in the snow, watercolor" }],
});
if (result.stopReason !== "stop") return result.errorMessage;
for (const block of result.output) {
  if (block.type === "image") image(block);
  else text(block.text);
}

不要用 text() 打印图片的 data——那是 base64,非常大,而且模型读不懂。image() 会把图片存到临时文件,并在结果里给出路径。

9.3.7 store() / load()

store(key, value) 保存一个 JSON 值,load(key) 读回来。存 undefined 表示删除这个键。

写入只在脚本成功时生效。 每次成功的脚本会往会话里追加一条 codemode-store 自定义条目,所以恢复的会话保留这些值,而且每个分支只看到自己那条路径上写下的值。

这个设计让 codemode 脚本能跨调用保持状态——比如记住一个分页游标:

javascript
const cursor = load("issuesCursor") ?? 0;
const page = await tools.mcp__jira__list_issues({ startAt: cursor });
const next = page.structuredContent?.nextCursor;
if (next) store("issuesCursor", next);
return page.structuredContent?.issues?.map((i) => i.key) ?? [];

限制有两个,都是字符数(不是字节数):

限制值
单个值
262144 字符的 JSON
所有值合计
1048576 字符

超了抛 RangeError。不要往里存图片数据——用 image(),它自己会存临时文件。

9.4 选项行与硬性限制

9.4.1 选项行必须放第一行

脚本可以以一行选项开头:

javascript
// @options: {"max_output_tokens": 2000, "timeout_ms": 60000}

这一行必须是脚本的第一行。放在任何其它位置,调用直接失败——而且错误信息会告诉你它在哪一行。

源码在 packages/codemode/src/source.ts:107-115:

typescript
const misplaced = lines.findIndex(
	(line, index) => index > 0 && line.trimStart().startsWith(CODEMODE_OPTIONS_PREFIX),
);
if (misplaced !== -1) {
	throw new CodemodeSourceError(
		`The ${CODEMODE_OPTIONS_PREFIX} line must be the first line of the input; found one on line ${misplaced + 1}`,
	);
}

注意这个检查是按行前缀匹配的:只要某一行(第一行之后)以 // @options: 开头就报错——哪怕那行其实只是一句普通注释,不是想写的选项行。源码注释说明了为什么要这么严:

Throws … for an options line that is not the first line, which would otherwise be ignored without notice.

如果放错位置被静默忽略,模型会以为自己设了超时结果没有——那比直接失败糟糕得多。

只支持两个字段,多一个就报错:

字段含义默认
max_output_tokens
脚本输出的 token 预算
10000
timeout_ms
整个脚本的硬性截止时间(含工具调用)
未设置(无限)

timeout_ms 必须是正整数,上限是 setTimeout 支持的最大延迟(2147483647 毫秒)。

图像生成要小心。 官方文档明确提醒:生成一张图可能要几分钟,别给这类脚本设一个短截止时间。示例里的 // @options: {"timeout_ms": 300000} 就是干这个的。

9.4.2 输出预算怎么用

max_output_tokens 默认 10000,估算方式是「字符数 / 4」。超过预算时:

  • 文本的头尾各保留一半;
  • 中间用 …N tokens truncated… 标记;
  • 完整文本写到临时文件,路径在结果里([Full output: <path> (read with offset/limit)]);
  • 图片排在文本后面,不受影响。

所以 max_output_tokens 不是「截断脚本」,是「截断给模型看的东西」。脚本本身照常跑完,完整结果留在磁盘上。

要输出很多数据时,正确做法是让脚本写文件(调 tools.write()),而不是加大 max_output_tokens——那样会把内容原样塞进上下文。

9.4.3 硬限制一览

这几条是 codemode 的硬边界,不是「建议」:

限制值越界表现
沙箱 VM 堆内存
256 MiB
抛 InternalError: out of memory,脚本失败
worker 数量
每进程 2 个
新脚本立即失败,不启动新 worker;等已有 worker 退出后重试
输出字符数
16777216(文本 + base64 图像数据合计)
脚本失败
输出条目数
100000 次 text() / image() / console 调用
脚本失败
store() 单值
262144 字符 JSON
RangeError
store() 总量
1048576 字符
RangeError
模型调用并发
每脚本 4 个
排队等待,不失败

内存上限为什么是 256 MiB。 源码注释解释了:

Heap limit for the QuickJS VM. The worker shares pi's process, so without a limit a runaway script can grow to wasm32's 4 GiB and take the session down.

worker 和 Pi 进程共享地址空间。一个失控的脚本如果能涨到 wasm32 的 4 GiB 上限,整个会话就跟着完蛋。256 MiB 是一个「够跑正常脚本,又不会拖垮会话」的折中。

worker 上限为什么是 2 而不是更多。 上限是整个 Pi 进程共享的,不是每个会话 2 个:

At most two codemode workers may be alive across all sessions in one pi process. A cancelled worker occupies its slot until it exits.

注意「被取消的 worker 在退出之前仍然占着槽位」。所以脚本超时之后立刻再发一个脚本,有概率撞上限失败——重试就行,等前一个 worker 退出。

源码里还留了一个替换的口子:

typescript
// Shared across calls and sessions. Allow one replacement while a cancelled worker is still exiting.
const MAX_CONCURRENT_CODEMODE_WORKERS = 2;

这条注释解释了为什么上限是 2 而不是 1:留一个替换的余量,让被取消的 worker 还在退出的同时,新的脚本能占用另一个槽位。

输出限制为什么存在。 prelude 里的注释说得很清楚:

The host keeps all output until the script ends, so without a limit a script that prints in a loop grows the host's memory until it crashes.

宿主把所有输出留到脚本结束才发给模型。一个在循环里打印的脚本会撑爆宿主内存。条目数上限覆盖的是「打印空字符串的循环」——那种循环字符数不涨但条目数涨。

9.4.4 一条不明显的限制:不能用 setTimeout 绕过截止时间

因为沙箱里没有定时器,timeout_ms 也不可能被脚本自己延长时间。超时是从宿主侧算的,到点就中断。

但反过来也成立:脚本没法「等一会儿再继续」。 如果模型写了个 await new Promise(r => setTimeout(r, 1000)),setTimeout 根本不存在,脚本会立刻以 ReferenceError 失败。

想让脚本「过一会儿再做点什么」,唯一的办法是让某次工具调用花掉那段时间。

9.5 与 MCP 默认暴露的关系

这一节把上一篇和这一篇接起来。

9.5.1 一个闭环

上一篇 8.4 节的结论是:MCP 工具默认 exposure 是 codemode——不声明给模型,也不在 codemode 的描述里列出。

把这条展开成一个完整链条:

  1. MCP 工具默认不声明给模型(core/mcp-servers.ts:241,默认值 ?? "codemode")。
  2. codemode exposure 的语义是「工具注册了、脚本能调,但模型看不到定义」。
  3. 于是模型够得着这些工具的唯一路径,就是 codemode 脚本。
  4. 所以 MCP 集成必须有一个 codemode 执行宿主。
  5. 反过来,codemode 的价值也依赖这件事:如果工具都直接声明给模型(direct),codemode 就只剩「并发」这一个收益,「中间结果不进上下文」这个核心收益就没了。

所以这两件事不是「两个功能恰好能配合」,而是互相定义的:MCP 默认选 codemode 是因为有 codemode;codemode 的收益成立是因为 MCP 工具不在模型上下文里。

9.5.2 谁负责激活谁

这个闭环在代码里是这样接上的:

MCP 扩展负责激活 codemode。 在 packages/coding-agent/src/extensions/mcp/index.ts:501-536 的 ensureDiscoveryActive() 里:

typescript
const exposures = new Set<McpExposure>();
for (const server of servers) {
	if (isEnabled(server)) for (const exposure of configuredExposures(server.entry)) exposures.add(exposure);
}
const needsCodemode = exposures.has("codemode");
const needsToolSearch = exposures.has("deferred");
if (!needsCodemode && !needsToolSearch) return;

有 codemode 暴露的服务器 → 激活 codemode 工具;有 deferred 暴露的服务器 → 激活 tool_search 工具。

激活发生在读取配置时,不是等服务器连上——所以第一个 prompt 不用为它多等。

如果 codemode 不在,Pi 会警告一次:

MCP tools are only reachable from the codemode or tool_search tool, but neither is active; they cannot be called.

这条警告的触发条件是:有 codemode 或 deferred 暴露的服务器,但两个工具都没激活。你可以主动关掉自动激活:

json
{
  "autoEnableCodemode": false,
  "mcpServers": { "...": {} }
}

9.5.3 一个容易被忽略的耦合点

codemode 是可替换的内置扩展(replaceable: true)。你可以写一个同名工具接管它。

但这里有个耦合:只有声明了 reachesTools 的同名工具才能接 MCP 工具。 源码注释写得很直白:

typescript
// Only a tool of these names that declares `reachesTools` can reach MCP tools; another
// extension's tool of the same name without it is never activated.
const hasReachingTool = (name: string) => tools.some((tool) => tool.name === name && tool.reachesTools);

reachesTools 声明「我这次调用可能会用到哪些别的工具」,它是 MCP 扩展「懒注册」机制的基础:MCP 扩展在自己的 tool_call 处理器里检查 reachesTools,趁调用执行前把需要的服务器等好、工具注册好。

所以如果你要自己实现一个 codemode,你必须同时实现 reachesTools,否则 MCP 扩展不会用它接 MCP 工具。这个细节在 扩展开发 里展开。

9.5.4 那什么时候不用 codemode

codemode 不是 MCP 的唯一模式。上一篇给的选择表仍然成立:

场景建议 exposure
通用服务器,工具多、输出大
codemode(默认)
工具少(3~5 个)且高频用
direct
工具多但输出小,希望模型像调普通工具一样调
deferred(配合 tool_search)
只想屏蔽某几个工具
hidden + toolExposure

direct 的意思是这些工具不进 codemode 的沙箱,模型直接调,codemode 跟它们无关。

deferred 的意思是工具被 tool_search 加载后模型直接调——也不经过 codemode。只有 codemode 这一档需要沙箱。

9.5.5 回到上一篇那句旧论证

现在可以把这件事说完整了。

旧资料的论证是:「MCP 工具定义吃掉太多上下文」,推论是「所以 Pi 不内置 MCP」。推论错了,前提对了一半。

  • 「工具定义吃掉上下文」——对,这是真的。40 个工具的声明每轮都付。
  • 「不内置就能解决」——不对。不内置是把功能拿掉,不是把成本拿掉。用户还是要接 MCP。

Pi 的解法是引入 exposure 模型:工具存在和工具定义给模型看分成两件事。默认选 codemode 这一档,把「筛选和组合」放在沙箱里做,把「调用哪些、怎么筛」的判断权留给模型,同时不让工具定义本身成为每轮的固定税。

而这个解法能成立,靠的是 codemode 这个执行宿主——这就是两篇文章必须连着读的原因。

9.6 事实来源

本文所有断言都可在基线 v1.3.0-16-g8efd314fa 的仓库里复算:

断言来源
codemode 工具输入只有 code 一个字段
packages/coding-agent/src/extensions/codemode/tool.ts:78-82
输入是原始 JS,不是 JSON,不是 markdown 围栏
packages/coding-agent/docs/codemode.md「Scripts」段
作为 async 函数体执行,顶层 await / return 可用
同上;packages/coding-agent/src/extensions/codemode/tool.ts:118
grammar 约束采样
packages/coding-agent/src/extensions/codemode/tool.ts:387
沙箱无 Node / 文件系统 / 网络 / 定时器
packages/coding-agent/docs/codemode.md「Scripts」段
只能通过 tools 与 models 触达外部
同上
不 settle 的 promise 立即失败
packages/codemode/src/runtime/prelude-source.ts stalled();packages/coding-agent/docs/codemode.md Limits 段
每次执行一个 worker 线程 + QuickJS VM
packages/codemode/src/runtime/host.ts:13-18
Bun 上超时不保证计算立即停止
packages/coding-agent/docs/codemode.md Limits 段;packages/codemode/src/runtime/host.ts:16-18,61
prelude 冻结内建对象、防原型污染
packages/codemode/src/runtime/prelude-source.ts:30-33,58-139
宿主桥接藏进闭包
packages/codemode/src/runtime/prelude-source.ts:4-7
codemode 的 exposure 是 model-only,脚本不能启动脚本
packages/coding-agent/src/extensions/codemode/tool.ts:383;packages/coding-agent/src/extensions/codemode/tool.ts:159-161
全局对象清单
packages/coding-agent/docs/codemode.md「Globals」表
工具标识符:非法字符变 _
packages/codemode/src/identifier.ts
按 output schema 决定脚本拿到的值
packages/coding-agent/src/extensions/codemode/execute.ts:428-434
MCP 工具 resolve 到完整 CallToolResult
packages/coding-agent/docs/codemode.md「Call tools」段;packages/coding-agent/src/extensions/mcp/tools.ts:8-10
bash 脚本侧输出上限 1 MiB,保留头尾各 512 KiB
packages/coding-agent/docs/codemode.md「Call tools」段
失败的调用 reject,Promise.allSettled 可保住成功项
packages/coding-agent/docs/codemode.md「Call tools」段
searchTools 用 BM25,默认 limit 8
packages/coding-agent/src/extensions/tool-search/tool.ts:21,119-158
脚本的 searchTools 与模型的 tool_search 共用同一个排序器
packages/coding-agent/src/extensions/codemode/execute.ts:33,591-618;src/extensions/tool-search/tool.ts:108-158
tool_search 只搜未激活且暴露为 codemode / deferred 的工具
packages/coding-agent/src/extensions/tool-search/tool.ts:187-205
tool_search 的 exposure 是 model-only,脚本搜不到它
packages/coding-agent/src/extensions/tool-search/tool.ts:227
codemode 与 deferred 暴露的工具两种间接机制都能触达
packages/coding-agent/docs/mcp.md「Control tool exposure」段
describeNamespace 返回服务器说明和工具名
packages/coding-agent/src/extensions/codemode/execute.ts:634-655
codemode.inlineBudget 默认 3000 估算 token
packages/coding-agent/src/core/settings-defaults.ts:57-60
描述预算按字符 / 4 估算,分组轮转选取
packages/coding-agent/src/extensions/codemode/tool.ts:139,203-220
codemode.mode 默认 on,only 会隐藏 direct 工具
packages/coding-agent/src/core/settings-defaults.ts:58;packages/coding-agent/src/extensions/codemode/tool.ts:320-356
models 的方法集与 classify / generateImages 不抛异常
packages/coding-agent/docs/codemode.md「Models」段
每脚本最多 4 个模型调用并发
packages/coding-agent/src/extensions/codemode/execute.ts:50
store() / load() 跨调用,写入在脚本成功时生效
packages/coding-agent/src/extensions/codemode/tool.ts:42-47;execute.ts:540-547
store 是 codemode-store 自定义条目,按分支隔离
packages/coding-agent/src/extensions/codemode/tool.ts:41-42;execute.ts:240-251
store 单值 262144 字符、总量 1048576 字符
packages/codemode/src/runtime/prelude-source.ts:34-35
@options 行必须第一行,放错直接失败
packages/codemode/src/source.ts:107-115
只支持 max_output_tokens 与 timeout_ms
packages/codemode/src/source.ts:13-14,73-76
max_output_tokens 默认 10000
packages/coding-agent/src/extensions/codemode/execute.ts:254
timeout_ms 默认未设置
packages/coding-agent/src/extensions/codemode/execute.ts:510
输出超预算:保留头尾、完整文本存临时文件
packages/coding-agent/src/extensions/codemode/execute.ts:397-420
结果头 Script completed / Script failed 与 wall time
packages/coding-agent/src/extensions/codemode/execute.ts:565
==> text N/M <== 与 <console_output> 格式
packages/coding-agent/src/extensions/codemode/execute.ts:270-289
失败不回滚已发生的工具调用
packages/coding-agent/src/extensions/codemode/execute.ts:322-325;packages/coding-agent/docs/codemode.md「Scripts」段
沙箱 VM 堆上限 256 MiB,越界抛 InternalError: out of memory
packages/coding-agent/src/extensions/codemode/execute.ts:52-56;packages/coding-agent/docs/codemode.md Limits 段
每进程最多 2 个 worker,被取消的 worker 退出前仍占槽位
packages/coding-agent/src/extensions/codemode/execute.ts:57-59;packages/coding-agent/docs/codemode.md Limits 段
输出字符上限 16777216、条目上限 100000
packages/codemode/src/runtime/prelude-source.ts:42-43
输出上限存在的原因是宿主保留全部输出
packages/codemode/src/runtime/prelude-source.ts:38-41
image() 支持 PNG / JPEG / GIF / WebP,并存临时文件
packages/codemode/src/runtime/prelude-source.ts:396-429;packages/coding-agent/src/extensions/codemode/execute.ts:350-390
MCP 默认 exposure 是 codemode
packages/coding-agent/src/core/mcp-servers.ts:241;packages/coding-agent/src/extensions/mcp/index.ts:136
MCP 扩展按 exposure 激活 codemode / tool_search
packages/coding-agent/src/extensions/mcp/index.ts:501-536
激活发生在读取配置时,不等服务器连上
packages/coding-agent/src/extensions/mcp/index.ts:1130-1132
autoEnableCodemode 默认 true,可关闭
packages/coding-agent/src/extensions/mcp/config.ts:29,68
两者都不激活时的警告文案
packages/coding-agent/src/extensions/mcp/index.ts:532-535
同名工具必须声明 reachesTools 才会被用来接 MCP 工具
packages/coding-agent/src/extensions/mcp/index.ts:513-516
codemode 是可替换的内置扩展
packages/coding-agent/src/extensions/index.ts:11
reachesTools 的语义与 codemode 的实现
packages/coding-agent/docs/extensions.md:183;packages/coding-agent/src/extensions/codemode/tool.ts:359-369
嵌套调用走工具管线,钩子照常触发
packages/coding-agent/src/extensions/codemode/tool.ts:8-10

完整事实表(含每条断言的推导命令)见专栏仓库的 research/pi-agent/FACTS.md。

上一篇 8 内置 MCP 讲了 MCP 工具怎么接进来、exposure 怎么选;这一篇讲了那个默认档位的执行宿主。想理解工具暴露的通用模型,回 6 工具系统与信任边界;想自己写一个 codemode 或接管内置实现,回 10 扩展开发。