Codex 源码导读:第九部分——工具路由与结果回写

0 阅读5分钟

统一工具注册、路由与并行执行

本章回答一个问题:模型返回工具调用后,Codex 如何从工具名找到真正的执行代码,并把结果送回下一轮模型请求。

先区分三个对象

ToolRegistry
    保存系统实际拥有的工具执行器

model_visible_specs
    保存本轮允许模型看到的工具定义

ToolRouter
    把模型返回的 tool-call 路由到 Registry 中的执行器

Registry 和 model_visible_specs 不是同一张表:工具可以已经注册,但因为权限、工具模式或暴露级别,本轮不一定会出现在模型请求的 tools 字段里。

工具路由与结果回写流程图

1. 本轮先构建工具注册表

源码:spec_plan.rs

let mut registry = ToolRegistry::default();
// Registry 是本轮所有可执行工具的总表

add_core_tool_sources(&context, &mut registry);
// 注册 Codex 内置工具:exec_command、write_stdin、view_image、协作工具等

MCP 和扩展工具也进入这张表:

for tool in mcp_tools {
    registry.register_external_with_exposure(
        tool.runtime,
        tool.exposure,
    );
}
// MCP 工具已经被包装成统一的运行时对象

append_extension_tool_executors(
    turn_context,
    model_info,
    extension_tool_executors,
    registry,
);
// 扩展工具也加入同一个 Registry

源码:spec_plan.rs

注册后的对象包含两部分:

pub struct RegisteredTool {
    pub(crate) runtime: Arc<dyn CoreToolRuntime>,
    pub(crate) exposure: ToolExposure,
}

源码:registry.rs

runtime
    真正负责执行工具

exposure
    决定工具是直接暴露、延迟暴露,还是隐藏

2. 从完整注册表筛出模型可见工具

for tool in registry.entries() {
    let exposure = tool.exposure;

    if !exposure.is_direct() {
        continue;
    }

    let spec = tool.runtime.spec();
    specs.push(spec_for_model_request(..., spec));
}

源码:spec_plan.rs

这一步生成的是 ToolSpec,随后放入模型请求的 tools 字段:

ToolRegistry
    → 根据 ToolExposure、工具模式、冲突规则筛选
    → build_model_visible_specs()
    → ToolSpec[]
    → 本轮模型请求的 tools

因此模型看到的是“工具定义”,而不是 Rust 中的执行器对象。

3. 模型返回 tool-call 后转换为内部对象

模型协议中的工具调用类似:

{
  "type": "function_call",
  "name": "exec_command",
  "arguments": "{\"cmd\":\"ls\"}",
  "call_id": "call_123"
}

ToolRouter::build_tool_call() 将它转换为 Codex 内部的 ToolCall:

let tool_name =
    ToolName::new(namespace, name)
        .with_default_namespace();

Ok(Some(ToolCall {
    tool_name,
    call_id,
    payload: ToolPayload::Function { arguments },
}))

源码:router.rs

4. 一轮模型可以产生多个工具调用

流式响应中,每完成一个工具输出项,Codex 就记录并创建一个工具 Future:

if let Some(tool_future) = output_result.tool_future {
    in_flight.push_back(tool_future);
}

源码:turn.rs

因此一轮模型响应可以形成:

tool-call-1 → ToolFuture-1 → in_flight
tool-call-2 → ToolFuture-2 → in_flight
tool-call-3 → ToolFuture-3 → in_flight

这里的 in_flight 是结果收集队列,不是等三个调用全部入队后才开始执行的任务队列。handle_tool_call 内部会立即 tokio::spawn;模型还在继续输出时,已完成参数的工具调用就可能开始执行。

5. 根据工具能力决定并行还是串行

let supports_parallel =
    router.tool_supports_parallel(&call);

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

源码:parallel.rs

含义是:

支持并行的工具
    → 获取 RwLock 读锁
    → 多个工具可以同时进入执行区

不支持并行的工具
    → 获取 RwLock 写锁
    → 独占执行,其他工具等待

所以实际情况是:

每个工具调用参数完成
  ├─ 创建工具任务 → 按并行能力争用读锁或写锁 → 执行
  └─ 将对应 Future 加入 in_flight,稍后按序收集结果

6. ToolRouter 将 ToolCall 绑定成 ToolInvocation

let invocation = ToolInvocation {
    session,
    turn,
    step_context,
    cancellation_token,
    tracker,
    call_id,
    tool_name,
    source,
    payload,
};

源码:router.rs

ToolInvocation 不只是工具参数,还带着执行上下文:

Session
当前 Turn / Step
权限和环境
取消信号
文件变更跟踪器
tool-call ID
工具名和参数

之后统一进入:

self.registry
    .dispatch_any_with_terminal_outcome(
        invocation,
        terminal_outcome_reached,
    )
    .await

7. Registry 统一校验并调用具体 Handler

let tool = match self.tool(&tool_name) {
    Some(tool) => tool,
    None => {
        return Err(
            FunctionCallError::RespondToModel(
                unsupported_tool_call_message(...),
            )
        );
    }
};
// 根据模型返回的工具名找到注册的 runtime

随后检查参数类型、执行前 Hook,并最终调用具体实现:

let output =
    tool.handle(invocation.clone()).await?;
// 这里才真正执行本地命令、文件操作、MCP 请求或子 Agent 操作

源码:registry.rs

权限、审批和沙箱不是由 ToolRouter 直接完成的;它们会在统一分派之后,由具体工具处理链和安全模块继续判定。

8. MCP 与本地工具在同一条主链上

MCP Handler 同样实现 CoreToolRuntime,只是在最终 handle() 内部调用 MCP Server:

let prepared_mcp_call = invocation
    .session
    .prepare_mcp_call(
        &self.tool_info.server_name,
        self.tool_info.tool.name.as_ref(),
    )
    .await;

let result = handle_mcp_tool_call(
    ...,
    prepared_mcp_call,
    ...,
)
.await;

源码:handlers/mcp.rs

所以差异只在最后的具体执行动作:

exec_command  → 受沙箱控制的本地进程
文件工具      → 工作区文件系统
MCP 工具      → MCP Server
spawn_agent   → 创建子 Agent 并发送初始任务

它们之前的路径相同:

模型 tool-call
    → ToolCall
    → ToolRouter
    → ToolInvocation
    → ToolRegistry
    → CoreToolRuntime::handle()

9. 工具结果先收口,再进入下一轮模型请求

正常处理返回的工具结果统一包装成 AnyToolResult;错误则通过对应的错误转换路径,形成模型可见的失败输出或向上报错:

pub(crate) struct AnyToolResult {
    pub(crate) call_id: String,
    pub(crate) payload: ToolPayload,
    pub(crate) result: Box<dyn ToolOutput>,
    pub(crate) post_tool_use_payload: Option<PostToolUsePayload>,
}

然后转换成模型协议中的 ResponseItem:

ResponseItemEnvelope {
    item: result
        .to_response_item(&call_id, &payload)
        .into(),
    metadata: ...,
}

源码:registry.rs

工具 Future 最后由 FuturesOrdered 收口:

while let Some(res) = in_flight.next().await {
    if let Ok(envelope) = res {
        sess.record_annotated_conversation_items(
            &turn_context,
            vec![envelope],
        )
        .await;
    }
}

源码:turn.rs

这里要区分两件事:

执行层面
    工具可能并行执行

结果收集层面
    按入队顺序收口并写入 Session

因此一轮多个工具的完整过程是:

模型流中先后完成 3 个 tool-call
        ↓
每完成一个调用,就启动工具任务,并把 Future 加入 in_flight
        ↓
工具按并行能力争用执行锁;执行可与剩余模型流重叠
        ↓
模型流收尾后,按入队顺序等待并收集工具结果
        ↓
工具结果写入 Session,编译进下一轮模型上下文
        ↓
模型继续输出文本,或再次产生 tool-call

源码基线:OpenAI Codex d58d0e5841e0de08e251673db2d5af8cf3a1ad51。文中的流程图用于标出本篇所处的运行阶段。