跳转到内容

Code Mode

前面几集里,模型要一次工具,就发一次工具调用。读一个文件是一轮,改一个文件是一轮,跑一条命令又是一轮。工具少的时候没问题;工具一多,模型会在“说要调什么”上花掉很多轮。

这一集看另一种做法:模型不直接逐个调工具,而是写一段 JavaScript。脚本里想调多少次就调多少次,循环、并发、解析输出都在脚本里完成;最后只把脚本的结果交回模型。

这就是 Code Mode。

图 11-1 一次写脚本,多次调工具

图 11-1 · 模型只发一次 codemode,脚本在里面调多次工具,只有输出回到模型

假设模型要比较两个文件,再跑一次测试:

读 a.txt
读 b.txt
跑 diff
跑测试

普通工具调用是四次模型往返。每次往返,模型都要重新看一遍上下文,再说下一次要调什么。Code Mode 把这一步压成一次:

const [a, b] = await Promise.all([
tools.read({ path: "a.txt" }),
tools.read({ path: "b.txt" }),
]);
const diff = await tools.bash({ command: "diff -u a.txt b.txt" });
const test = await tools.bash({ command: "npm test" });
text(diff.output);
console.log(test.output);
return { same: a === b, exitCode: test.exitCode };

模型只发一次 codemode 调用,脚本在里面调四次工具。对模型来说,最后只看到脚本的输出;中间那四次工具调用,是 Durable 在下面替它跑的。

Code Mode 是一个扩展,入口在 @earendil-works/pi-durable/codemode:

源码对照 · packages/durable/src/codemode/codemode.ts:255

export function createCodemodeExtension(options: CodemodeToolOptions): Extension {
return defineExtension({
name: "codemode",
tools: [createCodemodeTool(options), ...(options.models === false ? [] : createModelTools())],
sections: [
section("codemode", (input) => codemodeSection(/* … */)),
],
});
}

工具的输入只有一个 code 字符串(codemode.ts:213);它的 callers 只有 ["model"],所以脚本不能再启动另一个脚本;它的 replay 是 "safe",所以崩溃后会重放(codemode.ts:246)。

它需要一个沙盒。Node 上用 CodemodeSandbox,Cloudflare 上用 RemoteCodemodeSandbox:

import { CodemodeSandbox } from "@earendil-works/pi-codemode";
import { createCodemodeExtension } from "@earendil-works/pi-durable/codemode";
const codemode = createCodemodeExtension({
sandbox: new CodemodeSandbox({ memoryLimitBytes: 256 * 1024 * 1024 }),
});
registry.install(codemode);

如果只想把 codemode 这个工具给模型,其他工具留给脚本调,可以只把 codemode 放进 modelTools:

await root.configure({ modelTools: [codemode] }, context);

这样模型看到的是“一个能跑脚本的工具”,脚本里再通过 tools.<name> 去用读、写、bash 和 MCP 工具。

Code Mode 的脚本跑在 QuickJS 沙盒里,不是 Node。它没有 require、没有文件系统、没有网络、没有定时器,也不能直接读环境变量。

脚本能用的东西不多:

  • tools.<name>(args):调这个对话里的工具;
  • text()、image()、console.log()、return:产出结果;
  • store() / load():按对话保存 JSON 值;
  • searchTools()、describeTool()、describeNamespace():找没被列出来的工具;
  • models.*:模型目录、分类和图片生成(默认开启)。

源码对照 · packages/durable/src/codemode/describe.ts:29

const DESCRIPTION_INTRO = `Run JavaScript that calls other tools. The input is raw JavaScript (not JSON, no code fence), run as an async function body in a QuickJS sandbox: top-level \`await\` and \`return\` work. No Node, file system, network, or timers.
- \`await tools.<name>({ ...args })\` resolves to a string, or an object if the tool's declaration says so, and rejects with an Error on failure. A tool whose declaration returns an object resolves on failure too when it returned that object; check its fields. Calls still running when the script ends are cancelled.
- The script survives a restart: it resumes where it was, without running finished calls again.
- Optional first line: \`// @options: {"max_output_tokens": 10000, "timeout_ms": 60000}\``;

这就是沙盒的边界:脚本能编排工具,但不能绕过工具去碰机器。它要读文件,仍然走 tools.read;要跑命令,仍然走 tools.bash。工具背后可以是本地 ExecutionEnv,也可以是第十集讲的远程环境。

图 11-2 QuickJS 沙盒只有工具这扇门

图 11-2 · 脚本没有 Node、文件系统、网络和定时器;工具是唯一的出口

脚本里的 tools.read(...) 不是普通函数调用。它会在 Durable 里创建一个嵌套工具任务,挂在 codemode 这个父任务下面。

每个嵌套调用都有自己的任务记录、checkpoint、replay 策略和结果条目。脚本只拿到结果,不直接碰记录本。这样做的结果就是:

  • 嵌套调用失败,脚本里会收到一个错误;
  • 工具声明了 structuredOutputSchema,就返回结构化值;没声明,就返回文本;
  • bash 这种声明了 schema 的工具,非零退出也会 resolve 成 { output, exitCode, ... },脚本要自己检查 exitCode;
  • 脚本结束时还没完成的调用,会被取消;
  • 只有脚本最后的输出,才会作为 codemode 工具的结果交给模型。

源码对照 · packages/durable/docs/spec.md:4376

The script calls the agent's `callable` tools as `tools.<name>(args)`; each call is a nested call with key
`c<id>`, the VM's own number for the call. As in the coding agent, a tool that
declares `structuredOutputSchema` resolves with its `structuredOutput`, also on an
error result that carries one (`bash` with a nonzero exit resolves with
`{ output, exitCode, ... }`, which the script checks), and a schema-less tool
resolves with its text output, images left out, as its declaration
(`Promise<string>`) says. Every other error rejects...

嵌套调用在 UI 上也能看到。脚本运行期间,pi.live.nestedTools 里会出现它的调用行;结束后,codemode 结果的 details.calls 里会列出这次脚本调了哪些工具、状态是 ok、error 还是 cancelled。

图 11-3 每次 tools.* 都是一次嵌套任务

图 11-3 · codemode 父任务下面挂着每个 tools.* 的嵌套工具任务

模型写脚本之前,得知道有哪些工具可用。Code Mode 会加一个 codemode 系统提示词段,把可调用工具按 TypeScript 声明列出来,默认预算 3000 个估算 token:

源码对照 · packages/durable/src/codemode/describe.ts:23

export const DEFAULT_CODEMODE_INLINE_BUDGET = 3000;

工具太多时,列不下的部分不会硬塞。脚本可以用 searchTools(query, { limit?, namespace? }) 搜,用 describeTool(name) 看单个工具,用 describeNamespace(name) 看一组工具的说明。

工具还可以按名字前缀分组。比如一个 MCP server 的工具都叫 mcp__github__...,可以这样声明:

createCodemodeExtension({
sandbox,
namespaces: [
{ name: "github", prefix: "mcp__github__", description: "GitHub tools" },
],
});

源码对照 · packages/durable/src/codemode/describe.ts:14

export type CodemodeNamespace = {
readonly name: string;
readonly description?: string;
readonly instructions?: string;
readonly prefix: string;
};

命名空间的作用是让脚本和模型先看到“有哪些组”,而不是一上来就面对几百个工具名。

图 11-4 先给模型一张工具地图

图 11-4 · codemode 段在预算内列工具,列不下的用搜索和 describe 找

脚本不是只返回一个字符串。它可以有多个输出项、console 输出、图片和返回值。Code Mode 会把它们排成模型能读的格式:

  • 开头是 Script completed 或 Script failed,带执行时间;
  • 多个文本项用 ==> text N/M <== 分开;
  • console 行集中在一个 <console_output> 块里;
  • 失败时带上错误,以及已经调用过的工具;
  • 超过 max_output_tokens(默认 10000)的文本保留头尾,完整文本写到临时文件;
  • 图片保存成文件,结果里给出路径。

源码对照 · packages/durable/src/codemode/output.ts:44

function formatItems(output: readonly CodemodeOutputItem[]): Item[] {
const total = output.filter((item) => item.type === "text" && !item.console).length;
const items: Item[] = [];
const consoleLines: string[] = [];
let index = 0;
for (const item of output) {
if (item.type === "image") items.push(item);
else if (item.console) consoleLines.push(item.text);
else {
index++;
items.push({ type: "text", text: total > 1 ? `==> text ${index}/${total} <==\n${item.text}` : item.text });
}
}
// …
}

这也是 Code Mode 和普通工具调用的区别:普通工具的结果通常就是一段文本;Code Mode 的结果是一个“脚本执行报告”,模型能看出脚本做了什么、哪一步失败了。

图 11-5 结果是一份执行报告

图 11-5 · 结果带 header、文本项、console、截断文件和每个嵌套调用的状态

这是 Code Mode 最关键的一部分。

脚本是内存里的 VM 状态。如果进程在 await Promise.all(...) 等 200 个读文件时挂了,普通做法是脚本丢了,前面读过的也白读。Code Mode 的办法是:把脚本变成可重放的。

沙盒运行脚本时是确定性的:

  • 虚拟时钟;
  • 带种子的随机数;
  • UTC;
  • 同样的启动数据、同样的结果顺序,就会做出同样的调用。

每次脚本拿到一个工具结果之前,Code Mode 先把这条结果写进 pi.codemode.journal。重启后,它重新启动脚本,把 journal 里的结果按原顺序喂回去。脚本会再次发出同样的 tools.* 调用,这些调用按 key c<VM call id> 重新挂到原来的嵌套任务上,不会重新执行;然后脚本从断点继续。

源码对照 · packages/durable/src/codemode/codemode.ts:125

export const CodemodeRunDoc = defineDoc<RunState>({
kind: "pi.codemode.run",
version: 1,
scope: "task",
initial: () => ({}),
checkpointWhen: () => false,
});

源码对照 · packages/durable/src/codemode/codemode.ts:151

export const CodemodeJournalDoc = defineDoc<JournalState>({
kind: "pi.codemode.journal",
version: 1,
scope: "task",
initial: () => ({ entries: [] }),
checkpointWhen: () => false,
});

pi.codemode.run 存启动数据:模块版本、开始时间、种子、VM 指纹、deadline、脚本看到的工具和全局、store 快照。pi.codemode.journal 存每次交付给 VM 的结果,以及脚本在处理这个结果后已经发出了哪些调用。pi.codemode.store 存 store() / load() 的值,是对话级的,fork 时按 fork 点复制。

重放不是无条件的。遇到下面这些情况,Code Mode 不会假装成功,而是让这次调用以错误结束:

  • 脚本这次走了另一条路径,调用顺序和 journal 对不上;
  • 沙盒指纹变了;
  • Code Mode 的模块版本变了;
  • timeout_ms 已经过了。

沙盒在脚本运行时被关掉(比如进程退出)不算结果;下一次进程会用新的沙盒重新跑脚本。

图 11-6 脚本能扛重启

图 11-6 · 结果先写 journal,再交给 VM;重启后按原顺序重放,嵌套调用按 key 重新挂上

脚本还可以在对话里留一点小状态:

const count = load("count") ?? 0;
store("count", count + 1);
return count;

这些值按对话保存,fork 出来的对话看到的是 fork 点的快照。它们适合放“脚本上次跑到哪”“用户偏好”这类小 JSON,不适合放大量数据。

models.* 默认也会给脚本。models.classify() 和 models.generateImages() 不是普通本地函数,而是扩展内部的嵌套工具 pi.codemode.models.classify 和 pi.codemode.models.generateImages。它们各自有任务、用量和 replay 策略,一个脚本最多同时跑四个。models: false 可以关掉这组能力。

Code Mode 适合:

  • 工具很多,模型需要先搜索、再组合;
  • 一次任务要循环、并发或解析大量输出;
  • 想让模型写一段可复用的编排逻辑,而不是一轮一轮地“说下一步”;
  • 需要脚本在崩溃后接着跑,而不是从头再来。

不适合:

  • 只有一两个简单工具调用;
  • 需要 Node、文件系统、网络或定时器;
  • 脚本很短、模型直接调工具更清楚;
  • 需要严格隔离不可信代码,但又不准备沙盒资源。

Code Mode 的沙盒是 QuickJS,不是完整的 OS 沙盒。它把脚本限制在“只能通过工具接触外界”的范围内;工具侧的隔离仍然来自你给工具配置的 ExecutionEnv。如果工具跑在本地,QuickJS 只是脚本沙盒,不是 OS 沙盒;需要更强隔离,就把工具接到第十集的远程机器或容器。

这一集记住三句话:

  1. Code Mode 让模型写脚本,不逐个调工具。 脚本里可以循环、并发、解析,最后只把结果交给模型。
  2. 脚本里的每次 tools.* 都是嵌套工具任务。 它们有自己的 checkpoint、replay 和结果,只有脚本输出回到模型。
  3. 脚本能扛重启,是因为 journal 和确定性重放。 结果先写 journal,再交给 VM;重启后按原顺序喂回去,嵌套调用按 key 重新挂上。

前几集讲的是 Durable 怎么把一次运行记下来,这一集讲的是:模型可以把“怎么调工具”写成程序,而 Durable 负责让这段程序在崩溃后还能接着跑。