# Code mode · 让模型写 JavaScript 编排工具

> Code mode 给模型一个 exec 工具：模型写一段 JavaScript，在独立宿主进程里的 V8 isolate 中执行，脚本通过全局 tools 对象调用 Codex 的其它工具，中间结果留在脚本里，只把最后的文本、图片交还模型。它在 0.158.0 仍是开发中特性，由 code_mode / code_mode_only 开关或模型目录打开；宿主 codex-code-mode-host 默认经 stdin/stdout 与 Codex 通信，也能以 gRPC 服务的形式远程部署。

- 作者：David（道雾轩）
- 专栏：Codex 源码解读（https://daiw.net/manual/codex-source.md）
- 最后更新：2026-09-29
- 原文：https://daiw.net/manual/codex-source/code-mode
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

# Code mode · 让模型写 JavaScript 编排工具

前几篇的工具都是“一次调用、一次往返”：模型要先读三个文件、再根据结果决定调哪个 MCP 工具，就得来回采样好几次，每个中间结果都流经上下文。Code mode 换了一种思路：给模型一个能跑 JavaScript 的 `exec` 工具，让它把编排逻辑写成程序，工具调用在程序里完成。这一篇讲它怎么开、模型看到什么、代码在哪里跑、嵌套的工具调用又怎样回到 Codex。它还在开发中，手册只在 [MCP 集成](https://daiw.net/manual/codex/mcp)（参数类型的预算）和 [SDK 与 App Server](https://daiw.net/manual/codex/sdk-app-server)（`--code-mode-host`）两处顺带提到。

## 怎么开启

Code mode 在 0.158.0 里还是开发中的特性，默认关闭，有两档：

- `[features] code_mode = true`：工具模式变为 `CodeMode`，原有工具照旧直接暴露，另外多出 `exec` 与 `wait`，模型可以任选直接调用还是写脚本调用；
- `code_mode_only = true`：工具模式变为 `CodeModeOnly`，能在脚本里调用的工具都从直接清单里撤下，模型面前只剩 `exec`、`wait` 和少数只许直接调用的工具（如 `request_user_input`）。打开它会顺带打开 `code_mode`。

更细的设置写在 `[features.code_mode]` 表里：`default_exec_yield_time_ms`（`exec` 默认多久让出一次，缺省 30000 毫秒）、`tool_input_schema_max_bytes`（渲染参数类型的预算，最少 16000 字节）、`excluded_tool_namespaces`（不进脚本的命名空间）、`direct_only_tool_namespaces`（只直接调用的命名空间）。模型目录也能直接指定模式，优先级高于开关：

```rust
pub(crate) fn requested_tool_mode(turn_context: &TurnContext, model_info: &ModelInfo) -> ToolMode {
    model_info.tool_mode.unwrap_or_else(|| {
        if turn_context.config.features.enabled(Feature::CodeModeOnly) {
            ToolMode::CodeModeOnly
        } else if turn_context.config.features.enabled(Feature::CodeMode) {
            ToolMode::CodeMode
        } else {
            ToolMode::Direct
        }
    })
}

pub(crate) fn effective_tool_mode(turn_context: &TurnContext, model_info: &ModelInfo) -> ToolMode {
    let requested_tool_mode = requested_tool_mode(turn_context, model_info);
    if !turn_context.code_mode_available
        && requested_tool_mode == ToolMode::CodeMode
        && !turn_context.config.code_mode.disable_in_process_fallback
    {
        ToolMode::Direct
    } else {
        requested_tool_mode
    }
}
```

（`codex-rs/core/src/tools/mod.rs:74`）

`code_mode_available` 取决于能不能找到宿主程序 `codex-code-mode-host`（先找安装包的资源目录，再找 `codex` 可执行文件旁边），宿主开关 `code_mode_host` 默认开。找不到时，`CodeMode` 退回普通工具，`CodeModeOnly` 则“fail closed”，不退回；两种情况都会提示一次 “enable `features.code_mode_host` and install `codex-code-mode-host`”。`[features.code_mode_host]` 里的 `disable_in_process_fallback` 可以让 `CodeMode` 也不退回。

## 模型看到什么

`exec` 是一个 `custom` 工具，语法只有两部分：可选的第一行 pragma `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}`，其后是 JavaScript 源码，不许包 JSON、引号或 Markdown 代码块。它的描述模板开宗明义：

```rust
const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/compose tool calls
- Evaluates the provided JavaScript code in a fresh V8 isolate as an async module.
- All nested tools are available on the global `tools` object, for example `await tools.exec_command(...)`. Tool names are exposed as normalized JavaScript identifiers, for example `await tools.mcp__ologs__get_profile(...)`.
- Nested tool methods take either a string or an object as their input argument.
- Nested tools return either an object or a string, based on the description.
- Runs raw JavaScript -- no Node, no file system, no network access, no console.
```

（`codex-rs/code-mode-protocol/src/description.rs:20`）

模板后面还列了一组全局函数：`text()`、`image()`、`audio()` 往结果里追加内容，`store()` / `load()` 在同一会话的多次 `exec` 之间存取值，`notify()` 立刻插入一条额外的工具输出，`yield_control()` 让出控制、脚本继续跑，`setTimeout()`、`exit()`、`ALL_TOOLS` 等。`import` 一律报 `Unsupported import in exec`。

每个可嵌套的工具都被改写了描述，末尾附一段由 JSON Schema 渲染成的 TypeScript 声明，形如 `declare const tools: { exec_command(args: ...): Promise<...> }`，MCP 工具的返回类型写成 `CallToolResult<...>`。工具在脚本里的名字一般是“命名空间 + `__` + 工具名”，再把 JavaScript 标识符里不允许的字符换成 `_`。`exec` 让出时返回 `Script running with cell ID ...`，模型用 `wait`（参数 `cell_id`、`yield_time_ms`、`max_tokens`、`terminate`）继续等或终止；跑完则是 `Script completed` 或带 `Script error:` 的 `Script failed`，结果默认截到 10000 token。

## 代码在哪里跑

```mermaid
sequenceDiagram
  participant M as 模型
  participant C as Codex 内核
  participant H as codex-code-mode-host
  participant V as V8 isolate
  M->>C: exec JavaScript 源码
  C->>H: 首次使用时拉起宿主 · 打开会话
  C->>H: Execute 附带可用工具清单
  H->>V: 新线程 新 isolate 执行模块
  V->>H: tools.x 调用 · RuntimeEvent::ToolCall
  H->>C: DelegateRequest::InvokeTool
  C->>C: 本轮 worker 按普通工具调用分派
  C-->>H: 工具结果 JSON
  H-->>V: 兑现 Promise
  V-->>H: 结束或让出
  H-->>C: RuntimeResponse
  C-->>M: Script completed 加 text 与 image 输出
```

执行涉及四个 crate：`codex-code-mode-protocol` 定义会话接口、宿主消息与 gRPC 协议，`codex-code-mode-runtime` 用 `v8` crate 跑 JavaScript，`codex-code-mode-host` 是宿主程序，`codex-code-mode` 是 Codex 这一侧的客户端。内核里每个线程有一个 `CodeModeService`，第一次用到时才通过 `CodeModeSessionProvider` 建会话。默认的 `ProcessOwnedCodeModeSessionProvider` 把宿主作为子进程拉起：Unix 上放进独立的进程组，`kill_on_drop`，清掉不该继承的环境变量，再经 stdin/stdout 上的分帧协议握手（限时 30 秒），协商协议版本与能力。

宿主里的 `InProcessCodeModeSession` 为每个 cell 起一条系统线程、新建一个 V8 isolate 与 context。V8 全进程只初始化一次，可以关掉 JIT（`--jitless`）；为绕开所用 V8 版本在数组排序上的一个问题，初始化时还关掉了 Maglev 等优化路径，注释里附了上游修复的链接。脚本调用 `tools.x()` 时，运行时先造一个 Promise，把调用作为事件发出去，结果回来再兑现：

```rust
    let Some(resolver) = v8::PromiseResolver::new(scope) else {
        throw_type_error(scope, "failed to create tool promise");
        return;
    };
    let promise = resolver.get_promise(scope);

    let resolver = v8::Global::new(scope, resolver);
    // ...
    let id = format!("tool-{}", state.next_tool_call_id);
    state.next_tool_call_id = state.next_tool_call_id.saturating_add(1);
    let event_tx = state.event_tx.clone();
    state.pending_tool_calls.insert(id.clone(), resolver);
    let _ = event_tx.send(RuntimeEvent::ToolCall {
        id,
        name: tool_name,
        kind: tool_kind,
        input,
    });
    retval.set(promise.into());
```

（`codex-rs/code-mode-runtime/src/runtime/callbacks.rs:40`）

## 嵌套调用回到 Codex

事件经宿主的 `RemoteDelegate` 变成一条 `DelegateRequest::InvokeTool` 发回 Codex，落到 `CodeModeCellDelegate::invoke_tool`，再排进一个分派队列。每次采样开始时，`run_sampling_request` 会调用 `start_turn_worker` 为本轮起一个 worker，它有自己的 `ToolCallRuntime`，从队列里取出调用，交给 `submit_nested_tool`：

```rust
    let result = tool_runtime.handle_tool_call_with_source(
        step_context,
        call,
        ToolCallSource::CodeMode {
            cell_id: cell_id.to_string(),
            runtime_tool_call_id,
        },
        cancellation_token,
        Arc::default(),
    );
    Ok(async move { Ok(result.await?.code_mode_result()) })
```

（`codex-rs/core/src/tools/code_mode/mod.rs:400`）

也就是说，脚本里的每次工具调用都走和模型直接调用完全相同的[分派链](https://daiw.net/manual/codex-source/tool-architecture)：注册表查找、hook、审批、沙箱一样不少，只是来源标成 `ToolCallSource::CodeMode`，结果用 `code_mode_result()` 转成 JSON 而不是写进历史。以 `exec_command` 为例，脚本拿到的是含 `exit_code`、`session_id`、`output` 等字段的对象。`exec` 不能在脚本里调用自己。一个 cell 可以跨轮存活：让出之后，它后续的工具调用等到有 worker 在跑时才会被分派。

除了默认的子进程模式，宿主还能以网络服务运行：`codex-code-mode-host --listen grpc://IP:PORT` 提供 gRPC 服务 `CodeModeHost`，app-server 可以配置成通过 `GrpcCodeModeSessionProvider` 连过去（要求开启 `code_mode_host`）。协议把会话事件、工具调用订阅与每个工具结果放在各自的 HTTP/2 流上：

```text
service CodeModeHost {
  // Opens a session lease. The first event is always SessionOpened; dropping
  // this stream closes the session and terminates its active cells.
  rpc OpenSession(OpenSessionRequest) returns (stream SessionEvent);
  rpc CloseSession(CloseSessionRequest) returns (CloseSessionResponse);
  // ...
  rpc SubscribeToToolCalls(SubscribeToToolCallsRequest)
      returns (stream ToolCall);
  // ...
  rpc CompleteToolCall(CompleteToolCallRequest)
      returns (CompleteToolCallResponse);
  // ...
  rpc Execute(ExecuteRequest) returns (stream ExecuteEvent);
  rpc Wait(WaitRequest) returns (WaitResponse);
```

（`codex-rs/code-mode-protocol/src/grpc/codex.code_mode.v1.proto:8`）

文件头的注释说明了这样拆分的理由：大的工具输入输出不挤在会话事件流里，各个流可以并发推进，一个大结果不会堵住其它工具的完成或会话控制事件。

## 和同类实现对照

[OpenCode 源码解读](https://daiw.net/manual/opencode/web-and-codemode)里的 Code Mode 走的是另一条路：自己用 Acorn 解析、树遍历解释一个为编排裁剪的 JavaScript 子集，不用 `eval`。Codex 则直接嵌入 V8，把完整的 JavaScript 语义放进独立的宿主进程里跑，用进程边界隔离，并允许宿主部署到别处。两者的共同点是：脚本只能碰到宿主给的 `tools`，授权仍由每个叶子工具自己的审批与沙箱负责。和[《从 LLM 到 Coding Agent》](https://daiw.net/manual/llm-to-agent/tool-use)的基本闭环相比，Code mode 把“模型发一个调用、宿主执行、结果回填”的循环挪进了模型写的程序里，减少了采样往返，也减少了流经上下文的中间结果。

---

上一篇：[其它内置工具 · 计划、提问、权限与搜索](https://daiw.net/manual/codex-source/builtin-tools) · 下一篇：[权限模型 · 沙箱策略与审批策略](https://daiw.net/manual/codex-source/permissions-model)
