青雲的博客
拆开 Codex 第三部分 Codex 如何在机器上行动 第 03 章

模型为什么能调用工具:从 ToolSpec 到真正执行

拆开 Codex 如何从 StepContext 规划工具、生成模型可见 spec、注册 runtime,并把工具输出送回下一次采样。

源码版本
rust-v0.144.6
验证日期
Commit
5d1fbf26c43abc65a203928b2e31561cb039e06d

模型回复里出现一段 FunctionCall,随后终端真的执行了工具。最省事的解释是:把函数定义发给模型,模型按 JSON Schema 返回参数,Codex 调对应函数。

这段话只讲了接口形状,没有讲执行能力从哪里来。一个工具可能出现在模型可见列表里,却没有本地 runtime;也可能已经注册,可以被兼容路径或后续发现机制调用,却不出现在初始 Prompt.tools。即使 name 和 arguments 都正确,真正执行时还需要当前 session、step、cancellation token 和 tracker。

rust-v0.144.6 中,这条链的两个关键对象叫 CoreToolPlanContextPlannedTools。沿着真实类型往下走,才能看清 spec 与 runtime 是怎样绑定、又在哪些地方故意分开的。

可复现实验:用确定时间证明完整闭环

仓库已有一个比“让真实模型随便调工具”更稳定的测试:current_time_tool_returns_the_latest_time

测试启动本地 mock Responses API。第一次 SSE 序列返回 clock.curr_time 的 function call;Codex 通过 TestTimeProvider 取时间并回灌工具结果;第二次 mock response 完成 turn。断言检查两件事:第一次请求确实暴露 clock.curr_time,第二次请求中相同 call_id 的 function output 等于推进后的 SECOND_REMINDER

复现命令是:

just test -p codex-core current_time_tool_returns_the_latest_time

这个测试不请求真实模型服务,但会在本机启动 mock HTTP server;测试文件也带有网络可用性检查。TestTimeProvider 每次读取把时间推进 60 秒,因此断言不依赖墙上时钟。

仓库默认通过 just 调用 cargo-nextest。本次写作环境没有这两个命令,我在固定 commit 的一次性 checkout 里改跑等价的 Cargo 入口:

cargo test -p codex-core current_time_tool_returns_the_latest_time -- --nocapture

目标测试是 1 passed, 0 failed,另有 954 条用例被名称过滤;测试本身耗时 0.15 秒。首次运行还要下载并编译依赖,这台机器用了 7 分 46 秒。这个数字只说明本次冷启动成本,不是测试性能基线。

工具表不是 session 启动时写死的

run_turn 每个 sampling step 都捕获一次 StepContext。它是这一刻的运行快照,包含 turn context、environment、MCP runtime 等信息。MCP tool snapshot 在 step 内通过 OnceCell 固定,避免“发给模型的是一组工具,真正执行时却换成另一组连接状态”。下一次 follow-up sampling 又可以捕获新的 step,以接收已经发生的配置或环境变化。

run_sampling_request 随后调用 built_tools。这个函数从当前 step 收集:

  • MCP direct/deferred tools;
  • 已加载插件与 connector;
  • tool search candidates;
  • extension tool executors;
  • dynamic tools;
  • 当前模型、feature 和 environment 决定的 core tools。

built_tools 返回同时持有模型可见 specs 与本地 registry 的 ToolRouter,而非一份裸 Vec<ToolSpec>。同一个 router 一边交给 build_prompt,一边交给 ToolCallRuntime。这一步把“模型看见什么”和“宿主如何执行”放在同一个 sampling 边界上。

这个边界很关键。若只在 thread 创建时缓存 specs,后续 extension、MCP 或 environment 变化可能无法进入当前 step;若执行时重新从全局状态找 handler,又可能让模型看到的能力与实际能力分叉。

CoreToolPlanContext 如何变成 PlannedTools

CoreToolPlanContext 汇集规划所需的只读上下文。PlannedTools 则有两部分:

PlannedTools {
  runtimes: Vec<Arc<dyn CoreToolRuntime>>,
  hosted_specs: Vec<ToolSpec>
}

add_tool_sources 按 feature、模型和环境把不同来源加进来。普通 native tool 以 runtime 形式加入,因为 runtime 自己实现 tool_name()spec()exposure()handle()。provider hosted tool 则只加 spec,它的执行不发生在本地 registry。

规划最后调用 build_model_visible_specs_and_registry:遍历 runtimes,只有 ToolExposure::is_direct() 为真的 exposure,也就是 DirectDirectModelOnly,才生成初始可见 spec;随后追加 hosted specs;而 ToolRegistry 从全部 runtimes 创建。两个结果同源,却不是简单复制。

为什么两个集合不能粗暴地说成相等

ToolExposure 给出了四种暴露方式:

exposure初始模型工具列表本地 registry后续路径
Direct可直接调用,也可进入 code mode
DirectModelOnly直接模型工具,不进入嵌套 code-mode surface
Deferred可经 tool search 或 code mode 后续发现
Hidden只保留 dispatch/兼容能力

反方向的例外是 hosted spec:它进入模型可见 specs,却没有本地 runtime。当前版本的典型来源是 provider hosted web search。模型提出这类工具使用时,服务端负责执行,不应在本地 registry 中寻找同名 handler。

Unified Exec 也给了一个很具体的例子:模型看到的是 exec_commandwrite_stdin,旧的 shell_command 仍以 Hidden 方式注册,给兼容分发路径使用。若断言“spec 名集合必须始终等于 registry 名集合”,这个有意设计会被当成 bug。

因此更准确的不变量是:对本地 direct tool,模型可见 spec 与可执行 runtime 应来自同一个 planned runtime;同时允许明确标注的 dispatch-only、deferred 和 hosted 例外。这个不变量比“两个数组长度相等”更能抓住真实分叉。

spec 怎样进入模型请求

build_promptrouter.model_visible_specs() 放进 Prompt.tools。之后 build_responses_request 调用 create_tools_json_for_responses_api,逐个通过 serde 转成 JSON。

普通 Responses API 请求把结果放在顶层 ResponsesApiRequest.tools。Responses Lite 是一个 wire 例外:工具 JSON 被包装进 ResponseItem::AdditionalTools,插到 input 前缀,顶层 tools 变成 None。所以抓包时只检查顶层 tools,可能误判 Lite 请求“没有工具”。

flowchart TD
    accTitle: 工具从能力规划到结果回灌的闭环
    accDescr: StepContext 经 built_tools 生成 PlannedTools,并分成模型可见 specs 与本地 ToolRegistry;模型返回 FunctionCall 后,runtime 执行 ToolExecutor,把 ToolOutput 写回 history,触发下一次 sampling。
    S["StepContext"] --> B["built_tools"]
    B --> C["CoreToolPlanContext"]
    C --> P["PlannedTools"]
    P -->|"Direct runtimes + hosted specs"| V["router.model_visible_specs()"]
    P -->|"all local runtimes"| R["ToolRegistry"]
    V --> PR["Prompt.tools"]
    PR --> J["Responses API JSON"]
    J --> M["模型"]
    M --> FC["ResponseItem::FunctionCall"]
    FC --> TC["ToolCallRuntime"]
    TC --> I["ToolInvocation"]
    I --> R
    R --> H["CoreToolRuntime::handle"]
    H --> O["ToolOutput"]
    O --> RI["ResponseInputItem"]
    RI --> CH["conversation history"]
    CH --> PI["Prompt.input"]
    PI --> J

output_schema 有一个容易写错的边界

ResponsesApiTool 结构里有 output_schema: Option<Value>。看到这个字段,很容易写成“Codex 把工具输入 schema 和输出 schema 一起发给模型”。当前版本并非如此。

这个字段标记了 #[serde(skip)]。工具请求 JSON 又是通过 serde 生成的,所以普通 ResponsesApiRequest.tools 不包含它。它仍可作为 Codex 内部元数据,被 code mode 等路径使用;但不能把内部存在字段,等同于 wire payload 已发送字段。

还要和 Prompt.output_schema 分开。后者约束的是本次 turn 最终 assistant answer,经 create_text_param_for_request 进入 Responses API 的 text controls。它不是某个工具的返回值 schema。

这个例子给源码阅读一个实用提醒:类型字段只能证明内存模型允许保存某项数据;要证明它出现在网络请求里,还要继续追序列化属性和构建 request 的代码。

FunctionCall 到底怎样找到 handler

模型返回 ResponseItem::FunctionCall,里面包含 name、可选 namespace、JSON arguments 和 call_idhandle_output_item_done 不会直接执行它,而是先:

  1. ToolRouter::build_tool_call,规范化成内部 ToolCall
  2. 把模型产出的 call item 记入 conversation history;
  3. 用当前 step 的 child cancellation token 创建 tool future;
  4. 设置 needs_follow_up = true

ToolCallRuntime 持有 router、session、原始 step 和 diff tracker。支持 parallel call 的工具获取同一把 RwLock 的读锁,不支持的工具获取写锁。前者可以彼此并行,后者会与其他调用互斥,然后 router 才构造 ToolInvocation。invocation 明确携带:

session
turn / step_context
cancellation_token
tracker
call_id
tool_name
source
payload

registry 最后用 ToolName 找到 CoreToolRuntime。未注册时,它构造一个 RespondToModel 错误,让模型看到“unsupported tool call”并调整。找到 runtime 后,payload kind 不兼容会成为 fatal error;匹配时则先发 tool-start lifecycle、运行 PreToolUse hook,hook 可以阻断或改写输入,随后才调用 tool.handle(invocation)。成功结果还会生成 PostToolUse payload,经过 post hook 后再决定原结果、反馈替换或阻断。

这条链看起来啰嗦,但作用很实际:工具即使异步、并行执行,仍绑定到最初向模型暴露它的 StepContext,不必再从全局状态里猜当前 session。

ToolOutput 怎样回到下一次 sampling

ToolExecutor::handle 返回 ToolExecutorFuture<'_>。这个 boxed future 成功完成后才产出 Box<dyn ToolOutput>ToolOutput 必须实现 to_response_item(call_id, payload),把运行结果转换成 ResponseInputItem。function tool 通常得到 FunctionCallOutput,并保留原 call_id,让模型把结果与之前的 call 对上。

工具 future 完成后,drain_in_flightResponseInputItem 转成 ResponseItem,调用 record_conversation_items。后者先把 item 写进 session history,再持久化 rollout,并发送 raw response item。外层 run_turn 把模型返回的 needs_follow_up 与 pending input 合并;需要继续时回到循环,下一次 sampling 从更新后的 history 构造 prompt,并重新捕获这一 step 的 StepContext

完整闭环是:

StepContext
-> built_tools
-> CoreToolPlanContext
-> PlannedTools
-> model_visible_specs + ToolRegistry
-> Prompt.tools
-> ResponsesApiRequest
-> ResponseItem::FunctionCall
-> ToolCallRuntime
-> ToolInvocation
-> ToolRegistry
-> CoreToolRuntime::handle
-> ToolOutput
-> ResponseInputItem
-> conversation history
-> follow-up sampling

模型没有直接执行函数。它提出一个结构化调用,宿主选择 runtime、执行、封装结果,再决定是否继续 sampling。工具能力属于 Codex runtime,不属于模型进程。

源码依据

本章的证据固定在 5d1fbf26c43abc65a203928b2e31561cb039e06d,覆盖 local native tool 从规划到结果回灌的相邻边界。引用这些连接处,是为了避免拿某一个 handler 的行为代表整套工具系统。

尚未展开 MCP transport、dynamic tool 回调、provider hosted tool 的服务端实现,也没有覆盖 code mode 如何加载 Deferred tools。它们都遵守相邻协议,但不能从本章的 local native tool 主链直接外推具体执行细节。

失败边界

工具链至少有五种不同失败,不能都压成“tool error”。

  • spec 可见但调用名不在本地 registry:对 hosted tool 可能正常,对普通 local tool 则是能力分叉。
  • registry 有 runtime 但初始不可见:Hidden 或 Deferred 可能是设计,不是漏注册。
  • arguments JSON 无法解析:通常转成可回给模型的错误,允许模型修正。
  • runtime fatal:结束当前 sampling/turn 的路径,不应伪装成普通输出继续循环。
  • tool future 被取消:要看具体 runtime 是否等待资源 teardown,不能只看 future 消失。

还有一个观测误区:模型请求里没有 output_schema 不表示 runtime 不知道输出结构;它可能只作为内部 code-mode 元数据存在。反过来,spec 中出现 name 和 parameters,也不能证明调用已经通过审批或完成执行。

动手改一个地方

这里先做机制隔离,不把练习伪装成 upstream 已有功能。给固定版本临时加一个只返回常量的本地工具 codex_source_baseline:输入是 {},输出是 {"source_version":"rust-v0.144.6"}。它不执行 shell、不访问网络、不读写文件,用来排除业务副作用,只观察 spec、注册和输出回灌。

如果目标是判断一项真实工具改动是否完整,不要停在这个练习。第五部分会复盘固定 tag 中真实存在的 get_context_remaining,看它怎样在三次演进中补齐 feature gate、code mode 输出和统一 token accounting:《给 Codex 加一个工具:从能运行到合同完整》

练习临时放进已有的 Goal extension,因为这个 crate 已经有一条完整的 ToolContributor / ToolExecutor<ToolCall> 路径,不需要先学如何新建 crate 和接线 host。“源码基线”并不属于 Goal 业务;练习结束后,要么删掉它,要么再把它移入独立 extension,不要把教学挂载点当成正式模块边界。

具体改四处:

  1. codex-rs/ext/goal/src/spec.rs 定义工具名和空对象 input schema;
  2. codex-rs/ext/goal/src/tool.rs 定义 SourceBaselineTool,实现 ToolExecutor<ToolCall>
  3. codex-rs/ext/goal/src/extension.rsGoalExtension::tools 返回值中加入 executor;
  4. codex-rs/ext/goal/tests/goal_extension_backend.rs 加一条安装后的执行测试。

下面是 spec.rstool.rs 的核心节选,不是可直接粘贴的完整 patch:为了让主链更清楚,省略了现有定义、use 导入和测试 helper。类型、trait 方法和构造方式都来自 rust-v0.144.6

// codex-rs/ext/goal/src/spec.rs
pub const SOURCE_BASELINE_TOOL_NAME: &str = "codex_source_baseline";

pub fn create_source_baseline_tool() -> ToolSpec {
    ToolSpec::Function(ResponsesApiTool {
        name: SOURCE_BASELINE_TOOL_NAME.to_string(),
        description: "Return the source baseline used by this exercise.".to_string(),
        strict: true,
        defer_loading: None,
        parameters: JsonSchema::object(BTreeMap::new(), Some(Vec::new()), Some(false.into())),
        output_schema: None,
    })
}

// codex-rs/ext/goal/src/tool.rs
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SourceBaselineArgs {}

pub(crate) struct SourceBaselineTool;

impl ToolExecutor<ToolCall> for SourceBaselineTool {
    fn tool_name(&self) -> ToolName {
        ToolName::plain(SOURCE_BASELINE_TOOL_NAME)
    }

    fn spec(&self) -> ToolSpec {
        create_source_baseline_tool()
    }

    fn exposure(&self) -> ToolExposure {
        ToolExposure::Direct
    }

    fn handle(&self, invocation: ToolCall) -> codex_extension_api::ToolExecutorFuture<'_> {
        Box::pin(async move {
            let _: SourceBaselineArgs = parse_arguments(invocation.function_arguments()?)?;
            Ok(Box::new(JsonToolOutput::new(json!({
                "source_version": "rust-v0.144.6"
            }))) as Box<dyn ToolOutput>)
        })
    }
}

strict: trueadditionalProperties: false 限定了模型看到的输入;SourceBaselineArgs 在 runtime 再校验一次。JsonToolOutput 已经实现 ToolOutput,registry 会在转换 ResponseInputItem 时把原 call_id 传给它。executor 显式返回 ToolExposure::Direct,所以它会进入初始模型工具列表,也会留在本地 registry。

注册不需要另起一条路。在 GoalExtension::toolsvec![] 中追加 Arc::new(SourceBaselineTool)install_with_backend 已经执行 registry.tool_contributor(extension),之后 core 会从 contributor 取回这个 executor,包装成 planned runtime。要注意,Goal contributor 先检查 runtime.tools_visible(),所以这个练习工具只在该 extension 可用的 thread 中出现,不是全局常驻工具。

验收先放在 Goal extension 自己的 integration test 中。下面同样是节选,需要在文件顶部补上 use codex_tools::ToolExposure;;这个 tag 的 codex_extension_api 没有 re-export 该类型:

#[tokio::test]
async fn source_baseline_tool_returns_locked_version() -> anyhow::Result<()> {
    let runtime = test_runtime().await?;
    let tools = installed_tools(runtime, test_thread_id()?).await;
    let tool = tool_by_name(&tools, "codex_source_baseline");
    let spec = tool.spec();
    let invocation = tool_call("codex_source_baseline", "call-source-baseline", json!({}));
    let output = tool.handle(invocation.clone()).await?;

    assert_eq!(
        (
            spec.name(),
            tool.exposure(),
            output.code_mode_result(&invocation.payload),
        ),
        (
            "codex_source_baseline",
            ToolExposure::Direct,
            json!({ "source_version": "rust-v0.144.6" }),
        )
    );
    Ok(())
}

installed_tools 会先执行 Goal extension 的安装与 thread-start lifecycle,再遍历 registry 中的 contributors。因此这条测试同时检查了注册结果、direct exposure、spec 名和 handler 输出,不需要请求真实模型。codex_source_baseline handler 本身没有业务或外部副作用;测试 harness 仍会初始化 StateRuntime,把 SQLite 写进隔离的临时状态目录。这是测试基础设施的本地写入,不应被说成整条测试“零机器副作用”。运行:

just test -p codex-goal-extension source_baseline_tool_returns_locked_version

我在同一个固定 commit 的一次性 checkout 里补全了上面四处改动,再用等价的 Cargo 命令验收:

cargo test -p codex-goal-extension source_baseline_tool_returns_locked_version -- --nocapture
cargo test -p codex-goal-extension

目标测试是 1 passed, 0 failed;Goal extension 全量结果是 23 passed, 0 failed。四个文件之外不需要补 Cargo 依赖,文中特别指出的 ToolExposure 导入也与实际编译错误一致。

这里还有一个固定 tag 自身的复现边界:这份 Cargo.lock 仍有 132 个 workspace package 记录为 0.0.0,首次测试会把这些 metadata 重写为 0.144.6。在一次性副本里运行,或者测试后恢复 Cargo.lock;不要为了保持工作树干净改加 --locked,那会在测试开始前直接拒绝更新 lockfile。

这还不是 core 级的 follow-up 测试。要继续往下验证,可以复制本章前面的 current_time_tool_returns_the_latest_time mock Responses API 结构:第一次响应返回 codex_source_baseline 调用,第二次请求断言同一 call_id 对应的 function output 是固定 JSON。两层测试分开,失败时才能判断是 extension 注册有问题,还是 core router/follow-up 有问题。

局部改造的验收不要只断言 Prompt.tools 出现名字。至少同时断言:direct exposure 的 spec 可见、registry 能按同一个 ToolName dispatch、结果带原 call id 进入 follow-up request。若选择 Deferred,则相应地断言初始列表没有它、tool search 能发现它。

这一章建立了什么

检查工具链不能只看 Prompt.tools。还要确认同名 runtime 是否注册、调用绑定了哪个 StepContext,以及结果是否带着原 call_id 回到下一次请求。

Hidden 与 Deferred 可以只注册,hosted spec 可以只展示。这些不对称是设计边界,不能用“工具列表和 registry 必须相等”一把抹平。