Skip to content

工具并行判定

区分模型请求的 parallel_tool_calls 与 runtime 的并行安全声明,追踪 Registry 查询到 ToolCallRuntime 读写锁的完整判定。

基于rust-v0.150.0
CodexRustToolsConcurrency

工具并行判定 ​

Codex 中的“支持并行工具调用”有两层含义。第一层是模型请求是否允许一次 response 产生多个 tool calls;第二层是 这些调用进入本地执行器后,某个 runtime 是否可以与其他工具同时执行。前者来自 ModelInfo,后者来自 ToolExecutor::supports_parallel_tool_calls()、Registry exposure 和精确的 ToolName lookup。只满足其中一层, 都不能推出工具一定并发运行。

本文面向已经读过ToolOrchestrator执行流程和 ToolRegistry数据结构的读者。本文只分析并行判定和执行 gate,不展开多个 future 如何 join、结果如何排序,也不把“parallel-safe”理解成业务副作用天然无冲突。读完后,读者应能从一个具体 handler 追到最终读锁/写锁,解释 Hidden、namespace、MCP server opt-in 和 Responses Lite 对并行的不同影响。

1. 两个开关 ​

1.1 请求开关 ​

Prompt.parallel_tool_calls 表示模型请求层是否允许并行 tool calls。build_prompt 直接读取 turn_context.model_info.supports_parallel_tool_calls;请求构造时还会在 Responses Lite 下强制关闭。

相关源码:

  • codex-rs/core/src/session/turn.rs :: build_prompt
  • codex-rs/core/src/client.rs :: build_responses_request
rust
Prompt {
    input,
    tools: router.model_visible_specs(),
    parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls,
    base_instructions,
    output_schema: turn_context.final_output_json_schema.clone(),
    output_schema_strict: !crate::guardian::is_basic_session_source(
        &turn_context.session_source,
    ),
}

请求序列化时使用:

源码位置:codex-rs/core/src/client.rs :: build_responses_request

rust
parallel_tool_calls: prompt.parallel_tool_calls && !model_info.use_responses_lite,

因此模型能力为 true 但 use_responses_lite 为 true 时,最终 wire 仍是 false。这个开关只影响模型能否在一个响应中 提出多个调用,不决定本地 handler 之间的锁。

右侧 gate 仍可能被 Code Mode 等非当前模型 response 来源使用,因此 wire flag 为 false 也不等于运行时永远不会 同时收到多个 future;两层必须分别诊断。

1.2 Runtime开关 ​

每个 ToolExecutor 默认不支持并行;具体 runtime 必须显式覆盖。这个声明描述 handler 是否愿意进入共享读锁, 而不是描述 provider 请求能力。

源码位置:codex-rs/tools/src/tool_executor.rs :: ToolExecutor::supports_parallel_tool_calls

rust
pub trait ToolExecutor<Invocation>: Send + Sync {
    fn tool_name(&self) -> ToolName;

    fn spec(&self) -> ToolSpec;

    fn supports_parallel_tool_calls(&self) -> bool {
        false
    }

    fn handle(&self, invocation: Invocation) -> ToolExecutorFuture<'_>;
}

2. 判定链 ​

2.1 Handler声明 ​

Unified Exec 的 exec_command 和 write_stdin 都显式返回 true;MCP resource list/read 也允许并行。未覆盖方法的 Plan、ApplyPatch 等 runtime 使用默认 false。

相关源码:

  • codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs :: ExecCommandHandler
  • codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs :: WriteStdinHandler
  • codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs :: ListMcpResourcesHandler
rust
impl ToolExecutor<ToolInvocation> for ExecCommandHandler {
    fn supports_parallel_tool_calls(&self) -> bool {
        true
    }
}

impl ToolExecutor<ToolInvocation> for WriteStdinHandler {
    fn supports_parallel_tool_calls(&self) -> bool {
        true
    }
}

显式 true 只表示可以进入共享读锁。exec_command 仍会在自身内部按 session/process manager、sandbox、审批和文件 系统策略处理资源;公共 gate 不替代这些细粒度同步。

2.2 Adapter传播 ​

Extension adapter 不自行猜测并行性,而是把 extension executor 的声明原样向 Core 传播。MCP handler 则读取 ToolInfo.supports_parallel_tool_calls,该值来自 MCP server/tool 配置与发现结果。

相关源码:

  • codex-rs/core/src/tools/handlers/extension_tools.rs :: ExtensionToolAdapter
  • codex-rs/core/src/tools/handlers/mcp.rs :: McpHandler
rust
impl ToolExecutor<ToolInvocation> for ExtensionToolAdapter {
    fn supports_parallel_tool_calls(&self) -> bool {
        self.0.supports_parallel_tool_calls()
    }
}

impl ToolExecutor<ToolInvocation> for McpHandler {
    fn supports_parallel_tool_calls(&self) -> bool {
        self.tool_info.supports_parallel_tool_calls
    }
}

这使并行声明跟随具体 runtime,而不是按“所有 MCP 都并行”或“所有 extension 都串行”的粗粒度分类。

2.3 Registry修正 ​

Registry 用 canonical name 查找 RegisteredTool,然后同时检查 exposure 与 runtime 声明。Hidden exposure 强制 false;查找不到工具返回 None,Router 再用 unwrap_or(false) 收敛为 false。

源码位置:codex-rs/core/src/tools/registry.rs :: supports_parallel_tool_calls

rust
pub(crate) fn supports_parallel_tool_calls(&self, name: &ToolName) -> Option<bool> {
    let tool = self.tools.get(&name.clone().with_default_namespace())?;
    Some(
        tool.exposure != ToolExposure::Hidden
            && tool.runtime.supports_parallel_tool_calls(),
    )
}

源码位置:codex-rs/core/src/tools/router.rs :: tool_supports_parallel

rust
pub fn tool_supports_parallel(&self, call: &ToolCall) -> bool {
    self.registry
        .supports_parallel_tool_calls(&call.tool_name)
        .unwrap_or(false)
}

3. 名称隔离 ​

并行查询使用完整 canonical ToolName。默认 exec_command 声明 parallel-safe,不会让 mcp__server__.exec_command 自动获得同样能力;后者必须在自己的 MCP runtime metadata 中 opt in。

源码位置:codex-rs/core/src/tools/router_tests.rs :: parallel_support_does_not_match_namespaced_local_tool_names

rust
assert!(router.tool_supports_parallel(&ToolCall {
    tool_name: ToolName::plain(parallel_tool_name),
    call_id: "call-local-tool".to_string(),
    payload: ToolPayload::Function {
        arguments: "{}".to_string(),
    },
    encrypted_function_args: None,
}));

assert!(!router.tool_supports_parallel(&ToolCall {
    tool_name: ToolName::namespaced("mcp__server__", parallel_tool_name),
    call_id: "call-namespaced-tool".to_string(),
    payload: ToolPayload::Function {
        arguments: "{}".to_string(),
    },
    encrypted_function_args: None,
}));

这种隔离阻止“同名即同能力”的错误传播。工具名称的 namespace 不只是 wire 展示,也参与 runtime 和并行 metadata 查找。

4. Exposure影响 ​

4.1 Hidden ​

Hidden runtime 仍能通过 tool_runtime 查到,供兼容或内部路径使用,但 supports_parallel_tool_calls 返回 false。 这避免一个不在普通模型暴露面上的 legacy runtime 被当作当前 direct 调用的 parallel-safe 工具。

4.2 CodeModeOnly ​

CodeModeOnly 不等于 Hidden。只要 runtime 自己 opt in,Registry 查询仍可返回 true,因为它会被 Code Mode 嵌套调用。 测试同时覆盖 Hidden=false 和 CodeModeOnly=true 的差异。

源码位置:codex-rs/core/src/tools/router_tests.rs :: mcp_parallel_support_uses_handler_data

rust
let hidden_call = ToolCall {
    tool_name: ToolName::namespaced("mcp__hidden_echo__", "query_with_delay"),
    call_id: "call-hidden".to_string(),
    payload: ToolPayload::Function {
        arguments: "{}".to_string(),
    },
    encrypted_function_args: None,
};
assert!(!router.tool_supports_parallel(&hidden_call));
assert!(router.tool_runtime(&hidden_call).is_some());

let nested_only_call = ToolCall {
    tool_name: ToolName::namespaced("mcp__nested_echo__", "query_with_delay"),
    call_id: "call-nested".to_string(),
    payload: ToolPayload::Function {
        arguments: "{}".to_string(),
    },
    encrypted_function_args: None,
};
assert!(router.tool_supports_parallel(&nested_only_call));

4.3 Hosted工具 ​

Hosted web_search 没有 Registry runtime,因此 local parallel query 返回 false。它是否由 provider 并行执行不属于 本地 ToolCallRuntime 的 RwLock 模型。

源码位置:codex-rs/core/src/tools/router_tests.rs :: tools_without_handlers_do_not_support_parallel

rust
assert!(!router.tool_supports_parallel(&ToolCall {
    tool_name: ToolName::plain("web_search"),
    call_id: "call-web-search".to_string(),
    payload: ToolPayload::Function {
        arguments: "{}".to_string(),
    },
    encrypted_function_args: None,
}));

5. 执行Gate ​

5.1 共享RwLock ​

ToolCallRuntime 为一个 Step 的工具调用共享同一个 Arc<RwLock<()>>。parallel-safe 调用拿 read lock,多个 read lock 可以共存;非 parallel-safe 调用拿 write lock,与所有 read/write holder 互斥。

源码位置:codex-rs/core/src/tools/parallel.rs :: ToolCallRuntime::handle_tool_call_with_source

rust
let supports_parallel = router.tool_supports_parallel(&call);
let lock = Arc::clone(&self.parallel_execution);

let _guard = if supports_parallel {
    Either::Left(lock.read().await)
} else {
    Either::Right(lock.write().await)
};

router
    .dispatch_tool_call_with_terminal_outcome(/* ... */)
    .await

锁覆盖 Registry dispatch、Hook 和 handler 执行,guard 在整个 async block 结束后释放。因此一个 write-lock 工具会 等待已运行的 parallel calls 完成,也会阻止新的 read-lock 调用进入。

5.2 不做批次预分组 ​

当前 gate 不先扫描一组调用并构造“并行批次”;每个 future 独立查询 metadata 并竞争同一 RwLock。由锁语义自然 形成并发区间:连续 read holders 可重叠,write holder 成为屏障。future 的 join 和输出顺序属于下一篇专题。

5.3 取消等待 ​

取消 token 与 dispatch task 通过 tokio::select! 竞争。调用如果还在 readiness 或 lock wait 中,取消可以终止 dispatch task并返回 aborted output;它不会因为标记 parallel-safe 就绕过取消终态保护。

6. 副作用边界 ​

supports_parallel_tool_calls = true 是 runtime 作者的声明,不是静态纯函数证明。例如:

  • 两个 exec_command 可以并发,但如果同时写同一文件,业务副作用仍可能冲突;
  • 两个 write_stdin 可以并发访问不同 session,具体 process manager 仍需按 session id 保证安全;
  • MCP server opt in 表示服务器/工具声明可并发,客户端不会额外推导远端事务冲突;
  • read-only MCP resource handler 适合并发,但网络连接池、服务端限流仍可能导致等待或失败。

公共 RwLock 只提供“parallel-safe 与 serial 工具之间的粗粒度隔离”。资源级锁、文件系统事务、session id 和远端 服务能力仍由 handler/runtime 自己负责。

7. 测试路径 ​

7.1 Router判定 ​

mcp_parallel_support_uses_handler_data 同时构造 parallel true、false、Hidden 和 CodeModeOnly MCP runtime,断言 并行结果来自精确 handler metadata 与 exposure。parallel_support_does_not_match_namespaced_local_tool_names 证明 默认工具的能力不会泄漏给同名 namespace 工具。

相关测试:

  • codex-rs/core/src/tools/router_tests.rs :: mcp_parallel_support_uses_handler_data
  • codex-rs/core/src/tools/router_tests.rs :: parallel_support_does_not_match_namespaced_local_tool_names
  • codex-rs/core/src/tools/router_tests.rs :: tools_without_handlers_do_not_support_parallel

7.2 MCP集成 ​

stdio_mcp_parallel_tool_calls_default_false_runs_serially 使用默认 false 的 MCP server,观察两个延迟调用串行完成; stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently 将 capability 设为 true,断言两个调用并发完成。它们证明 server/tool opt-in 会穿过 ToolInfo、McpHandler、Registry 和 RwLock,而不是只停留在配置对象。

相关测试:

  • codex-rs/core/tests/suite/rmcp_client.rs :: stdio_mcp_parallel_tool_calls_default_false_runs_serially
  • codex-rs/core/tests/suite/rmcp_client.rs :: stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently

7.3 Code Mode集成 ​

code_mode_nested_tool_calls_can_run_in_parallel 同时包含两类断言:先用 barrier 和总耗时检查两个 nested calls 是否重叠,再检查 custom tool output 的 content-item 形状和 text(JSON.stringify(results)) 结果。因此它不是一个 纯粹的并发基准:耗时断言与输出 adapter 断言必须分别定位,后置输出形状失败不能直接推翻前面的并发时序,反之 只满足耗时也不能宣称完整 Code Mode 集成通过。

源码位置:codex-rs/core/tests/suite/code_mode.rs :: code_mode_nested_tool_calls_can_run_in_parallel

8. 阅读练习 ​

在 Codex 源码 workspace 中运行:

bash
cargo test -p codex-core mcp_parallel_support_uses_handler_data
cargo test -p codex-core parallel_support_does_not_match_namespaced_local_tool_names
cargo test -p codex-core stdio_mcp_parallel_tool_calls_default_false_runs_serially
cargo test -p codex-core stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently
cargo test -p codex-core code_mode_nested_tool_calls_can_run_in_parallel --test all -- --nocapture

然后尝试回答:

  1. 模型请求 parallel_tool_calls=true,但两个 handler 都使用默认 false 时,本地执行会怎样?
  2. 一个 Hidden runtime 声明 true,为什么 Router 仍返回 false?CodeModeOnly 为什么不同?
  3. 三个 future 按 A(parallel)、B(serial)、C(parallel) 到达锁时,哪些可以重叠,哪个形成屏障?
  4. MCP server opt-in 从 ToolInfo 到 read lock 经过哪些对象?
  5. 为什么 parallel-safe 不能证明两个工具同时修改同一文件是安全的?

9. 边界 ​

并行判定只决定模型请求许可和本地 execution gate,不负责:

  • 多个 future 的 join、错误隔离和输出排序;
  • handler 内部资源锁、数据库事务、文件冲突和远端限流;
  • approval、sandbox、取消 teardown 和 lifecycle 终态;
  • provider 是否真的产生多个 tool calls。

排查“模型没有并行”“调用仍然串行”或“并发后副作用冲突”时,应分别检查 Prompt.parallel_tool_calls → canonical ToolName → RegisteredTool.exposure → runtime flag → RwLock admission → handler内部资源, 不要用单一的 supports_parallel_tool_calls 字段解释整条链路。