本文基于 OpenAI Codex 当前代码实现,拆解模型可见的
exec工具、Code Mode 运行时,以及它背后的 Programmatic Tool Calling(PTC)机制。
传统 Agent 的工具使用方式通常是:
模型推理 → 调用一个工具 → 工具结果回到模型 → 再次推理 → 调用下一个工具
这条链路直观,但当任务需要批量检索、并行调用、过滤大量结构化数据时,会产生三个问题:
- 每个中间结果都进入模型上下文;
- 多次模型往返增加延迟;
- 排序、过滤、聚合等确定性工作仍由模型承担。
Codex 新的 exec 工具采用了另一种思路:让模型生成一小段 JavaScript,在受控 V8 环境中调用和编排普通工具,只把最终选择的结果返回模型。
这正是 PTC 的核心价值。
一、先澄清三个容易混淆的名字
1. exec
本文讨论的 exec 是模型可见的 freeform/custom tool,输入是原始 JavaScript:
const result = await tools.exec_command({ cmd: "rg -n TODO ." });
text(result.output);
2. exec_command
这是一个普通的进程执行工具。它可以被 exec 中的 JavaScript 调用,但它并不是 JavaScript 运行时本身。
3. codex exec
这是 Codex CLI 的非交互命令,与本文的 PTC runtime 是不同概念。
仓库内部把这套 JavaScript 编排机制称为 Code Mode。
二、exec 的模型协议入口
exec 被注册为 ToolSpec::Freeform。模型发送的不是常见的 JSON arguments,而是:
custom_tool_call(
name = "exec",
input = <raw JavaScript>
)
代码入口位于:
输入第一行还可以携带 pragma:
// @exec: {"yield_time_ms": 30000, "max_output_tokens": 2000}
const result = await tools.some_tool({ query: "..." });
text(result);
其中:
yield_time_ms控制首次返回前等待多久,默认 10 秒;max_output_tokens控制本次exec直接输出预算,默认 10,000 tokens。
pragma 只允许这两个字段,并要求非负 JavaScript safe integer。
三、核心设计:控制流与能力分离
exec 最值得关注的设计,并不是“在 Agent 里塞一个 JavaScript REPL”,而是把系统拆成两个平面。
V8 控制流平面
JavaScript 负责:
- 并行调用;
- 条件判断;
- 循环与批处理;
- 排序、过滤和 join;
- 聚合统计;
- 决定哪些结果进入模型上下文。
Codex 能力平面
真实能力仍由 Codex ToolRouter 提供:
- shell/进程执行;
- 文件修改;
- Web Search;
- MCP tools;
- dynamic tools;
- plugin/extension tools;
- 图片和其他多模态工具。
因此,JavaScript 本身不直接获得文件系统、网络和进程权限。所有 I/O 都必须通过 tools.* 跨越宿主桥接。
四、V8 Cell 是如何运行的
每次 exec 都会创建一个新的 V8 isolate 和 context,并把输入编译成 async ES module,所以支持 top-level await。
实现位于:
运行环境刻意保持很小:
- 没有 Node.js;
- 没有直接文件系统 API;
- 没有直接网络 API;
- 没有
console; - 禁止 static/dynamic import;
- 移除
Atomics、SharedArrayBuffer和WebAssembly。
宿主只安装明确允许的 globals:
| Global | 用途 |
|---|---|
tools |
当前启用工具的 Promise 代理 |
ALL_TOOLS |
工具名称与描述元数据 |
text() |
输出文本或 JSON |
image() |
输出图片 content item |
generatedImage() |
输出生成图片及提示 |
store()/load() |
session 范围的 JSON 状态 |
notify() |
向当前 turn 即时注入进度 |
yield_control() |
立即交还控制权但保持 cell 运行 |
exit() |
成功结束 module |
setTimeout/clearTimeout |
最小 timer 能力 |
五、tools.* Promise 桥接
当 JavaScript 调用:
const result = await tools.mcp__github__search_issues({
query: "is:open label:bug"
});
运行时会:
- 把 V8 参数序列化成 JSON;
- 创建一个
PromiseResolver; - 生成 cell-local tool ID;
- 发出
RuntimeEvent::ToolCall; - Cell Actor 将请求交给 CodeMode delegate;
CodeModeDispatchBroker将其转换为普通ToolInvocation;- 请求进入 Codex 原有
ToolRouter; - 工具结果通过
ToolOutput::code_mode_result转为 JSON; - V8 promise 被 resolve 或 reject;
- microtask checkpoint 后继续执行 JavaScript。
相关实现:
这里有一个关键结论:
从 exec 调用工具,不会绕过 Codex 原有的 approval、sandbox、hook、cancellation、telemetry 和输出适配。
exec 也明确禁止调用自身,避免递归创建嵌套 runtime。
六、哪些工具可以从 exec 调用
工具是否进入 tools 由 ToolExposure 决定:
| Exposure | 模型直接调用 | exec 嵌套调用 |
|---|---|---|
Direct |
是 | 是 |
Deferred |
初始隐藏,可发现 | 是 |
DirectModelOnly |
是 | 否 |
Hidden |
否 | 否 |
同时还会应用:
excluded_tool_namespaces;direct_only_tool_namespaces;- provider/model capability;
- 工具 spec 是否能成功转换。
实际可调用清单应以运行时为准:
text(ALL_TOOLS.map(({ name }) => name));
MCP tools
MCP tool 会被包装成普通 McpHandler,因此可以直接参与 PTC:
const result = await tools.mcp__analytics__query({ limit: 1000 });
const rows = result.structuredContent?.rows ?? [];
text(rows.filter(row => row.score > 0.8).slice(0, 20));
MCP 返回通常保留 CallToolResult:
contentstructuredContentisError_meta
Dynamic 与 Plugin Tools
当前 thread 注入的 dynamic function tools,以及 extension 的 ToolContributor,同样可以进入 tools。这意味着 PTC 并不是为某一套内置工具硬编码的机制,而是一层通用的编排面。
七、并发:PTC 最直接的性能收益
独立调用可以使用 Promise.all:
const [issues, commits, builds] = await Promise.all([
tools.mcp__github__search_issues({ query: "is:open" }),
tools.mcp__github__list_commits({ limit: 100 }),
tools.ci__list_runs({ branch: "main" }),
]);
text({
openIssues: issues.structuredContent?.total ?? 0,
recentCommits: commits.structuredContent?.items?.slice(0, 10) ?? [],
failedBuilds: (builds.items ?? []).filter(run => run.status === "failed"),
});
Broker 会分别启动 nested dispatch task,所以这些调用可以真正并行。
但“运行时支持并行”不代表所有工具都适合并行。有副作用、共享状态或顺序依赖的工具仍应串行 await。
八、为什么 PTC 能显著节省上下文
假设三个工具各返回 5,000 tokens。
传统链路可能让模型依次看到 15,000 tokens 中间结果,然后再生成摘要。
PTC 可以在 JavaScript 内完成:
const all = [...a.items, ...b.items, ...c.items];
const top = all
.filter(item => item.score >= 0.8)
.sort((x, y) => y.score - x.score)
.slice(0, 20);
text({
total: all.length,
matched: top.length,
items: top,
});
模型只看到最终的 Top-20、总数和过滤条件。
这使 PTC 特别适合:
- 大批量搜索;
- 多数据源 join;
- 日志与记录筛选;
- 统计聚合;
- fan-out/fan-in 工作流;
- 只需把少量结论交回模型的任务。
九、yield、wait 与长任务
Cell 的主要生命周期可以概括为:
Running ──完成──> Completed
│
├──yield deadline / yield_control──> Yielded
│ │
│ └──wait──> Running / Completed
│
└──terminate──> Terminating ──> Terminated
首次 exec 默认观察 10 秒。如果 module 还未完成,会返回:
Script running with cell ID ...
模型随后使用:
{
"cell_id": "...",
"yield_time_ms": 10000,
"max_tokens": 10000,
"terminate": false
}
每次 wait 只返回上一个观察点之后的新输出,而不是重放完整 transcript。同一个 cell 同时只允许一个 observer。
terminate: true 会取消 cell 及其 callback cancellation tokens;CPU 死循环还可以通过 V8 thread-safe isolate handle 强制终止。
十、store/load 的一致性边界
store(key, value) 只接受 JSON-serializable 数据。
每个新 cell:
- 获取 session store 快照;
- 在 cell 内记录 write set;
- 完成发布时原子提交 write set。
这解决了 terminate 与 completion 竞争时的部分状态发布问题。
但它不是跨工具事务:如果某个 nested tool 已经修改文件或调用外部 API,随后 JavaScript 失败,这些外部副作用不会自动回滚。
十一、安全模型的真实边界
Code Mode 的核心安全属性是 capability separation:
- JavaScript 没有直接 I/O;
- 能力必须通过已命名的 nested tool;
- 工具暴露面在 cell 创建前确定;
- nested call 重用正常 ToolRouter;
exec不能递归调用自身;- session → cell → callback 形成取消链。
Code Mode 可以运行在独立的 code-mode-host 进程中;如果 host binary 缺失,实现允许退回进程内 V8。
因此不能把“启用了 host feature”简单理解为绝对的 OS 进程隔离。真实安全性还依赖:
- V8 embedding;
- host process 是否实际可用;
- 资源限制;
- nested tool 的权限策略;
- approval 和 sandbox 配置。
十二、几个容易踩坑的地方
1. 忘记 await
tools.exec_command({ cmd: "important-side-effect" });
module 可能在工具完成前结束。未 await 的 promise 和 timer 不会维持 cell 生命周期。
应写成:
await tools.exec_command({ cmd: "important-side-effect" });
2. 把全部结果重新 text 出去
这样会抵消 PTC 的上下文优势。应输出 counts、过滤条件、Top-N 和必要证据。
3. 无界 Promise.all
PTC 支持并发,但不应该一次生成数千个请求。应分批、设上限并处理失败。
4. 假设所有工具都在 tools 中
DirectModelOnly、Hidden 或被 namespace 配置排除的工具不会进入 nested surface。始终检查 ALL_TOOLS。
5. 把 store 当持久化数据库
store 是 session 状态,不是跨会话的耐久存储。
十三、PTC 适合什么,不适合什么
适合
- 多个独立调用可并行;
- 大量结构化中间结果;
- 确定性的过滤、排序和聚合;
- 只希望模型看到摘要或少量样本;
- 组合内置、MCP 和自定义工具。
不一定适合
- 只有一个简单工具调用;
- 每一步都需要模型做语义判断;
- 高频人工审批;
- 强副作用、顺序和确认比吞吐更重要;
- 中间结果本身必须逐项由模型审阅。
结语
Codex 的新 exec 并不只是“让模型会写 JavaScript”。
它把 Agent 工具调用从:
模型驱动的逐步 RPC
推进为:
模型生成受控程序 → 程序编排能力工具 → 模型接收精炼结果
这带来了三个结构性变化:
- 确定性计算从模型转移到代码;
- 大量中间数据从模型上下文转移到 runtime;
- 多工具调用从串行模型往返转为可并行的程序编排。
从 Agent 工程角度看,PTC 不是单纯的性能优化,而是模型、程序与工具之间职责边界的一次重新划分。



