雨天小六

读懂 Codex(2.10):工具 Turn 的端到端调用链

· 更新于 2026-08-02 · 专栏:读懂 Codex

#Codex#Agent Runtime#软件架构#Tool Call#Tool Runtime

工具调用不是模型输出一个函数名后 Core 直接 match name。在 Codex 中,工具面先按一次 Step 的环境、 MCP、Feature、权限和 Extension 状态构建;模型 Call 回来后被解析成统一 ToolCall,经 Registry 找到 Runtime/Handler,经过并发门、审批和取消控制,最后把带相同 Call ID 的 Output 写回模型历史,再发起 下一次模型采样。

这一闭环解释了为什么“工具执行完成”和“Turn 完成”是两个不同的终点。

ToolRouter 在 StepContext 捕获时构建

built_tools 汇总多种来源:

  • Core 内建工具及其 Feature/模型条件;
  • 当前环境支持的 shell/apply_patch/runtime;
  • MCP tools 与 connector/app projection;
  • Plugins/Skills 推荐或 discoverable tools;
  • Extension tool contributors;
  • Thread 启动时注册的 dynamic tools;
  • deferred namespace/tool search 能力。

输出一边是用于遥测/连接器处理的 MCP ToolInfo,另一边是 Arc<ToolRouter>。Router 内同时保存 model_visible_specsToolRegistry

def capture_step_context(turn, environments, mcp, extensions):
    router = build_tool_router(
        turn=turn,
        environment_snapshot=environments,
        mcp_binding=mcp,
        extension_executors=extensions.tools_for_step(),
        dynamic_tools=turn.dynamic_tools,
    )
    return StepContext(turn=turn, tool_router=router, mcp=mcp)

模型 Prompt 使用这个 Router 的 specs,回包执行也保留同一个 StepContext。MCP 在模型思考期间刷新不会 让一个已经广告过的 Tool Call 突然换 Handler。

Tool Call 从 ResponseItem 解析为统一结构

ToolRouter 当前识别:

ResponseItemToolPayload特殊条件
FunctionCallFunction arguments JSON stringname + optional namespace
ToolSearchCallToolSearch structured arguments只处理 execution == client 且有 call_id
CustomToolCallCustom input string可用于 apply_patch 等 custom grammar

其他 ResponseItem 返回 None,继续走普通 message/reasoning 投影。ToolSearch 参数反序列化失败属于 RespondToModel:系统记录原 Call,并生成错误 Output 让模型修正,而不是崩溃整个 Thread。

统一 ToolCall 保存 ToolName、call_id、payload 和可选 encrypted function args。Collaboration 某些 plaintext 消息还会标记为 DirectPlaintextMessage,日志只写 [plaintext arguments],避免把敏感内容作为普通 payload 输出。

先记录 Call,再开始执行

Response stream 收到 OutputItemDone 后,handle_output_item_done 先让当前 Turn 接受 mailbox delivery, 记录 Tool Call ResponseItem,然后创建工具 Future,并把 needs_follow_up 设为 true。

async def handle_model_item(step, item):
    call = ToolRouter.build_tool_call(item)
    if call is None:
        return await finalize_display_item(item)

    await history.record_and_persist(item)  # Call 先成为事实
    future = step.tool_runtime.handle_tool_call(
        call,
        cancellation=step.turn.child_token(),
    )
    return OutputResult(needs_follow_up=True, tool_future=future)

先写 Call 能保证中止、崩溃或恢复时,历史不会出现一个没有来源的 Tool Output。反向不变式则要求正常 工具 Future 最终生成同 call_id 的 Output。

模型 Tool Call 从 StepContext Router 解析、持久化、Registry 分派、结果写回历史并触发下一次模型采样的闭环
图 2.10-1:工具输出不是直接返回 UI 后结束,而是进入模型历史,驱动同一 Turn 的下一次 response。

Registry 把名称解析与 Handler 实现分开

ToolRouter 将 ToolCall 转成 ToolInvocation,其中带 Session、Turn、完整 StepContext、CancellationToken、 TurnDiffTracker、call_id、ToolName、source 与 payload。Registry 再按命名空间和名称定位已注册 Runtime。

这种设计有两个边界:

  • Router 决定“这个 Step 广告了什么、名字如何解析”;
  • Handler/Runtime 决定“如何校验参数、审批、执行与形成 ToolOutput”。

后续第三章会逐种拆 shell、apply_patch、MCP、web、multi-agent;本节只关注它们共享的调用骨架。

并行能力由工具注册信息决定

ToolCallRuntime 为本次 sampling request 创建一个共享 RwLock<()>

  • supports_parallel=true 的 Call 取 read lock,多个可同时执行;
  • 不支持并行的 Call 取 write lock,等待所有 read lock 结束,并阻止其他 Call 进入。
支持并行的工具获取共享读锁,不支持并行的工具获取写锁并与全部调用互斥
图 2.10-2:并行不是模型一个总开关;模型要支持并行,具体工具注册也要声明可并行。

这比为“串行工具”单独建队列更强:一个写锁 Call 不仅与其他串行 Call 互斥,也与正在执行的并行 Call 互斥。适合会修改共享工作区或依赖严格顺序的工具。

Prompt 的 parallel_tool_calls 仍由模型能力决定。模型不支持时不会被鼓励一次发多个 Call;即便模型发了, Runtime 的每工具 gate 仍是最后约束。

并发执行,按模型顺序写回

每个 Tool Future 被压入 FuturesOrdered。Future 可以在后台并发推进,但 drain 按插入顺序 yield。这样 模型一批输出 A、B,即使 B 先执行完成,历史仍稳定写 A-output、B-output。

async def drain_tool_batch(futures_ordered, session, turn):
    async for result in futures_ordered:  # completion work may overlap; yield order stable
        output = result.into_response_item()
        await session.record_conversation_items(turn, [output])
        await maybe_mark_memory_polluted(output)

稳定顺序减少 Provider 对并行 output 排列差异的敏感性,也让 rollout 重放可预测。代价是前面的慢工具会 阻止后面的快结果先写历史。

Handler 错误通常要反馈给模型

ToolCallRuntime 将非 Fatal FunctionCallError 转成失败 Output:

  • Function → FunctionCallOutput;
  • Custom → CustomToolCallOutput;
  • ToolSearch → completed、空 tools 的 ToolSearchOutput。

Output 的 success=false 并保留 call_id。模型可以读取错误并改参数、换工具或向用户解释。只有 Fatal 才上升为 CodexErr 并终止当前 sampling/Turn 路径。

RespondToModel 甚至可以在 Router 解析阶段直接产生 Output,不启动 Handler。这个区分让“工具参数不对” 成为 Agent 可恢复事实,而“Runtime 内部不变量破坏”成为终止错误。

审批和用户输入会让 Tool Future 暂停

Shell、patch、MCP elicitation、request_user_input、request_permissions 等 Handler 可在 Session service 中 注册 waiter,发对应 Event,然后 await 回答型 Op。此时:

  • Turn 仍是 Running;
  • sampling response 已 Completed;
  • Tool Future 尚未完成;
  • 下一次模型采样不能开始。

try_run_sampling_request 在 stream 结束后 drain 所有 in-flight tools。TokenCount 也延后到 pending tools 解决之后,避免 UI 在等待用户批准时持续显示模型进度。

取消要避免两个终态

工具执行与 Turn CancellationToken 绑定。取消发生时:

  • 若 Handler 已到 terminal outcome 或 task 已结束,读取真实结果;
  • 声明 waits_for_runtime_cancellation 的工具不被粗暴 abort,等待其关闭进程/资源;
  • 其他工具 abort Tokio task;
  • 生成带 wall time 的 aborted ToolOutput,并发 tool-aborted lifecycle;
  • AtomicBool 保证完成路径和中止路径只有一个拥有 terminal outcome。

Shell/unified_exec 的中止文本包含 wall time,其他工具使用通用 aborted-after 信息。这样历史仍有与 Call 匹配的终止 Output,而不是留下永远未回答的 Call。

Tool Output 写回后为什么必须再次采样

工具结果只回答“动作发生了什么”,最终用户回复仍由模型结合结果生成。因此任意 Tool Call 都设置 needs_follow_up=true。drain 完成、Output 进入历史后,run_turn 回到循环:

  1. 检查 token limit/auto compact;
  2. capture 新 StepContext;
  3. clone 包含 Call + Output 的历史;
  4. 发下一次模型 response;
  5. 直到没有工具、pending input 或 end_turn=false。

一次 Turn 可以包含多批工具,形成 model → tools → model → tools → model final

TurnDiff 跨所有工具调用聚合

RegularTask 创建一个共享 TurnDiffTracker,传入每个 ToolInvocation。会修改文件的 Runtime 把差异记入同一 Tracker。一个 sampling response 的工具 drain 后,如果 Provider response completed,Runtime 可以发 TurnDiff Event;整个用户 Turn 看到统一 diff,而不是每个 apply_patch 互不关联。

失败矩阵

场景历史输出后续
未注册 ToolNamefailure Tool Output模型再采样
ToolSearch 参数错误RespondToModel Output模型修正
Handler 普通失败success=false Output模型解释/重试
Handler Fatal无伪造成功 OutputTurn Error
并行工具 B 先完成等 A 后按 A、B 写回稳定顺序
等待审批Future 暂停,Turn 仍 activeAnswer Op 唤醒
用户取消aborted Output/lifecycle,随后 TurnAborted不重复 Completed
Output 写回后 context 超限auto compact同 Turn 继续

测试焦点

async def test_call_is_persisted_before_handler_starts(probe):
    await probe.model_emits(function_call("x", call_id="c1"))
    assert probe.history.contains_call("c1")
    assert probe.handler.started_after_history_write("c1")


async def test_parallel_execution_preserves_output_order(runtime):
    runtime.enqueue(call("slow", "c1", parallel=True))
    runtime.enqueue(call("fast", "c2", parallel=True))
    await runtime.finish("c2")
    await runtime.finish("c1")
    assert history.output_call_ids()[-2:] == ["c1", "c2"]


async def test_nonfatal_error_is_model_visible(fake_model):
    fake_model.tool_handler_raises(BadArguments("missing path"))
    await run_turn()
    second_prompt = fake_model.requests[1]
    assert second_prompt.tool_output.success is False

工具 Turn 的共享设计可以概括为:同 Step 广告与执行一致、Call 先持久化、Handler 受并发和取消门控、 Output 保序写回、模型负责在下一次 response 中收口。

评论


← 返回文章列表