Dynamic Tools 为什么把执行权交回宿主
从 thread/start 的动态工具声明出发,沿 Core 的 ToolRegistry、DynamicToolHandler、pending oneshot 和 app-server 的 item/tool/call 反向请求,拆开一个没有本地领域实现的工具怎样由宿主执行,再把结果送回模型。
第 21 章的 MCP 链路是 Codex 作为客户端去连接外部 server。Dynamic Tools 的方向反过来:宿主先告诉 Codex“模型可以看到哪些函数”,模型真正调用时,Codex 再把请求发回宿主。这个反向箭头不是协议表面上的小差异,它决定了谁拥有执行权,也决定了 Core 能够保证什么。
在固定版本的 app-server 测试里,一次调用的可见顺序很短:先收到一个 item/started,然后 app-server 发出 item/tool/call 请求;宿主返回 contentItems,Core 发出 item/completed,后续 Responses 请求里出现同一个 call_id 的 function_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 先把它们分开:
| 层 | owner | executor | governor | 固定版本里能确认的事实 |
|---|---|---|---|---|
| spec admission | thread 的宿主客户端提交声明 | app-server thread_start_task 接收并转交 | 非空 thread/start 才调用 validate_dynamic_tools | 恢复自 SessionMeta 的声明不重走这道 gate;声明也不会带来实现 |
| model-visible tool | Core session / turn | add_dynamic_tools 建立 DynamicToolHandler,再放进 ToolRegistry | exposure override、Code Mode 和 provider 能力 | 初始 exposure 与 registry entry 都不等于最终对模型可见 |
| domain execution | 宿主客户端 | 宿主自己的进程、服务或应用代码 | 宿主自己的权限与业务规则 | item/tool/call 是反向请求;Core 只能等待结果 |
| pending call | 当前 active turn | Tokio oneshot(只能发送一次结果的单次通道),即 Sender/Receiver<DynamicToolResponse> | TurnState.pending_dynamic_tools 与 call_id | 回填必须命中当前 turn 的同一 key |
| model continuation | Core session | FunctionToolOutput 和 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;函数保存 name、description、input_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 时,会从 InitialHistory 的 SessionMeta.dynamic_tools 读回它们。显式传入非空 vector 时,则直接使用新值,不查 history。
这两条路径没有汇合成同一个 admission gate。validate_dynamic_tools 只位于非空 thread/start 的请求处理路径;Session::new 从 SessionMeta 恢复 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::Cleared;InitialHistory::New | InitialHistory::Cleared => None 是 get_dynamic_tools 的无历史分支。resume_thread_with_history 向 Core 传入 Vec::new(),fork_thread_with_initial_history 也传入 Vec::new(),让 Session::new 有机会从 InitialHistory::Resumed 或 InitialHistory::Forked 的 SessionMeta 读取声明。这里的空 vector 是“允许从历史取值”的哨兵,不是说恢复后的 session 没有 dynamic tools。
本节源码依据(7 处)
Core 注册的是等待型 handler
每个 turn 的 add_dynamic_tools 都会把声明转换成 DynamicToolHandler runtime。普通 function 生成 ToolSpec::Function,namespace 中的 function 生成 ToolSpec::Namespace。接下来不是一次二选一,而是几道彼此独立的 gate:
| 阶段 | 条件与变换 | model request | tool_search | Code Mode nested | registry |
|---|---|---|---|---|---|
| direct exposure | defer_loading=false 得到 ToolExposure::Direct;它只是 exposure,不保证最终 model-visible | Direct 先成为候选 | 无 | 仍可能进入 | runtime 进入 |
| deferred exposure | defer_loading=true 得到 ToolExposure::Deferred;它是初始 exposure,不保证经 tool_search 可搜索 | 不直接进入 | 只成为候选 | 仍可能进入 | runtime 仍进入 |
| namespace override | 独立配置 direct_only_tool_namespaces 把 ToolExposure::Direct 覆盖为 DirectModelOnly,也把 ToolExposure::Deferred 覆盖为 DirectModelOnly | 强制直接候选 | 退出 deferred 集合 | DirectModelOnly 不进入 | 保留 |
| Code Mode gate | is_hidden_by_code_mode_only 在 CodeModeOnly 隐藏普通 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 spec | Deferred 才可搜索 | 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,随后发出状态为 InProgress 的 DynamicToolCallItem;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 表示为一个有 InProgress、Completed、Failed 状态的 thread item,开始时没有 content items 和 success,结束时才补齐。ItemStarted 处理器看到这个 item 后先发标准 notification,再用同样的 thread_id、turn_id、call_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 协议只允许 InputText 和 InputImage 两类 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 invalid 的 InputText,success: false | 否 |
InputImage 使用远程 HTTP URL | app-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,就必须把实验记为未运行,而不是“通过”。本次日志有完整测试名、... ok、1 passed 和 0 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 |
restored | InitialHistory::Resumed / Forked 从 SessionMeta 选入 specs,未重走 start validation | 仍能匹配并执行恢复后的 tool name | 恢复不等于通过当前 start admission |
planned | DynamicToolHandler 已进入 ToolRegistry;Direct / Deferred 只是初始 exposure,最终 surface 仍受 planner gate 约束 | 无 | 模型可见、可搜索或一定会调用 |
in_progress | pending map 有 call_id,DynamicToolCallItem started | 收到 item/tool/call 后执行并保持 response | request 已离开 app-server |
completed | response sender 命中,item success=true,model output 已构造 | 返回真实 content items | 模型已经接受或采纳结果 |
failed | fallback、success=false,或 handler 走 receiver-dropped 分支 | 区分业务失败与 transport/cancel | 下一 turn 会自动重试 |
stale | notify 找不到 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。