从 Tool Call 到代码编排:OpenAI Codex 新 exec 工具与 PTC 架构深度解析

本文基于 OpenAI Codex 当前代码实现,拆解模型可见的 exec 工具、Code Mode 运行时,以及它背后的 Programmatic Tool Calling(PTC)机制。

传统 Agent 的工具使用方式通常是:

模型推理 → 调用一个工具 → 工具结果回到模型 → 再次推理 → 调用下一个工具

这条链路直观,但当任务需要批量检索、并行调用、过滤大量结构化数据时,会产生三个问题:

  1. 每个中间结果都进入模型上下文;
  2. 多次模型往返增加延迟;
  3. 排序、过滤、聚合等确定性工作仍由模型承担。

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;
  • 移除 AtomicsSharedArrayBufferWebAssembly

宿主只安装明确允许的 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"
});

运行时会:

  1. 把 V8 参数序列化成 JSON;
  2. 创建一个 PromiseResolver
  3. 生成 cell-local tool ID;
  4. 发出 RuntimeEvent::ToolCall
  5. Cell Actor 将请求交给 CodeMode delegate;
  6. CodeModeDispatchBroker 将其转换为普通 ToolInvocation
  7. 请求进入 Codex 原有 ToolRouter
  8. 工具结果通过 ToolOutput::code_mode_result 转为 JSON;
  9. V8 promise 被 resolve 或 reject;
  10. microtask checkpoint 后继续执行 JavaScript。

相关实现:

这里有一个关键结论:

从 exec 调用工具,不会绕过 Codex 原有的 approval、sandbox、hook、cancellation、telemetry 和输出适配。

exec 也明确禁止调用自身,避免递归创建嵌套 runtime。


六、哪些工具可以从 exec 调用

工具是否进入 toolsToolExposure 决定:

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

  • content
  • structuredContent
  • isError
  • _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:

  1. 获取 session store 快照;
  2. 在 cell 内记录 write set;
  3. 完成发布时原子提交 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 中

DirectModelOnlyHidden 或被 namespace 配置排除的工具不会进入 nested surface。始终检查 ALL_TOOLS

5. 把 store 当持久化数据库

store 是 session 状态,不是跨会话的耐久存储。


十三、PTC 适合什么,不适合什么

适合

  • 多个独立调用可并行;
  • 大量结构化中间结果;
  • 确定性的过滤、排序和聚合;
  • 只希望模型看到摘要或少量样本;
  • 组合内置、MCP 和自定义工具。

不一定适合

  • 只有一个简单工具调用;
  • 每一步都需要模型做语义判断;
  • 高频人工审批;
  • 强副作用、顺序和确认比吞吐更重要;
  • 中间结果本身必须逐项由模型审阅。

结语

Codex 的新 exec 并不只是“让模型会写 JavaScript”。

它把 Agent 工具调用从:

模型驱动的逐步 RPC

推进为:

模型生成受控程序 → 程序编排能力工具 → 模型接收精炼结果

这带来了三个结构性变化:

  1. 确定性计算从模型转移到代码;
  2. 大量中间数据从模型上下文转移到 runtime;
  3. 多工具调用从串行模型往返转为可并行的程序编排。

从 Agent 工程角度看,PTC 不是单纯的性能优化,而是模型、程序与工具之间职责边界的一次重新划分。


代码索引

1. exec / Code Mode / PTC 端到端架构

控制流平面与能力平面分离:模型生成 JavaScript,V8 Cell 负责 Promise 编排,实际能力调用仍经过 Dispatch Broker 与 ToolRouter。

2. 单个 exec Cell 的运行时生命周期

展示 Fresh Isolate、top-level await、PromiseResolver、RuntimeEvent、microtask checkpoint,以及 yield / wait / terminate 的状态变化。

3. 传统 Tool Calling 与 PTC 对比

传统方式需要多次模型往返并持续吸收原始结果;PTC 可以并行 fan-out,并在 runtime 中完成过滤、关联与 Top-N,只向模型返回紧凑结果。