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

Codemode:脚本怎样组合工具调用

拆解 codemode 脚本的沙箱边界、工具发现路径与结果裁剪机制,说明脚本失败为什么不会撤销已发生的外部副作用。

Codemode:脚本怎样组合工具调用

模型每次只能调一个工具。想读三个文件就得发三次调用,每次都是一次完整的模型往返;想对一批结果做过滤、聚合,中间数据会整块进上下文。工具多了之后,这种"一次一个"的节奏会让上下文迅速膨胀。

codemode 换了个做法。它让模型写一段 JavaScript,由脚本在沙箱里组合工具调用,最后只把脚本输出交回模型。嵌套的工具结果不进上下文,只有脚本的输出进。

这引出两个新的风险面:脚本在沙箱里跑,能力边界和直接调用不同;更关键的是——脚本失败不等于外部副作用被撤销。

这篇讲清楚一次 codemode 调用里工具声明、工具发现、脚本调用分别走哪条路径,写出可复用的编排脚本,并指出哪些副作用脚本失败也带不走。读它需要会写 JavaScript,以及对 Pi 的工具机制有基本印象。

Pi 迭代很快,动手前先记录 pi --version;本文依据 Pi 官方文档 与 官方仓库 编写。

一、codemode 改变了什么

差别不在"能不能调工具",而在谁来做编排。

维度模型逐个调用codemode 脚本调用
编排者
模型
模型写好的脚本
工具结果去向
每个结果都进上下文
只进脚本变量,模型只看到最终输出
并发
靠模型在同一轮发多个 toolCall
脚本里 Promise.all / Promise.allSettled
中间数据
全部进上下文
在沙箱里被过滤、聚合、丢弃
失败粒度
一个工具失败,那一轮就带错误
脚本自己决定怎么处理
成本
N 次模型往返
1 次模型往返 + N 次工具执行

要在 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。返回值取决于工具类型:

工具类别脚本收到什么
声明了 outputSchema 的工具
结构化值。bash 解析成 { output, truncated, full_output_path?, exit_code, wall_time_seconds }
MCP 工具
完整的 CallToolResult,含 isError 和 structuredContent
read
文件文本;图片则是 image block { type: "image", data, mimeType, note }
其它工具
文本输出

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 都能用。

javascript
// 脚本是 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 环境。

能力沙箱内说明
顶层 await / return
有
脚本就是 async function 的函数体
文件系统 / 网络 / 定时器
无
只能通过工具访问;等一个永远不会 settle 的 promise 会立刻失败,不会挂住
Node API(process、require、fetch)
无
eval / Function
有
只能生成同一 VM 里的代码
工具调用 / 模型调用
有
tools.<name>(args)、models.*

沙箱内存上限 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's beforeToolCall and afterToolCall hooks. To apply them to nested calls too, run each call through runToolCall() 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.

情况已经发生的事未完成的事
脚本成功
全部工具调用生效
—
脚本抛错
失败之前发出的调用全部生效
还在跑的调用被取消
脚本正常结束
全部生效
没被 await 的 promise 被丢弃

具体意味着:脚本先 tools.write(...) 写了三个文件、第四个抛错 → 前三个文件已经在磁盘上;脚本调了发邮件的 MCP 工具然后自己崩了 → 邮件已经发出去了。

沙箱隔离的是脚本本身能碰到的东西,它不隔离工具调用的副作用——那些工具跑在宿主进程里,用宿主进程的权限。所以脚本要做有副作用的事,就得让工具幂等,或让工具支持补偿操作,而不是指望"脚本失败了就当没发生"。

七、一个完整的编排示例

「先检索、再并行读取、最后只返回必要结果」的完整形态:

javascript
// @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 会话不加载内置扩展,要显式装:

typescript
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 写成了替换语义。

九、官方已定义、源码事实与教学设计

类别内容
官方已定义
脚本在 QuickJS 沙箱里跑,没有 Node、文件系统、网络、定时器;只有脚本输出到达模型;// @options: 行必须第一行;失败的调用 reject;失败之前的工具调用不会被撤销;models.* 的用量计入会话成本;codemode 与 tool_search 注册时 inactive;codemode.inlineBudget 默认 3000 估算 token
源码事实(文档未逐字确认,以官方仓库当前实现为准)
嵌套调用的入口是 ctx.executeTool(),parentToolCallId 就是 codemode 的调用 ID;MCP 服务器的 codemode 曝光映射到工具层面的 deferred 曝光;worker 槽位是跨会话共享的进程级资源,被取消的 worker 在真正退出前仍占着槽位;worker 上限与超限行为的细节随版本变化
教学设计
「检索 → 并行读取 → 裁剪返回」的脚本模板;用 allSettled 作为默认写法;幂等 / 补偿的建议——官方没规定,但"脚本副作用不可撤销"是官方事实

你现在能做什么

  • 写一个 codemode 脚本,在一个目录里并发 grep 多个子目录,用 Promise.allSettled() 收集结果,只把命中文件名数组 return 出去。
  • 用 describeNamespace("mcp__<服务器>") 读一个 MCP 服务器的说明文字,确认它不出现在 codemode 描述里但能在脚本里找到。
  • 把 defaultTools 从 ["codemode"] 改成 ["+codemode"],对比 getActiveToolNames() 的输出差异。
  • 写一个脚本故意在三次 tools.write() 之后抛错,然后检查磁盘——前三个文件确实写进去了,验证"脚本失败不撤销副作用"。
  • 在脚本里调用 models.getAvailableOfType("classifier") 列出当前凭据可用的分类模型 ID。
  • 用 // @options: 的 max_output_tokens 限制脚本输出,观察超限时首尾保留、中间写入临时文件。

排障速查

现象检查点处理
模型不写脚本,还是一个个调工具
codemode 是否激活
getActiveToolNames() 里有没有 codemode;defaultTools 要写 ["+codemode"],["codemode"] 是替换语义
脚本里调不到 MCP 工具
曝光方式
默认曝光 codemode 的工具不在描述里,脚本要用 searchTools() 或 ALL_TOOLS 找
MCP 服务器连上了,描述却没变
deferred 曝光
设计如此;用 describeNamespace("mcp__<服务器>") 读服务器说明
tools.mcp__dev-radius__search 报未定义
标识符规则
非标识符字符换成 _:tools.mcp__dev_radius__search
脚本抛错,之前的文件写入还在
副作用语义
脚本失败不撤销已发生的调用;让工具有幂等设计或补偿操作
输出太大,模型只看到一半
max_output_tokens
默认 10000;调 // @options: 第一行,或在脚本里裁剪后再返回
图片生成脚本超时
timeout_ms
图像生成可能跑几分钟,别设短期限
并行脚本排队后失败
worker 数量
一个进程最多两个 worker 跨会话共享;上限可用 PI_CODEMODE_MAX_WORKERS 调整,容量满时排队等待
脚本报内存溢出
沙箱内存
256 MB 上限;在数据流入时就过滤和聚合,别累积大数组
models.classify() 没抛错但结果不对
返回值检查
它不抛异常,检查 stopReason 和 errorMessage
权限扩展拦不住脚本里的调用
嵌套调用路径
脚本走 ctx.executeTool();直接调 tool.execute() 会绕开钩子

参考资料