Codemode:让模型写脚本调工具
用一段沙箱里的 JavaScript 代替 N 次工具调用
上一篇讲了 MCP,结论是「MCP 工具默认不声明给模型」。这一篇讲那个声明之外的路径:模型怎么用到这些工具。
答案是一个工具:codemode。模型调用它的时候,参数里放的不是 JSON 参数,而是一整段 JavaScript 源码。这段源码在一个 QuickJS WASM 沙箱里执行,脚本内部可以调用 Pi 的其它工具(包括全部 MCP 工具),而只有脚本的输出回到模型。
先给结论,因为它是理解后面所有细节的钥匙:
Codemode 用一次工具调用换掉了 N 次工具调用。中间结果在沙箱里过,不进 LLM 上下文。
这句话有两个可验证的推论:
- 上下文成本从「N 个工具结果」降到「1 个脚本输出」。 脚本里读八个文件,八份全文只在沙箱里活过一瞬。
- 工具调用本身也不进上下文。 嵌套调用发生在 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):
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):
constrainedSampling: { type: "grammar", variants: { openai_lark: CODEMODE_SOURCE_GRAMMAR } },语法本身不限制 JavaScript,只固定选项行的形状(见 9.4 节)。
9.1.2 一个完整的例子
假设你要读八个源文件,统计每个文件的行数。用 read 调八次,八份全文全部进上下文。用 codemode:
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 而不是 Promise.all 是有讲究的:codemode 文档明确说,失败的、被阻断的、参数非法的调用会 reject,一个失败会让 Promise.all 整个挂掉,allSettled 则把成功的那些留下。
9.1.3 输出长什么样
codemode 的结果是一段文本,开头固定:
Script completed
Wall time 0.4 seconds
Output:失败时开头是 Script failed,后面跟着已产生的部分输出,再跟 Script error: 和错误详情。
有个细节对写脚本的人很重要:失败的脚本不会回滚已经发生的工具调用。 源码里的错误信息原话是 "Tool calls made before the failure (they are not undone)"。也就是说,脚本里已经调过的 tools.write() 确实写进去了。
输出项之间有分隔规则:
图片是独立的输出项,会插在文本中间。
9.1.4 和「直接调工具」的成本对比
把上一篇的 exposure 讨论接上:
最后一行是关键差别。模型用 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。
官方描述的原话是 "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 的存在有两个理由:
- 中断隔离。 中断标志放在
SharedArrayBuffer里,由 VM 轮询。脚本超时或被中止时,宿主设标志,VM 在下一个轮询点抛出不可捕获的错误。 - 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:
// packages/coding-agent/src/extensions/codemode/tool.ts:383
exposure: "model-only",model-only 的语义是「只对模型可见,其它工具永远调不到它」(对照表在 扩展开发 里)。而 getCodemodeCallableTools() 还会再过滤一次:
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 表):
9.3.2 调工具
tools 上的每个方法对应会话能调的一个工具,方法名是标识符形式:非法标识符字符变成 _。所以 MCP 工具 mcp__dev-radius__search 在脚本里是:
const result = await tools.mcp__dev_radius__search({ query: "codemode" });每个方法接受一个对象,就是那个工具的参数。
返回值取决于工具:
有两个例外值得记住:
bash 的输出不受模型侧截断。 模型看到的 bash 输出被截到 2000 行或 50 KB(哪个先到算哪个),但脚本拿到的 output 最多 1 MiB。超出的部分保留头尾各 512 KiB,中间留省略标记,truncated 置位,完整内容在 full_output_path 里。
MCP 的 isError 结果在脚本里会 resolve,不会 reject。 同一个结果直接调用时是错误结果。所以脚本里要自己判断:
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():
const hits = await searchTools("create issue in jira");它用 BM25 对工具元数据排序(工具名、描述、参数 schema 里的描述和字段名、命名空间信息),默认返回 8 条(DEFAULT_TOOL_SEARCH_LIMIT)。可以加 limit 和 namespace:
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) 返回命名空间的信息:
const ns = await describeNamespace("mcp__github");
// { name: "mcp__github", description: "...", instructions: "...", tools: ["mcp__github__search_code", ...] }服务器 instructions 不进任何工具描述,只有 describeNamespace() 能拿到。这是给 MCP 服务器的「用法说明」留的专属通道。
完整的一个「找工具再调用」:
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;9.3.4 两种发现方式:脚本内的 searchTools() 与模型侧的 tool_search
Pi 里有两个工具搜索入口,它们共用同一个 BM25 排序器和同一份工具元数据(createToolSearchDocument()),但搜完之后做的事完全不同:
这正是上一篇 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)。
codemode: {
mode: "on",
inlineBudget: 3000,
},放不下的工具被省略(不是截断)。源码里的选取算法(tool.ts:203-220)是分组轮转的:每一轮,每个分组(先是没命名空间的工具,再是按名字排的命名空间)放一个它剩下最便宜的工具;某个分组放不下就整个退出,其它分组继续。这样每个命名空间都会先被代表到,而不是让第一个命名空间把预算吃光。
这个预算存在的理由和 exposure 一样:描述也是每轮都付的上下文税。
codemode.mode 决定 codemode 激活时其它工具怎么呈现:
only 是一种更激进的策略:把所有工具调用都收进脚本。代价是模型失去了「直接调一个简单工具」的最短路径。
9.3.6 models:跑非 LLM 模型
models 是第二个(也是唯一一个)通往外部的出口。它让脚本能调用分类器和图像模型——这两类模型在 agent 循环里不是一等公民,但放进批量脚本里非常有用。
type 是 "chat"、"image"、"classifier" 三者之一。聊天模型会被列出来但不能从脚本里跑——脚本已经在一个 agent 循环里了,再套一层 LLM 调用没有意义。
一个分类的完整例子:
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 },
);三个要注意的点:
classify()和generateImages()不抛异常。 供应商出问题时它们返回stopReason: "error"和errorMessage。所以必须检查stopReason。- 每个脚本最多 4 个模型调用并发(
MAX_CONCURRENT_MODEL_CALLS)。超出的排队等待,所以Promise.all覆盖很多条目是安全的。 - 模型调用的用量会计入会话成本,并且在结果里显示出来。
生成图片时,图片块必须用 image() 显示:
// @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 脚本能跨调用保持状态——比如记住一个分页游标:
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) ?? [];限制有两个,都是字符数(不是字节数):
超了抛 RangeError。不要往里存图片数据——用 image(),它自己会存临时文件。
9.4 选项行与硬性限制
9.4.1 选项行必须放第一行
脚本可以以一行选项开头:
// @options: {"max_output_tokens": 2000, "timeout_ms": 60000}这一行必须是脚本的第一行。放在任何其它位置,调用直接失败——而且错误信息会告诉你它在哪一行。
源码在 packages/codemode/src/source.ts:107-115:
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.
如果放错位置被静默忽略,模型会以为自己设了超时结果没有——那比直接失败糟糕得多。
只支持两个字段,多一个就报错:
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 的硬边界,不是「建议」:
内存上限为什么是 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 退出。
源码里还留了一个替换的口子:
// 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 的描述里列出。
把这条展开成一个完整链条:
- MCP 工具默认不声明给模型(
core/mcp-servers.ts:241,默认值?? "codemode")。 codemodeexposure 的语义是「工具注册了、脚本能调,但模型看不到定义」。- 于是模型够得着这些工具的唯一路径,就是 codemode 脚本。
- 所以 MCP 集成必须有一个 codemode 执行宿主。
- 反过来,codemode 的价值也依赖这件事:如果工具都直接声明给模型(
direct),codemode 就只剩「并发」这一个收益,「中间结果不进上下文」这个核心收益就没了。
所以这两件事不是「两个功能恰好能配合」,而是互相定义的:MCP 默认选 codemode 是因为有 codemode;codemode 的收益成立是因为 MCP 工具不在模型上下文里。
9.5.2 谁负责激活谁
这个闭环在代码里是这样接上的:
MCP 扩展负责激活 codemode。 在 packages/coding-agent/src/extensions/mcp/index.ts:501-536 的 ensureDiscoveryActive() 里:
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 暴露的服务器,但两个工具都没激活。你可以主动关掉自动激活:
{
"autoEnableCodemode": false,
"mcpServers": { "...": {} }
}9.5.3 一个容易被忽略的耦合点
codemode 是可替换的内置扩展(replaceable: true)。你可以写一个同名工具接管它。
但这里有个耦合:只有声明了 reachesTools 的同名工具才能接 MCP 工具。 源码注释写得很直白:
// 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 的唯一模式。上一篇给的选择表仍然成立:
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 的仓库里复算:
完整事实表(含每条断言的推导命令)见专栏仓库的 research/pi-agent/FACTS.md。
上一篇 8 内置 MCP 讲了 MCP 工具怎么接进来、exposure 怎么选;这一篇讲了那个默认档位的执行宿主。想理解工具暴露的通用模型,回 6 工具系统与信任边界;想自己写一个 codemode 或接管内置实现,回 10 扩展开发。