Codemode 补遗:把多步工具工作交给脚本
小A让 Agent 调了六个工具:先搜文件,再读两个结果,查一次 issue,最后把四段文本拼成摘要。每次调用都要模型重新决定下一步,工具返回的长文本也一股脑进入上下文。小A看着越来越长的对话,问老Z:“能不能让模型先写一小段程序,把这些工具调用编排完,再只把结论交回来?”
“Pi 的 Codemode 就是这种接口。”老Z说,“模型把 JavaScript 作为一个工具调用提交。脚本能串联、并行和筛选其他工具,只有脚本输出进入模型上下文。先把边界说清:脚本失败不会撤销已经发生的工具副作用,QuickJS 的能力限制也不等于操作系统级安全沙箱。”
本附录服务源码篇和实现篇:读源码篇第8章工具与扩展时,或想让 Agent 批量组合工具调用时来查。依据上游 pi 标签 v1.0.0(提交 a13d35a742c6ef8462812a28fbe1d8c8b7431c32)。Pi 集成侧看 packages/coding-agent/src/extensions/codemode/ 与 core/nested-tool-calls.ts;独立沙箱包看 packages/codemode。这里解释脚本模型、嵌套调用、发现、状态、模型工具与失败边界,不把它扩展成通用工作流引擎。
它改变的是工具调用的编排位置
普通工具调用大致是“模型提出一个工具调用 → 宿主执行 → 结果回到模型”。Codemode 把多个内部步骤收进一段脚本:
模型提交 JavaScript
↓
QuickJS 执行脚本
├─ tools.read(...) ──┐
├─ tools.grep(...) ──┼─ 结果回到脚本,由脚本筛选或合并
└─ tools.bash(...) ──┘
↓
text(...) / return value
↓
只有整理后的输出回到模型“这不是模型绕过工具,而是把多步控制流放到了脚本里。”老Z说,“模型仍要决定写什么脚本,宿主仍负责实际执行工具。区别在于:中间结果可以先由确定性代码处理,不必每一步都回模型再决定下一步。”
一个适合这个模式的例子是并行读取若干独立文件,再只返回包含某个标记的行:
const paths = ["src/a.ts", "src/b.ts", "src/c.ts"];
const results = await Promise.allSettled(
paths.map((path) => tools.read({ path })),
);
for (let index = 0; index < results.length; index++) {
const result = results[index];
if (result.status === "fulfilled") {
const matches = result.value.split("\n").filter((line) => line.includes("TODO"));
if (matches.length > 0) text(`${paths[index]}\n${matches.join("\n")}`);
} else {
text(`${paths[index]}: ${result.reason}`);
}
}Promise.allSettled() 让一个读取失败时,其他结果仍能被处理。这里的脚本只把匹配行送回模型;文件内容只在脚本执行过程中可用。筛选逻辑是普通 JavaScript,不需要让模型再做一次文本筛选。
“‘并行’要说准确。”老Z提醒,“多个调用是否同时跑,取决于会话的工具执行模式:全局顺序模式,或某个工具声明了 executionMode: "sequential",嵌套调用就串行;否则才并发。脚本写法不变,实际是否交错由宿主决定。”
QuickJS 限制了脚本能直接碰到的东西
@earendil-works/pi-codemode 在 Worker 线程里运行 QuickJS WebAssembly VM。脚本拿不到 Node API、文件系统、网络请求、process 或定时器;它能触达宿主的路径,是 Pi 注入的 tools 与 models 等函数。宿主负责超时、中止和 Worker 生命周期;每次执行都创建新的 VM,避免一次脚本状态泄漏到下一次执行。沙箱(worker、QuickJS wasm 模块)首次调用时才加载,不在启动时。
脚本输入是原始 JavaScript 函数体,因此顶层 await 和 return 可用。它可以用 text()、image() 或 console.* 累积输出,exit() 立即成功结束,也可以直接 return 一个值。工具名会转换成 JavaScript 标识符:例如 MCP 工具 mcp__dev-radius__search 可以通过 tools.mcp__dev_radius__search(...) 调用。可选的首行 // @options: {...} 声明输出预算与期限;解析失败抛 CodemodeSourceError,选项行会被替换成空行,让栈回溯行号继续对上脚本。
两个与直觉相反的运行时规则:脚本结束时仍未 await 的 Promise 被丢弃;运行中的调用会收到取消请求,但不响应 signal 的工具副作用仍可能继续。因为 VM 没有定时器,等待一个永远不会落定的 Promise 会立即失败而不是挂死。
能力边界
- 脚本不能自行读取文件或访问网络;它只能请求宿主提供的工具完成这些动作。
- 工具的权限仍由工具本身和 Pi 的工具管线决定。Codemode 不会给工具附加更小的文件或网络权限。
- QuickJS 的 Worker 与内存限制控制的是脚本执行环境,不会回滚工具已完成的外部副作用。
- 脚本不能再启动一个 codemode 脚本:
codemode工具注册的暴露级别是model-only,只有模型能发起它。
Pi 集成时用 ctx.executeTool() 执行嵌套调用。参数校验、tool_call/tool_result 扩展事件和权限扩展都走会话的工具管线(runToolCall),与模型直接发起的调用同一条路;沙箱里的 JSON Schema 只用于渲染声明,不校验值。可观测数据分两层:渲染层的调用行记录工具名、参数摘要(截 200 字符)、状态、耗时、错误摘要(截 500 字符)与 models.* 调用的成本;会话记录层的 nestedCalls 挂在工具结果消息上,限额为最多 256 条、单条参数 8 KiB、总参数 32 KiB,超限只丢参数或调用并标记 complete: false——注释写明这份记录“保留给会话,不发给模型”。
中间结果被脚本消费,不等于副作用消失
脚本可以对独立调用使用 Promise.all(),也可以用 Promise.allSettled() 分别处理成功与失败。调用返回值有两种常见形态:声明了输出 schema 的工具可返回结构化值;其他工具通常以文本返回。MCP 工具会给脚本完整的 CallToolResult,其中包括 structuredContent 和 isError。
“注意 MCP 的错误语义。”老Z说,“服务器返回 isError: true 的调用,在脚本侧仍是 fulfilled 的结果对象,不会 reject;脚本必须自己检查 isError。真正 reject 的是失败、被拦或参数非法的调用。”
小A问:“如果第二个工具调用失败,刚才第一个工具做的事会自动回滚吗?”
“不会。”老Z说,“Codemode 是编排容器,不是事务管理器。write 已写入文件、远端 API 已接受请求,就算后续脚本抛错,这些副作用也已经发生。脚本结果会保留部分输出和调用状态,并附一行‘失败前已调用的工具(不会撤销)’,但不会替你撤销已完成的调用。”
所以脚本要按操作性质安排步骤:先并行执行彼此独立的只读工作,再汇总;有写入或外发动作时,清楚区分准备阶段与提交阶段,并为重试设计幂等性。不要因为一组工具调用写在同一个 JavaScript 函数里,就把它误当作原子事务。
工具目录按需展开
如果把几十个工具的完整 schema 一次放进提示词,模型每轮请求都要携带很长的工具描述。Codemode 提供 ALL_TOOLS、searchTools()、describeTool() 和 describeNamespace() 来发现能力。searchTools() 用 BM25 相关性排序,默认返回 8 条,可按 namespace 过滤;describeNamespace() 接受 mcp__dev-radius、mcp__dev_radius、dev-radius、dev_radius 四种写法。
Pi 会按 codemode.inlineBudget(默认 3000 个估算 token)限制内嵌声明。目录的挑选不是简单截断:selectCatalog 按命名空间分组轮转,每轮各组放入自己最便宜的剩余工具,某个组装不下就退出、其余继续——先保证每个命名空间有代表,再谈把单个组列全。
这里要分清两套暴露级别命名空间:MCP 配置层的 McpExposure(codemode/deferred/direct/hidden)和 pi 工具层的 ToolExposure。MCP 配置为 codemode 的工具注册进 pi 时映射为 pi 层的 deferred,而 pi 层的 deferred 工具从不列入 codemode 描述——所以默认配置的 MCP 工具不会出现在 codemode 的工具目录里,脚本要靠 searchTools() 找到它们。codemode 与 deferred 两种间接入口是互达的:脚本能调用 deferred 工具,tool_search 也能加载 codemode 工具。
这对 MCP 尤其有用。MCP server 可以有很长的工具清单,也可能在会话运行时才连接;不把它们的声明塞进每轮请求,正是 Pi 把 MCP 默认设为 codemode 暴露的原因(详见附录第11篇 MCP 补遗)。
Pi 有两种 Codemode 呈现模式:
| 模式 | 模型看到什么 | 适用边界 |
|---|---|---|
on(默认) | 直接声明的工具仍可单独调用,其描述追加脚本调用方式;codemode 描述列出非 direct、非 deferred 的工具 | 渐进采用,保留直接调用路径 |
only | 可调用工具集中列在 Codemode 描述中,请求里不再声明 direct 工具 | 希望所有工具调用经脚本统一编排 |
only 模式隐藏的只是声明:那些工具仍在会话注册表里、仍可被脚本调用,已加载的声明也留在会话分支上,/tree、恢复和 fork 不受影响。
Pi v1.0.0 的发布说明称默认工具配置下提示词约减少 40%:其对照条件是默认工具加激活的 codemode,一次 GPT-5.6 请求从约 5,300 token 降到约 3,300。这是发布说明针对该配置给出的数字,不应当作所有模型、工具数量和设置下的恒定节省。同时 1.0 改进了错误信息:读一个不存在的 tools.Bash 会提示近似项 tools.bash,models.* 参数形状错误给出期望结构,未知模型指向 models.getAvailableOfType()。
Store 是会话状态,不是任意持久化
store(key, value) 和 load(key) 允许不同脚本执行间传递小型 JSON 状态。Pi 只在脚本成功后把写入追加为 codemode-store 会话条目;恢复会话或切到分支时,Pi 从当前分支的条目重建 store。因此状态会跟着会话历史分支走,而不是写入一个全局数据库。store(key, undefined) 删除键;没有会话上下文时(裸 Agent 或直接调用工具),load() 从空开始、写入被丢弃。
const cursor = load("cursor") ?? "";
const page = await tools.fetch_page({ cursor });
if (page.nextCursor) store("cursor", page.nextCursor);
text(JSON.stringify({ count: page.items.length, hasMore: Boolean(page.nextCursor) }));Store 适合保存游标、任务 ID 或短摘要,不适合图片、整份工具输出或秘密。单值最多 256 * 1024 个 JSON 字符,总量最多 1024 * 1024;超限在脚本内抛 RangeError、脚本失败。失败脚本的 store 写入不会落入会话,但这条规则仍不影响它此前调用的工具副作用。
models 让脚本组合不同类型的模型能力
Pi 向 Codemode 暴露当前会话的模型目录与凭据解析。脚本可列举和选择分类器或图像模型,再调用 models.classify() 或 models.generateImages();聊天模型只可列入目录,不能从脚本内再次启动聊天请求。模型调用的用量会计入 Codemode 结果和会话成本。
一次脚本中,分类器和图像模型调用最多同时运行四个;更多调用按提交顺序排队。模型调用失败以 stopReason 和 errorMessage 返回,脚本应检查结果。图像生成返回的图片块不会自动落盘;要交回当前模型,需逐个 image(block) 输出。若脚本生成图片却没有展示,Pi 会追加一条提示,避免图片默默丢失。image() 只接受 base64 的 PNG、JPEG、GIF、WebP,MIME 类型按魔数而不是声明判定,远程 URL 被拒绝。
凭据由 Pi 的模型注册表解析,脚本只用模型的 provider 和 id 选择条目。实现不会把模型条目里的自定义 headers 暴露给脚本(目录条目交给脚本前删除 headers),也只按 {provider, id} 解析目标——脚本传入的 baseUrl 或 headers 永远不会拿到宿主凭据。
失败、预算与适用范围
Codemode 的失败有脚本解析/运行错误、超时、中止和沙箱错误四类。失败结果保留已产生的部分输出和嵌套调用状态。输出默认预算 10,000 token(常量 DEFAULT_MAX_OUTPUT_TOKENS,按每 token 4 字符折算):超限时文本合成一条“头…省略…尾”,全文尽力写入临时文件并附路径——写盘失败时结果里如实报告失败原因,没有路径。
期限分两套:Pi 集成在不写 @options 时默认无期限(timeoutMs ?? Infinity),只受中止和内存上限约束;独立沙箱包 CodemodeSandbox 的默认超时是 300,000 毫秒(5 分钟)。Pi 集成的 QuickJS VM 堆上限是 256 * 1024 * 1024 字节(256 MiB,上游文档写作 256 MB),超出在脚本内抛 InternalError: out of memory。图像生成可能耗时数分钟,脚本期限要按任务设,不要设太短。
适合 Codemode 的工作
- 一批相互独立的只读工具调用,可并行后汇总;
- 先调用工具,再对结果做确定性的筛选、排序、合并或统计;
- MCP 工具很多,模型需要先搜索再按需调用;
- 将分类器或图像模型与工具结果组合为一条脚本流程。
如果任务需要复杂审批、跨工具原子提交、长期调度、回滚或人工确认状态机,单段脚本并不自动提供这些保障;应由宿主服务、工具协议或持久化工作流承担。
从源码能归纳出的工程价值是:把确定性编排留在代码里,把压缩后的证据送回模型,同时借由 Pi 的工具管线保留调用校验和扩展拦截点。它的代价是多了一层运行时和排错边界,脚本作者还必须管理部分失败、并发与副作用。它不是“模型写代码所以安全”,也不是“工具返回不进上下文所以没有风险”。
源码走查
- 从
packages/coding-agent/src/extensions/codemode/tool.ts的createCodemodeToolDefinition()找到工具描述、prepareLoadout改写与延迟加载入口;对照selectCatalog()看懂目录预算的轮转挑选。 - 在
execute.ts找ctx.executeTool(),确认嵌套调用经过 Pi 的工具管线;跟到toScriptValue()查看结构化结果、文本结果和错误如何交给脚本,以及 MCPisError为何不 reject。 - 读
core/nested-tool-calls.ts的NESTED_CALL_LIMITS与NestedCallRecorder;对照packages/ai/src/types.ts中nestedCalls“不进模型”的注释。 - 比较
executeCodemode()中读取分支 store、运行脚本和仅在成功时appendEntry的顺序;分别预测:脚本失败、脚本store(key, undefined)、无会话上下文三种情况下 store 的结果。 - 在
packages/codemode/src/runtime/host.ts与 worker 实现中核对 QuickJS 的 Worker 生命周期、默认超时、内存限制与中断标志;再对照 Pi 集成execute.ts里timeoutMs ?? Infinity的差异。 - 检查
models.generateImages()的凭据来源、并发上限和用量记账;找出“生成而未展示图片”的提示分支。 - 设计一个先调用只读工具、再调用写工具且脚本中途失败的场景,分别记录哪些结果会被保留、哪些副作用不会撤销。
