青雲的博客
拆开 Codex 第四部:外部能力怎样进入同一套治理 第 22 章

Dynamic Tools 为什么把执行权交回宿主

从 thread/start 的动态工具声明出发,沿 Core 的 ToolRegistry、DynamicToolHandler、pending oneshot 和 app-server 的 item/tool/call 反向请求,拆开一个没有本地领域实现的工具怎样由宿主执行,再把结果送回模型。

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

第 21 章的 MCP 链路是 Codex 作为客户端去连接外部 server。Dynamic Tools 的方向反过来:宿主先告诉 Codex“模型可以看到哪些函数”,模型真正调用时,Codex 再把请求发回宿主。这个反向箭头不是协议表面上的小差异,它决定了谁拥有执行权,也决定了 Core 能够保证什么。

在固定版本的 app-server 测试里,一次调用的可见顺序很短:先收到一个 item/started,然后 app-server 发出 item/tool/call 请求;宿主返回 contentItems,Core 发出 item/completed,后续 Responses 请求里出现同一个 call_idfunction_call_output。这里没有一个“Core 在本机执行动态工具”的中间步骤。

model function call
  -> Core ToolRegistry lookup
  -> DynamicToolHandler registers pending waiter
  -> app-server item/tool/call (host)
  -> host DynamicToolCallResponse
  -> Core Op::DynamicToolResponse
  -> function_call_output in the next model request
本节源码依据(1 处)

先分清三个角色

“动态工具”这个名字容易把三个不同问题压成一个:模型看到了什么、谁真正做事、谁决定这次调用何时失效。固定版本里可以用 owner / executor / governor 先把它们分开:

ownerexecutorgovernor固定版本里能确认的事实
spec admissionthread 的宿主客户端提交声明app-server thread_start_task 接收并转交非空 thread/start 才调用 validate_dynamic_tools恢复自 SessionMeta 的声明不重走这道 gate;声明也不会带来实现
model-visible toolCore session / turnadd_dynamic_tools 建立 DynamicToolHandler,再放进 ToolRegistryexposure override、Code Mode 和 provider 能力初始 exposure 与 registry entry 都不等于最终对模型可见
domain execution宿主客户端宿主自己的进程、服务或应用代码宿主自己的权限与业务规则item/tool/call 是反向请求;Core 只能等待结果
pending call当前 active turnTokio oneshot(只能发送一次结果的单次通道),即 Sender/Receiver<DynamicToolResponse>TurnState.pending_dynamic_toolscall_id回填必须命中当前 turn 的同一 key
model continuationCore sessionFunctionToolOutput 和 Responses 请求构造turn cancellation、模型请求生命周期宿主只返回 content items;Core 负责把它们交给模型

因此,本文说“Core 没有 executor”时,指的是 Core 没有动态工具的领域 executor。DynamicToolHandler 确实实现了 ToolExecutor<ToolInvocation>,但它的工作是解析参数、登记 waiter、发出 lifecycle 事件并等待宿主;它不是一个会执行 demo_tool 业务逻辑的本地实现。把这两个含义混在一起,会让后面的所有权图看起来像是 Core 偷偷拥有了宿主能力。

Spec 可以恢复,但不会重跑 start validation

DynamicToolSpec 只有声明:顶层 Function,或包含多个 function 的 Namespace;函数保存 namedescriptioninput_schema 和可选的 defer_loading,没有 executor。真正的调用另用 DynamicToolCallRequest / DynamicToolResponse

声明进入 session 时只需抓住两条事实。第一,app-server 对非空的 thread/start.dynamic_tools 调用 validate_dynamic_tools,检查名称、长度、保留前缀、namespace 重名、deferred 位置和 input schema;字段缺失会先由 unwrap_or_default 变成空 Vec,显式 [] 也同样是空 Vec,因此这两种输入都不会进入 validator。第二,session 创建后会把采用的 specs 写进持久化元数据;以后 Core 用空的显式 vector 重建 session 时,会从 InitialHistorySessionMeta.dynamic_tools 读回它们。显式传入非空 vector 时,则直接使用新值,不查 history。

这两条路径没有汇合成同一个 admission gate。validate_dynamic_tools 只位于非空 thread/start 的请求处理路径;Session::newSessionMeta 恢复 specs 时不会再调用它。因此,“spec 可以恢复”只说明旧声明能重新进入 planner,不说明它又通过了当前版本的 start validation,更不说明宿主 executor 已经存在。resume、fork 和 history override 怎样构造 InitialHistory,统一留给第 27 章。

换句话说,SessionMeta 中恢复的 specs 不重走 validate_dynamic_tools;这是恢复路径与非空 thread/start admission 之间的明确边界。

把恢复入口写成源码名,会比一句“从历史恢复”更不容易误读:thread/start 新建线程使用 InitialHistory::New,清空历史使用 InitialHistory::ClearedInitialHistory::New | InitialHistory::Cleared => Noneget_dynamic_tools 的无历史分支。resume_thread_with_history 向 Core 传入 Vec::new()fork_thread_with_initial_history 也传入 Vec::new(),让 Session::new 有机会从 InitialHistory::ResumedInitialHistory::ForkedSessionMeta 读取声明。这里的空 vector 是“允许从历史取值”的哨兵,不是说恢复后的 session 没有 dynamic tools。

本节源码依据(7 处)

Core 注册的是等待型 handler

每个 turn 的 add_dynamic_tools 都会把声明转换成 DynamicToolHandler runtime。普通 function 生成 ToolSpec::Function,namespace 中的 function 生成 ToolSpec::Namespace。接下来不是一次二选一,而是几道彼此独立的 gate:

阶段条件与变换model requesttool_searchCode Mode nestedregistry
direct exposuredefer_loading=false 得到 ToolExposure::Direct;它只是 exposure,不保证最终 model-visibleDirect 先成为候选仍可能进入runtime 进入
deferred exposuredefer_loading=true 得到 ToolExposure::Deferred;它是初始 exposure,不保证经 tool_search 可搜索不直接进入只成为候选仍可能进入runtime 仍进入
namespace override独立配置 direct_only_tool_namespacesToolExposure::Direct 覆盖为 DirectModelOnly,也把 ToolExposure::Deferred 覆盖为 DirectModelOnly强制直接候选退出 deferred 集合DirectModelOnly 不进入保留
Code Mode gateis_hidden_by_code_mode_onlyCodeModeOnly 隐藏普通 nested-capable spec;DirectModelOnly 明确豁免普通 Direct 被隐藏,DirectModelOnly 保留不在这一步改变合格 runtime 进入保留
provider namespace filter最终 ToolSpec::Namespace 都要过滤;命名空间中的 ToolExposure::Direct 与 namespaced DirectModelOnly 都受 provider namespace_tools,顶层 Function 不受provider 不支持时移除 namespaced spec不在这一步单独决定不删除 nested runtime不删除
search gate只有 supports_search_tool && namespace_tools,且至少一个 Deferred runtime 有 search metadata,才追加 search executor加入 tool_search specDeferred 才可搜索Deferred 仍可能独立进入原 runtime 仍保留

这张表最容易漏掉的是最后一列:model request、search 和 Code Mode nested 是三种 surface,ToolRegistry 却由完整 runtime 集合构造。某个 spec 没出现在当前模型请求里,不等于 Core 丢掉了它,更不等于执行权从宿主回到了 Core。

本节源码依据(9 处)

反向调用链

把 planner 和运行时放在同一张图里,才能看清“没出现在当前 model request”与“没有注册 runtime”是两回事。

flowchart TB
  accTitle: Dynamic Tool 从模型调用到宿主执行再回到模型
  accDescr: DynamicToolSpec 先经过 exposure 和 capability gates,形成 model、search 或 Code Mode surface;三条路径都通过同一 registry。调用时 registry 先登记 pending waiter,再发 ItemStarted 和宿主反向请求。
  subgraph PLAN[planner gates]
    SPEC["DynamicToolSpec"] --> EXP{"defer_loading?"}
    EXP -- "false" --> DIRECT["ToolExposure::Direct"]
    EXP -- "true" --> DEFERRED["ToolExposure::Deferred"]
    DIRECT --> OVERRIDE{"direct_only namespace?"}
    DEFERRED --> OVERRIDE
    OVERRIDE -- "match" --> DMO["DirectModelOnly"]
    OVERRIDE -- "no, Direct" --> MODE{"ToolMode?"}
    OVERRIDE -- "no, Deferred" --> SEARCH_GATE{"supports_search_tool && namespace_tools?"}
    OVERRIDE -- "no, Deferred" --> CODE_GATE{"Code Mode eligible?"}
    MODE -- "Default" --> SHAPE{"ToolSpec::Namespace?"}
    MODE -- "CodeMode" --> SHAPE
    MODE -- "CodeMode" --> CODE_GATE
    MODE -- "CodeModeOnly" --> CODE_GATE
    DMO --> SHAPE
    SHAPE -- "Function" --> MODEL
    SHAPE -- "Namespace" --> PROVIDER{"provider namespace_tools?"}
    PROVIDER -- "yes" --> MODEL
    PROVIDER -- "no" --> REG["ToolRegistry"]
    SEARCH_GATE -- "yes" --> SEARCH["tool_search surface"]
    SEARCH_GATE -- "no" --> REG
    CODE_GATE -- "yes" --> CODE["Code Mode nested surface"]
    CODE_GATE -- "no" --> REG
    MODEL --> REG
    SEARCH --> REG
    CODE --> REG
  end
  subgraph CORE[Core runtime]
    CALL["model or nested tool call"] --> REG
    REG --> WAIT["register pending waiter by call_id"]
    WAIT --> ITEM["ItemStarted: DynamicToolCall"]
    RESOLVE["resolve pending waiter"] --> OUT["function_call_output"]
  end
  subgraph APP[app-server and host]
    ITEM --> REQ["item/tool/call"]
    REQ --> EXEC["host domain executor"]
    EXEC --> RESP["DynamicToolCallResponse"]
    RESP --> DECODE["decode and map"]
  end
  DECODE --> RESOLVE
  WAIT -. "turn cancellation" .-> CANCEL["clear pending waiter"]
  CANCEL -. "handler or runtime abort" .-> OUT

ToolRegistry 里仍只是等待型 DynamicToolHandler。领域 executor 在宿主一侧;Core 负责 gate、dispatch、call id 和生命周期,不替宿主完成业务操作。

call_id 是这条链的钥匙

1. handler 先登记,再发出 started

DynamicToolHandler::handle_call 只接受 function payload,解析 JSON arguments 后调用 request_dynamic_tool。后者先创建 oneshot,再在 active-turn lock 内把 sender 按 call_id 插入 TurnState.pending_dynamic_tools,随后发出状态为 InProgressDynamicToolCallItem;app-server 随后把这次 started item 变成 item/tool/call 反向请求。

TurnState 把 dynamic waiter 与 approval、permissions、user input 和 elicitation waiter 分开存放。insert_pending_dynamic_tool 返回旧 sender;如果同一个 call_id 被重复使用,代码会覆盖旧 entry 并记录 warning。这是一个实际的 failure boundary:call id 不是展示字段,而是 pending state 的唯一索引,宿主不能自行改写。

本节源码依据(4 处)

2. app-server 把 lifecycle 变成宿主请求

app-server 的 v2 item model 将 dynamic call 表示为一个有 InProgressCompletedFailed 状态的 thread item,开始时没有 content items 和 success,结束时才补齐。ItemStarted 处理器看到这个 item 后先发标准 notification,再用同样的 thread_idturn_idcall_id、namespace、tool 和 arguments 调用 send_request(ServerRequestPayload::DynamicToolCall)

这里的“交回宿主”是协议动作,不是 UI 约定。DynamicToolCall 在 common protocol 中明确写成“Execute a dynamic tool call on the client”;宿主收到的是 JSON-RPC request,返回的是结构化 DynamicToolCallResponse。如果宿主不支持这个请求,它可以让 request 失败;Core 仍会走失败回填,而不会凭空执行一个本地替代品。

本节源码依据(3 处)

3. 宿主响应通过 app-server 回到 Core

on_call_response 等待 app-server request 的 callback。成功的 JSON 先反序列化成 DynamicToolCallResponse,再把 app-server content item 转成 Core 的 DynamicToolCallOutputContentItem,最后提交 Op::DynamicToolResponse { id: call_id, ... }Session::notify_dynamic_tool_response 取出同一个 call_id 的 sender,把 response 送进 Core handler 正在等待的 receiver。

收到 response 后,request_dynamic_tool 才发出 completed item:success: true 对应 Completed,false 对应 Failed,content items 和 duration 一并写入。handler 再把这些 content items 转成 FunctionToolOutput,交给模型请求层。app-server 的 event mapping 也会把 Core item 的 text/image 逐项映射回 v2 ThreadItem::DynamicToolCall,因此客户端看到的 lifecycle 与模型收到的 output 是同一次 call 的两个投影。

本节源码依据(4 处)

content items 怎样变成模型输入

Dynamic Tool response 不是一个任意字符串。Core 协议只允许 InputTextInputImage 两类 content item;转换到 Responses API 的 FunctionCallOutputContentItem 时,image 会被补上默认 detail。后续请求把这些 item 放进同一个 function_call_output 的 output 数组,call id 仍然保持不变。

这一步的所有权又回到 Core:宿主只决定 content items 的内容和 success;Core 决定怎样把它们序列化成下一次 Responses 请求。宿主不能直接写入模型历史,也不能跳过 Core 的 call-id 配对。

本节源码依据(1 处)

宿主返回坏数据时发生什么

这里至少有三种失败,不应写成一个笼统的“工具失败”:

失败输入发生位置Core 可见结果是否继续等宿主
JSON 缺字段、类型错误或无法反序列化app-server decode_response生成一条 dynamic tool response was invalidInputTextsuccess: false
InputImage 使用远程 HTTP URLapp-server image URL guard生成固定的 remote-image error text,success: false
app-server request 被普通 transport error 拒绝on_call_response callback生成 dynamic tool request failed 的失败文本

decode_response 对 malformed JSON 和远程图片走 fallback;只有合法且满足 URL 约束的 response 才会原样转给 Core。注意,固定版本的 exact round-trip 实验覆盖了 text 和 data URL image,但没有覆盖 malformed JSON;后者是源码明确的分支,不应伪装成同一条实验已验证。

相邻的固定测试专门验证了远程图片分支:宿主声称 success,但返回 https://example.com/tool.png,客户端看到 Failed,后续 Responses 请求里的 output 是 remote-image error,而不是一个可继续消费的图片。

还有一个容易漏掉的分支:如果 request error 被识别为 turn transition server-request error,on_call_response 会直接 return,不再提交 Op::DynamicToolResponse。这和普通 transport error 的 fallback 不同,原因是当前 turn 已经换代,旧请求不应该向新 turn 注入一条失败 output。

本节源码依据(2 处)

active turn 被取消时,谁负责收口

Dynamic handler 正在 rx_response.await 等待时,turn 可能被 interrupt、任务失败或 thread teardown 终止。Core 的 abort_turn_if_active / abort_all_tasks 先让 task 观察 cancellation,再调用 input queue 的 clear_pending;后者会清掉 TurnState.pending_dynamic_tools。这一步的硬保证是 sender 被丢弃,旧宿主 response 不再有原 turn 的 sender 可以回填;如果调用方错误复用同一个 call_id,它仍可能命中新 turn 的 entry,所以 call id 必须在未完成生命周期内保持唯一。

但不要把“sender 被丢弃”直接写成“必然会收到一个 Failed DynamicToolCall”。外层 ToolCallRuntime 还持有自己的 cancellation token。DynamicToolHandler 没有覆写 waits_for_runtime_cancellation,所以普通取消路径可以 abort dispatch task,并生成通用的 AbortedToolOutput;只有 handler 继续运行到 rx_response.await 返回 Err 的路径,request_dynamic_tool 才会发出 Failed item 和“cancelled before receiving a response”的错误。两条路径都完成 waiter 清理,但 lifecycle 事件和模型可见错误文本可能不同。

app-server 自己也跟踪 server request callback。thread unload 或 teardown 会调用 cancel_requests_for_thread,从 callback map 移除这个 thread 的 request;只有调用方传入 error 时,才会向 waiter 回填错误。于是有两层清理:Core 清掉当前 turn 的 domain-response sender,app-server 清掉发给宿主的 JSON-RPC callback。它们的顺序和错误文本可能不同,但共同目标是防止旧 turn 的 response 穿透到下一轮;这个结论以 call_id 不被新调用复用为前提。

这里有一个实用的 race 边界:宿主可能已经把 response 写回,但 Core 刚好清掉了 pending entry。notify_dynamic_tool_response 找不到 key 时只记录 warning;在 call_id 不复用的前提下,它不会把旧 response 放进别的 turn。对调用方来说,最重要的约束仍是不要复用旧 call_id,也不要把 turn 结束后的 response 当成新调用的答复。

本节源码依据(5 处)

指定实验:确实跑了一个测试

实验在第四部源码工作台创建并校准的 disposable archive 副本中运行;源码 checkout /tmp/codex-handbook-final-rust-v0.144.6 只提供源码基线。额外参数用于保留完整 JSON-RPC transcript:

: "${ARCHIVE_CODEX_RS:?先执行第四部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
just test --locked -p codex-app-server --test all dynamic_tool_call_round_trip_sends_content_items_to_model --no-capture

关键输出是:

PASS ... codex-app-server::all suite::v2::dynamic_tools::dynamic_tool_call_round_trip_sends_content_items_to_model
Summary ... 1 test run: 1 passed

fixture 做了四件有用的事:thread/start 提交一个 function spec;mock Responses server 发出 dyn-call-items-1;宿主 response 同时返回 inputText("dynamic-ok") 和一个 data URL inputImage;测试检查 completed item 与下一次 Responses 请求中的 function_call_output,其中 image detail 被序列化为 high。因此这条实验确实证明了 call_id -> host response -> content items -> model output 的 happy path。

本节源码依据(2 处)

running 1 是这次实验的最低真实性门槛。687 filtered out 说明同一个 all harness 里还有其他测试,但不代表它们被执行;如果只看到 running 0 tests,或者只看到某个 binary harness 的 filtered count,就必须把实验记为未运行,而不是“通过”。本次日志有完整测试名、... ok1 passed0 failed,所以 happy path 的结论成立。

实验没有覆盖两类情况:malformed JSON 的 fallback,以及 interrupt 正好落在 host response 到达前的 cancellation race。前者有 decode_response 源码和远程图片相邻测试,后者有 task/input queue 的 waiter 清理链;它们是可审计的源码结论,但不应借用这条 1 passed 的名义。

失败边界和可观测状态

可以把一次 dynamic call 的状态压成下面这张表。InProgress 在 Core 侧只证明 pending waiter 已登记、started item 已发出,不能反推 request 已经离开 app-server;Completed 也只说明 Core 收到了 success: true 的结构化 response,业务是否完成仍由宿主定义。这里与 MCP 的差别只在最后一跳:MCP 由 Codex 的 client runtime 调用 server,Dynamic Tools 由宿主接住反向请求;两者最终都受 ToolRouter 调度。

状态Core 的证据宿主的责任不能顺手推出
start-admitted非空 thread/start 通过 validate_dynamic_tools,spec 进入 session保证后续能识别声明的 tool name宿主已有可运行 executor
restoredInitialHistory::Resumed / ForkedSessionMeta 选入 specs,未重走 start validation仍能匹配并执行恢复后的 tool name恢复不等于通过当前 start admission
plannedDynamicToolHandler 已进入 ToolRegistry;Direct / Deferred 只是初始 exposure,最终 surface 仍受 planner gate 约束模型可见、可搜索或一定会调用
in_progresspending map 有 call_id,DynamicToolCallItem started收到 item/tool/call 后执行并保持 responserequest 已离开 app-server
completedresponse sender 命中,item success=true,model output 已构造返回真实 content items模型已经接受或采纳结果
failedfallback、success=false,或 handler 走 receiver-dropped 分支区分业务失败与 transport/cancel下一 turn 会自动重试
stalenotify 找不到 pending call_id,或 turn-transition request 被丢弃丢弃旧 response(call_id 不复用)可以把旧结果写进当前 turn

交给第 23 章:ownership-reversal timeline

最后把 ownership reversal 压成一条时间线:

T0  host owner       thread/start 提交 DynamicToolSpec(只有声明)
T1  Core planner     add_dynamic_tools 建立 DynamicToolHandler,并放进 ToolRegistry
T2  model/Core       模型发出 function call;Core 解析 arguments
T3  Core governor    以 call_id 在当前 active turn 登记 pending oneshot
T4  Core/app-server  发出 DynamicToolCall started,再发 item/tool/call
T5  host owner       宿主执行自己的 domain executor,返回 content_items + success
T6  app-server       解码/拒绝坏 payload,保留原 call_id,提交 Op::DynamicToolResponse
T7  Core owner       notify 取出 sender,完成 item,并生成 FunctionToolOutput
T8  model/Core       下一次 Responses 请求携带同一 call_id 的 function_call_output
TC  Core governor    turn abort 清掉 sender;宿主 request callback 同时或随后被取消

T2 前 Core 拥有模型调用和 pending state,T4 到 T5 宿主拥有领域执行,T6 之后 Core 收回 model continuation。第 23 章接着追同一张 ToolRouter 里的 Code Mode nested dispatch。