Codemode:脚本怎样组合工具调用
拆解 codemode 脚本的沙箱边界、工具发现路径与结果裁剪机制,说明脚本失败为什么不会撤销已发生的外部副作用。
Codemode:脚本怎样组合工具调用
模型每次只能调一个工具。想读三个文件就得发三次调用,每次都是一次完整的模型往返;想对一批结果做过滤、聚合,中间数据会整块进上下文。工具多了之后,这种"一次一个"的节奏会让上下文迅速膨胀。
codemode 换了个做法。它让模型写一段 JavaScript,由脚本在沙箱里组合工具调用,最后只把脚本输出交回模型。嵌套的工具结果不进上下文,只有脚本的输出进。
这引出两个新的风险面:脚本在沙箱里跑,能力边界和直接调用不同;更关键的是——脚本失败不等于外部副作用被撤销。
这篇讲清楚一次 codemode 调用里工具声明、工具发现、脚本调用分别走哪条路径,写出可复用的编排脚本,并指出哪些副作用脚本失败也带不走。读它需要会写 JavaScript,以及对 Pi 的工具机制有基本印象。
Pi 迭代很快,动手前先记录 pi --version;本文依据 Pi 官方文档 与 官方仓库 编写。
一、codemode 改变了什么
差别不在"能不能调工具",而在谁来做编排。
要在 200 个文件里找所有引用某函数的文件,逐个调用 grep 就是 200 个 toolCall、200 份结果进上下文,模型会被撑爆;一次 codemode 调用,脚本并发跑 grep、只把匹配的文件名数组 return 出去,模型看到 20 行路径。
上下文里少了 200 份原始输出。
二、四条路径:声明、列举、发现、调用
工具进入模型视野有四条不同的路径,混在一起讲就会得出错误结论。
路径一:声明给模型。 codemode 激活后,codemode.mode 决定其它工具怎么呈现。on(默认)保持声明,描述里追加一句怎么从脚本调用,模型可以直接调也可以写脚本调;only 把声明从模型请求里隐去,模型只能写脚本调。
路径二:列在 codemode 描述里。 codemode 自己的描述会列出脚本可调用工具的 TypeScript 声明,按 namespace 分组。两个约束:列出的声明共享 codemode.inlineBudget 额度(默认 3000 个估算 token,字符数除以 4),放不下的工具被排除;deferred 曝光的工具不列,默认曝光为 codemode 的 MCP 工具属于这一类。
这解释了一个现象:MCP 服务器连上时 codemode 的描述不会变——那些工具本来就 deferred。
路径三:脚本运行时发现。 ALL_TOOLS 列出全部可调用工具的 { name, description },含没列在描述里的;searchTools(query, { limit?, namespace? }) 按 BM25 相关度排序(limit 默认 8);describeTool(name) 拿到工具的描述和 TypeScript 声明;describeNamespace(name) 拿到一个 namespace 的 { name, description?, instructions?, tools }。
namespace 查找很宽松:describeNamespace("mcp__dev-radius")、"mcp__dev_radius"、"dev-radius"、"dev_radius" 都命中同一个服务器。服务器说明文字只在 describeNamespace() 里读得到。
路径四:调用。 形态是 tools.<identifier>(args),identifier 规则是不能做 JavaScript 标识符的字符换成 _——mcp__dev-radius__search 在脚本里是 tools.mcp__dev_radius__search。返回值取决于工具类型:
bash 的 output 不受模型看到的那 2000 行 / 50KB 限制:最多装 1 MiB,更长时保留首尾各 512 KiB,truncated 为 true,完整内容在 full_output_path。
一个关键区别:工具曝光 vs 工具声明
MCP 服务器的 exposure 决定工具从哪条路径到达模型。默认的 codemode 曝光映射到工具层面的 deferred 曝光,意味着:
- 工具注册了(
getAllTools()里能看到) - 工具不声明给模型(
getActiveToolNames()里没有) - 工具不出现在 codemode 描述里
- 脚本通过
searchTools()/ALL_TOOLS/describeTool()找得到并能调用
所以"服务器连上了,模型还是调不到工具"往往不是故障,而是曝光策略在起作用。
三、并发、局部失败与结果裁剪
脚本是 async function 的函数体,顶层 await 和 return 都能用。
// 脚本是 async function 的函数体:顶层 await 和 return 都能用
const targets = ["api", "web", "worker"];
// 用 allSettled:单个失败不毁掉整批
// Promise.all 会在第一个拒绝时立刻抛出,后面成功的结果全丢
const settled = await Promise.allSettled(
targets.map((dir) => tools.grep({ pattern: "TODO", path: `src/${dir}` })),
);
// 成功和失败分开收集:失败原因也要 return 出去,模型才知道哪部分没覆盖
const hits = [];
const failures = [];
settled.forEach((outcome, i) => {
if (outcome.status === "fulfilled") hits.push({ dir: targets[i], output: outcome.value });
else failures.push({ dir: targets[i], error: String(outcome.reason) });
});
// 只有 return 的内容会交回模型:这里的原始 grep 输出还留在沙箱里
return { hits, failures };为什么用 Promise.allSettled() 而不是 Promise.all():官方文档写明失败的调用会 reject 并携带工具的错误文本,「Use Promise.allSettled() to keep the results of the calls that succeed.」Promise.all() 会在第一个拒绝时立刻抛出,后面的成功结果全丢。有失败可能的场景,allSettled 是默认选择。
三种失败源表现一样:调用拒绝,抛出的 Error 携带工具的错误文本——工具自己抛错、被 beforeToolCall 拦截、参数不合法。脚本没有 catch 就会终止,部分输出保留,结果以 Script failed 开头。
这三类都要能被模型看到,模型才知道下一步该改什么。
结果裁剪有三层,作用点不同。工具内部先裁:bash 存 1 MiB,MCP 文本结果超 20 KB 中间挖空。脚本输出再裁:max_output_tokens 默认 10000,超了保留首尾,完整文本写临时文件,路径在结果里。沙箱总量是硬上限:文本和 base64 图片数据超 16777216 字符,或 text()/image()/console 超 100000 次,脚本失败。
前两层的区别很关键:工具内部的裁剪发生在脚本拿到数据之前,脚本拿到的是完整结果;脚本输出的裁剪发生在交回结果之后。 裁剪策略该写在脚本里。
// @options: 行可以调 max_output_tokens 和 timeout_ms,但必须是第一行,放在别处这次调用会在脚本运行前就失败。timeout_ms 是整个脚本的硬期限,图片生成可能跑几分钟,别设短期限。
四、沙箱的能力边界
脚本跑在 QuickJS 沙箱里(编译成 WebAssembly),不是 Node 环境。
沙箱内存上限 256 MB,超了抛 InternalError: out of memory。别在脚本里累积大数组,过滤和聚合要在数据流入时就做。
两条限制值得记住:一个 pi 进程里最多两个 codemode worker 同时存活,跨所有会话共享。官方文档说明这个上限可用 PI_CODEMODE_MAX_WORKERS 调整,容量满时新脚本排队等空位,最多 60 秒(或到自己的 timeout_ms),否则失败且根本不启动。以及脚本不能启动另一个 codemode 脚本。
这段的细节随版本变化:较早的实现把上限硬编码为 2,超限时直接报错而不是排队。动手前用 pi --version 对照 官方 Codemode 文档 的 Limits 一节和 环境变量文档 里 PI_CODEMODE_MAX_WORKERS 的说明。
models 全局
脚本能访问模型目录,跑非 LLM 模型: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",chat 模型列得出来但不能跑。
两个容易踩的点:classify() 和 generateImages() 在 provider 报错时不抛异常,要检查 stopReason("stop" / "error" / "aborted")和 errorMessage;同一脚本里最多四个这类调用同时在跑,更多排队等空位。
这些调用的用量会计入 codemode 工具结果,也计入会话成本。 官方原话是「Their usage is added to the codemode tool result and counts toward the session cost.」
模型 ID 各家不一样,文档举的例子是 typesafe/jev-latest 和 openrouter/typesafe/jev-1.13。用 models.getAvailableOfType("classifier") 找当前凭据能用的 ID。
五、嵌套调用仍然走 Agent 管线
这决定了你能不能用权限扩展管住脚本里的调用。
脚本里的 tools.<name>(args) 最终落到 ctx.executeTool(name, args, { signal }),它经过参数校验、beforeToolCall / tool_call 钩子、工具执行、afterToolCall / tool_result 钩子。所以权限扩展对脚本里的调用一样生效。官方 MCP 文档确认:「Calls made from codemode scripts carry the codemode call ID as parentToolCallId.」
codemode 包的 README 说得更直接——直接调 tool.execute() 会绕开钩子:
Calling
tool.execute()directly skips the agent'sbeforeToolCallandafterToolCallhooks. To apply them to nested calls too, run each call throughrunToolCall()from@earendil-works/pi-agent-core.
runToolCall() 在 Agent Core 的事件循环 里,签名是 runToolCall(toolCall, options)。options 需要 tools、assistantMessage、context,可选 beforeToolCall、afterToolCall、signal、onUpdate。它永远不因工具失败而 reject:未知工具、校验错误、被拦截、抛出的错误都变成 isError: true 的结果。
自己把 MCP 工具包成 AgentTool 并直接调 tool.execute(),你的权限扩展就形同虚设。
六、外部副作用不会因为脚本失败而撤销
这是本篇最重要的一节。官方文档的原话:
Tool calls are real: calls made before a failure are not undone. Calls still running when the script ends are cancelled, and unawaited promises are discarded.
具体意味着:脚本先 tools.write(...) 写了三个文件、第四个抛错 → 前三个文件已经在磁盘上;脚本调了发邮件的 MCP 工具然后自己崩了 → 邮件已经发出去了。
沙箱隔离的是脚本本身能碰到的东西,它不隔离工具调用的副作用——那些工具跑在宿主进程里,用宿主进程的权限。所以脚本要做有副作用的事,就得让工具幂等,或让工具支持补偿操作,而不是指望"脚本失败了就当没发生"。
七、一个完整的编排示例
「先检索、再并行读取、最后只返回必要结果」的完整形态:
// @options: {"max_output_tokens": 2000, "timeout_ms": 60000}
// 这一行必须放在第一行:放到别处,这次调用会在脚本运行前就失败
// 第一步:检索。先用 grep 缩小范围,不直接读文件。
const search = await tools.grep({ pattern: "export function parse", path: "src" });
// 第二步:解析命中行,取出文件路径。
// 用 Set 去重:同一个文件可能有多处命中
const files = [...new Set(
String(search)
.split("\n")
.map((line) => line.match(/^([^:]+):/)?.[1])
.filter((p) => typeof p === "string")
)];
// 早退:没有命中就不要继续读了
if (files.length === 0) return { files: [], note: "no match" };
// 第三步:并行读取。用 allSettled,单个失败不影响整批。
const reads = await Promise.allSettled(files.map((path) => tools.read({ path })));
// 第四步:裁剪。只把结论交回模型,原始内容留在沙箱里。
const summaries = [];
const failed = [];
reads.forEach((outcome, i) => {
if (outcome.status === "fulfilled") {
const body = String(outcome.value);
// 每份只留 200 字符摘录:全文进上下文会把它撑爆
summaries.push({ path: files[i], lines: body.split("\n").length, excerpt: body.slice(0, 200) });
} else {
failed.push({ path: files[i], error: String(outcome.reason) });
}
});
return { files: summaries, failed };四个要点:allSettled 而不是 all;每个文件只回 200 字符摘录;失败的文件单独列出;// @options: 在第一行。
模型收到的是 { files: [{ path, lines, excerpt }...], failed: [...] }——结论,不是原文。
八、怎么启用 codemode
CLI 把 codemode 作为内置扩展加载,但它注册时是 inactive 的。pi --tools +codemode 只对单次运行生效;写进 ~/.pi/agent/settings.json 的 "defaultTools": ["+codemode"] 则永久生效。
注意 + 的语义。["+codemode"] 是追加,保留 read、bash、edit、write;写 ["codemode"] 是替换,会把默认工具全换掉。也可以让 MCP 扩展自动激活:有 codemode 曝光的服务器连上时启用。
SDK 会话不加载内置扩展,要显式装:
import {
createAgentSession,
createCodemodeExtension,
DefaultResourceLoader,
getAgentDir,
SessionManager,
SettingsManager,
} from "@earendil-works/pi-coding-agent";
const cwd = process.cwd();
// SDK 不加载内置扩展,要显式传工厂
const resourceLoader = new DefaultResourceLoader({
cwd,
agentDir: getAgentDir(),
extensionFactories: [
// mode: "on" 保持其它工具的声明状态;"only" 则全部收进脚本
createCodemodeExtension({ mode: "on" }),
],
});
// 必须自己 reload,否则扩展工厂不会被实例化
await resourceLoader.reload();
// applyOverrides 必须在 createAgentSession 之前调用:
// 会话创建时就用 defaultTools 算出了初始激活集合,之后再改不会回溯
const settingsManager = SettingsManager.create(cwd);
// "+codemode" 是追加语义,不会替换默认工具
settingsManager.applyOverrides({ defaultTools: ["+codemode"] });
const { session } = await createAgentSession({
resourceLoader,
settingsManager,
sessionManager: SessionManager.inMemory(),
});
try {
// 绑定扩展发出 session_start:扩展注册的工具这时才可被使用
await session.bindExtensions({});
// getActiveToolNames 是"声明给模型的工具",不是全部已注册工具
console.log("Active tools:", session.getActiveToolNames().join(", "));
const unsubscribe = session.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
try {
await session.prompt(
"用 codemode 在 src/ 里找所有导出 parse 函数的文件,并行读取后只告诉我文件路径和行数。",
);
} finally {
unsubscribe();
}
console.log();
} finally {
// 释放:中止活动工作、使扩展上下文失效、断开 agent、移除事件监听
session.dispose();
}你应该看到 getActiveToolNames() 里同时有 read、bash、edit、write 和 codemode。只有 codemode 说明 defaultTools 写成了替换语义。
九、官方已定义、源码事实与教学设计
你现在能做什么
- 写一个 codemode 脚本,在一个目录里并发
grep多个子目录,用Promise.allSettled()收集结果,只把命中文件名数组return出去。 - 用
describeNamespace("mcp__<服务器>")读一个 MCP 服务器的说明文字,确认它不出现在 codemode 描述里但能在脚本里找到。 - 把
defaultTools从["codemode"]改成["+codemode"],对比getActiveToolNames()的输出差异。 - 写一个脚本故意在三次
tools.write()之后抛错,然后检查磁盘——前三个文件确实写进去了,验证"脚本失败不撤销副作用"。 - 在脚本里调用
models.getAvailableOfType("classifier")列出当前凭据可用的分类模型 ID。 - 用
// @options:的max_output_tokens限制脚本输出,观察超限时首尾保留、中间写入临时文件。