工具系统总览 · 暴露、注册、路由与并行

Codex 在每次采样前重新规划一遍工具:spec_plan 依据特性开关、模型目录、提供方能力与执行环境挑出工具登记进 ToolRegistry,再决定每个工具是直接给模型、留给 tool_search 还是只给 Code mode。模型发出的调用在流还没结束时就被派发,一把读写锁区分可并行与需独占的工具,再经 hook、处理器与编排器落地。

作者 David更新于 第 20 篇(共 57 篇)

工具系统总览 · 暴露、注册、路由与并行

run_turn 主循环里,模型每要一次工具,Codex 就得回答四个问题:这一步给模型看哪些工具(规划与暴露),模型点名的工具由谁处理(注册与路由),几个调用能不能同时跑(并行),会改动系统的操作要不要先问人、放不放进沙箱(编排)。这一篇把四件事串成一张地图,后面六篇再分头深入。

用户看到的样子

在界面上,工具调用就是历史记录里的一条条条目:跑了什么命令、改了哪些文件、调了哪个 MCP 工具、搜了什么网页。决定“这一轮有哪些工具”的配置散在几处:[features] 里的开关(shell_tool、unified_exec、view_image 默认开,code_mode、request_permissions_tool 默认关,见配置参考);顶层的 web_search(disabled、cached、indexed、live)决定带不带托管的网页搜索;tools.update_plan.enabled 控制计划工具,不写就是关;[mcp_servers.<名字>] 里的每个服务器都会把自己的工具加进来(见 MCP);PreToolUse、PostToolUse hook 还能在执行前后拦截调用(见 Hooks)。

两层代码:codex-tools 与 core/src/tools

codex-tools crate 的文件头注释说,这里放的是“可以放在 codex-core 之外”的共享工具定义与 Responses API 原语:ToolSpec(序列化后就是请求里的一个 tool,分 function、namespace、tool_search、web_search、custom 五种,custom 在 Rust 里叫 Freeform,apply_patch 与 Code mode 的 exec 都是它)、ToolExposure、JSON Schema 的清洗与压缩(MCP 与动态工具的输入 schema 超过 5,000 字节时,依次去掉描述、去掉 $defs,再把第 3 层以下的复杂子结构和 anyOf 一类组合整块换成空 schema,直到放得下),以及给延迟加载工具生成检索文本的 tool_search。核心契约是 ToolExecutor:

pub trait ToolExecutor<Invocation>: Send + Sync {
    /// The concrete tool name handled by this runtime instance.
    fn tool_name(&self) -> ToolName;

    fn spec(&self) -> ToolSpec;

    /// The preferred exposure before the host applies step-specific policy.
    fn exposure(&self) -> ToolExposure {
        ToolExposure::Direct
    }

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

    /// Handles one invocation without retaining capabilities borrowed by the host.
    fn handle<'a>(&'a self, invocation: Invocation) -> ToolExecutorFuture<'a>
    where
        Invocation: 'a;
}

(codex-rs/tools/src/tool_executor.rs:106)

spec 与执行逻辑绑在同一个对象上,模型看到的描述和实际处理器不会走散;supports_parallel_tool_calls 默认 false,并发要工具自己声明。codex-core 在它之上加了 CoreToolRuntime(codex-rs/core/src/tools/registry.rs:56),补上 hook 负载、遥测标签、所属 MCP 服务器、流式参数 diff 等可选能力。core/src/tools/ 下的分工是:spec_plan.rs 规划,registry.rs 注册与分派,router.rs 路由,parallel.rs 并发,orchestrator.rs 审批加沙箱,handlers/ 是各工具的处理器,runtimes/ 是真正起进程、写文件的执行后端。

每次采样前重建工具表

工具表不是会话级的常量。run_turn 每发一次模型请求前都会捕获一个 StepContext,注释写着 “Capture once so context, advertised tools, and tool calls share one request view.”——同一次请求里,发给模型的工具清单和随后执行调用的注册表是同一份。捕获过程调用 turn::built_tools(codex-rs/core/src/session/turn.rs:1742),它先准备插件安装推荐的候选,再交给 build_tool_router:

    let mut registry = ToolRegistry::with_tool_policy(Arc::clone(&session.tool_policy));
    add_core_tool_sources(&context, &mut registry);

    let registered_mcp_tools = session.services.mcp_handler_cache.append_mcp_tools(
        // ...
    );
    apply_mcp_tool_exposure_policy(
        // ...
    );
    let standalone_web_search_tool = append_extension_tool_executors(
        // ...
    );
    append_dynamic_tool_runtimes(&turn_context.dynamic_tools, &mut registry);
    let hosted_specs = hosted_model_tool_specs(
        // ...
    );

    finalize_tool_router(

(codex-rs/core/src/tools/spec_plan.rs:150)

登记顺序就是来源顺序:内置工具(shell 类、MCP 资源类、通用工具、协作工具四组)、MCP 工具、扩展贡献的工具(ext/ 下各扩展通过 tools_for_step 提供,例如独立网页搜索 web.run、图片生成 image_gen.imagegen)、动态工具(app-server 客户端在线程上声明、由客户端自己执行的工具,处理器发出 DynamicToolCall 条目后等客户端回话),最后是只有 spec、由服务端执行的托管工具 web_search。决定某个工具进不进来的依据有五类:特性开关、模型目录里的 ModelInfo(如 shell_type、apply_patch_tool_type、supports_search_tool)、提供方能力(namespace_tools、web_search)、执行环境的数量(没有环境就没有 shell、apply_patch、view_image,多个环境时参数里多一个 environment_id),以及扩展 API 的 ToolPolicy(allowed_tools 一旦设置就是白名单)。

图表加载中…

finalize_tool_router 做收尾:Code mode 开启时注册 exec 与 wait 两个工具,把可嵌套的工具登记成脚本里能调用的函数,code_mode_only 下这些工具就不再直接暴露(见 Code mode);模型支持工具搜索、又有带检索信息的延迟工具时,补上 tool_search;打开 features.tool_registry.error_on_tool_collisions 时,重名或同名命名空间描述不一致会直接报 ToolCollision;最后 build_model_visible_specs 挑出可直接暴露的工具,把同名 namespace 合并、组内按名字排序,交给 ToolRouter::from_parts。

六种暴露方式

同一个注册好的工具,可以出现在三个“面”上:初始工具清单(DIRECT)、tool_search 检索结果(DEFERRED)、Code mode 脚本里的嵌套调用(CODE_MODE)。ToolExposures 是这三位的 bitflags,枚举 ToolExposure 则是六个合法组合:Direct、DirectModelOnly、Deferred、DeferredModelOnly、CodeModeOnly、Hidden(登记在册、可以分派,但不给模型看)。MCP 工具的换算最能说明问题:

        tool.exposure = match (
            exposures.contains(ToolExposures::DIRECT),
            exposures.contains(ToolExposures::DEFERRED),
            exposures.contains(ToolExposures::CODE_MODE),
        ) {
            (false, false, false) => ToolExposure::Hidden,
            (false, false, true) => ToolExposure::CodeModeOnly,
            (true, false, false) => ToolExposure::DirectModelOnly,
            (true, false, true) => ToolExposure::Direct,
            (false, true, false) => ToolExposure::DeferredModelOnly,
            (false, true, true) => ToolExposure::Deferred,
            (true, true, _) => unreachable!("direct and deferred exposure are mutually exclusive"),
        };

(codex-rs/core/src/tools/spec_plan.rs:256)

输入来自两处:服务器配置里的 omit_tools_from(ChatGPT 应用连接器还有自己的同名配置)先减掉若干面;然后看模型支不支持工具搜索(supports_search_tool 且提供方支持 namespace_tools),支持就去掉 DIRECT 面、保留 DEFERRED,否则去掉 DEFERRED。所以在支持搜索的模型上,MCP 工具默认不进初始清单,模型要先调 tool_search(BM25 检索,默认返回 8 个)把 schema 取回来。直接暴露与延迟加载互斥,最后一行的 unreachable! 就是这条不变式。

注册表:先到先得

ToolRegistry(codex-rs/core/src/tools/registry.rs:294)的主体是 IndexMap<ToolName, RegisteredTool>,键是带命名空间的 ToolName(不带命名空间的归入默认的 functions),保留登记顺序。登记分两种口径:内核自己的工具走 register_trusted,重名是程序错误,调试构建直接 panic,发布构建记一条错误日志;外部来源(MCP、扩展、动态工具)走 register_external,重名时跳过后来者、记下第一次冲突,而且不许占用保留名 exec_command 与 shell_command。两种口径都先过 ToolPolicy 白名单。

分派:边流边执行

模型的流里每完成一个输出项,handle_output_item_done(codex-rs/core/src/stream_events_utils.rs:315)就用 ToolRouter::build_tool_call 看它是不是工具调用——FunctionCall、execution 为 client 的 ToolSearchCall、CustomToolCall 三种会变成 ToolCall,负载分别是 ToolPayload::Function、ToolSearch、Custom。是的话立刻派发,不等整条回复结束:调用本身先写进历史,ToolCallRuntime::handle_tool_call 在返回 future 之前就 tokio::spawn 了执行任务,future 放进 FuturesOrdered;流结束后 drain_in_flight 按模型发出调用的顺序收集结果、写回历史,下一次采样就能看到。工具因此和模型的后续输出同时在跑。

图表加载中…

ToolRegistry::dispatch_any_with_state 是所有调用的必经之路:找不到工具就回给模型一句 unsupported call: <工具名>;负载类型对不上是 Fatal;接着跑 PreToolUse hook,它可以拦下调用,也可以改写输入;然后通知扩展的生命周期观察者、调用处理器;成功后再跑 PostToolUse hook,它可以否决结果,或者用一段反馈文本替换模型看到的输出。错误分两级:FunctionCallError::RespondToModel 变成一条 success: false 的工具输出交还模型,让它自己纠正;Fatal 不产生工具输出,直接作为错误向上抛。hook 的细节见 Hooks。

并行:一把读写锁

每轮采样时 build_prompt 总把 Prompt 的 parallel_tool_calls 设为 true(codex-rs/core/src/session/turn.rs:1561),但发出的请求还要与模型信息里的 use_responses_lite 取反相与(codex-rs/core/src/client.rs:1005):随附目录里除 gpt-5.5 外的模型都走 Responses Lite,请求里的值其实是 false;为 true 时模型可以一次发出多个调用。不管模型一次给几个调用,真正的并发控制在 ToolCallRuntime(codex-rs/core/src/tools/parallel.rs:45)里,只有一个 Arc<RwLock<()>>:

                let guard = if supports_parallel {
                    Either::Left(lock.read().await)
                } else {
                    Either::Right(lock.write().await)
                };
                // Admission through the parallel-execution gate marks the end
                // of dispatch waiting and the start of handler execution.

(codex-rs/core/src/tools/parallel.rs:202)

声明可并行的工具拿读锁、彼此并发;其余工具拿写锁,独占执行。0.158.0 的内置工具里声明可并行的有 exec_command、write_stdin、view_image、tool_search 与三个 MCP 资源工具,MCP 工具则看服务器配置的 supports_parallel_tool_calls,或者工具自己带了只读注解 readOnlyHint,扩展工具(如 web.run)各自声明;apply_patch 没有覆盖默认值,改文件时独占。注册表还加了一道保险:Hidden 工具一律按不可并行处理。用户中断时,还没结束的任务被 abort,模型收到 aborted by user after 1.2s 这样的结果,exec_command 则是带 Wall time 的另一种措辞。

编排器在链条里的位置

处理器分两种。update_plan、tool_search 这类纯内核工具在 handle 里就做完了;要在执行环境里起进程或写文件的工具(exec_command、apply_patch),处理器会把请求交给 ToolOrchestrator::run,由它按“审批 → 选沙箱 → 执行 → 沙箱拒绝时视策略请求批准并提权重试”的顺序驱动一个实现了 ToolRuntime<Req, Out> 的执行后端(codex-rs/core/src/tools/sandboxing.rs:364)。注意这是另一个 trait:CoreToolRuntime 面向模型,ToolRuntime 面向沙箱。审批与沙箱的规则留给审批流程。命令与补丁的开始、结束事件由 ToolEmitter(codex-rs/core/src/tools/events.rs)统一发出;命令输出怎样截断、拼成模型看到的文本,下一篇细讲。

和《从 LLM 到 Coding Agent》对照

工具系统的抽象设计提炼的富工具接口,在 Codex 里拆成了 ToolExecutor 加 CoreToolRuntime,“并发安全默认 false”的约定也一模一样。边流边执行讲的“工具块一结束就派发”正是 handle_output_item_done 的做法,但教学实现把一批调用分成并行组和串行组,Codex 用一把读写锁达到同样效果:不需要事先看到整批调用,来一个排一个。MCP 与工具懒加载里的 search_tools 元工具,对应这里的 tool_search 与 Deferred 暴露。


上一篇:任务类型与 Plan 模式 · 一轮里跑的不只是对话 · 下一篇:执行命令 · shell 与 unified exec

本页目录