模型为什么能调用工具:两张表,一个闭环
沿 rust-v0.144.6 的 StepContext、PlannedTools、ToolRouter、ToolCallRuntime 与 ToolRegistry,解释模型可见规格和本地执行注册表怎样同源构建、故意不相等,并用 current-time exact test 验证 call_id 闭环。
第 13 章停在一个很窄的边界:tool future 成功时,第 12 章会把 output 写回 history;future 成为 Err 时,当前 drain 不会替它补造 durable output,也不会回滚已经发生的副作用。要解释这个 future 为什么有时返回模型可见失败、有时真的变成 Err,得把它拆开。
future 里保存的不是一个裸 handler。每个 follow-up sampling step 都先捕获一份 StepContext,再执行 built_tools(current StepContext) -> CoreToolPlanContext -> PlannedTools { runtimes, hosted_specs } -> ToolRouter { model_visible_specs, registry }。同一个 router 随后同时服务请求构造和本地执行。模型发回 call 后,路径继续进入 ToolCallRuntime、ToolInvocation 与 registry。
flowchart TB
accTitle: 工具规格到执行结果的闭环
accDescr: 当前 StepContext 生成 PlannedTools,分成模型可见 specs 与本地 registry;specs 经 Prompt 和 Responses 得到 FunctionCall,registry 经 ToolCallRuntime 和 ToolInvocation 执行 handler,ToolOutput 保留 call_id 后由第十二章 drain 并触发 follow-up
STEP[StepContext for one sampling step] --> PLAN[CoreToolPlanContext and PlannedTools]
PLAN --> VISIBLE[model_visible_specs]
PLAN --> REGISTRY[ToolRegistry]
PLAN --> HOSTED[hosted specs]
HOSTED --> VISIBLE
VISIBLE --> PROMPT[Prompt.tools]
PROMPT --> REQUEST[Responses request]
REQUEST --> CALL[OutputItemDone FunctionCall]
CALL --> CORECALL[core ToolCall]
CORECALL --> RUNTIME[ToolCallRuntime]
RUNTIME --> INVOCATION[ToolInvocation]
INVOCATION --> REGISTRY
REGISTRY --> HANDLER[CoreToolRuntime::handle]
HANDLER --> RESULT[AnyToolResult and ToolOutput]
RESULT --> OUTPUT[matching ResponseInputItem with call_id]
OUTPUT --> DRAIN[Chapter 12 ordered recording drain]
DRAIN --> FOLLOWUP[follow-up sampling step]
这张图只画一轮能力闭环。第 12 章已经拥有 done item、future 入队、按入队顺序的 recording drain 与 follow-up;本章只引用那个出口,不重新解释 history 持久化。第 10 章已经拥有 Prompt.tools 到普通 Responses、Lite 与 output_schema 的序列化差异,本章也停在 Prompt.tools 这一侧。
每次 sampling step 重建工具表,retry 不换表
run_turn 的外层 sampling/action loop 在第一次使用预先捕获的 first_step_context,后续每次正常 follow-up 回到 loop 时重新调用 capture_step_context。注释把目的写得很直白:context、advertised tools 与 tool calls 必须共享同一个 request view。环境、MCP snapshot 或 capability root 可以在两个 sampling step 之间变化,但不能在同一步的请求与执行之间悄悄换掉。
进入 run_sampling_request 后,built_tools 在 retry loop 之前只执行一次。它用 step 中固定的 MCP tools、plugins、connectors、extension executors 和 dynamic tools 建出 ToolRouter,随后用同一个 router 创建 ToolCallRuntime。sampling retry 可以从最新 history 重建 Prompt.input,同一次 transport retry 也可以在 client 层重新建流,但两者都复用这一份 router 和 StepContext,不会在重试中途重新枚举工具。
built_tools 完成异步资料收集后,把 step_context 和 ToolRouterParams 交给 ToolRouter::from_context。真正的纯规划入口是 build_tool_specs_and_registry:它先形成 CoreToolPlanContext,再把每类工具 source 加进 PlannedTools,最后一次性投影出 visible specs 与 registry。
同一个 ToolRouter 保存两种能力视图
PlannedTools 只有两类原料:runtimes 与 hosted_specs。本地 runtime 实现共享的 ToolExecutor<ToolInvocation> 合同,自己提供 tool_name()、spec()、exposure()、supports_parallel_tool_calls() 和 handle()。这避免了最危险的一类漂移:代码在一处声明 schema,在另一处按另一个名字注册 handler。
规划结束时,代码遍历 runtimes。符合直接暴露策略的 runtime 调用自己的 spec() 进入 model_visible_specs;所有 runtimes 则进入 ToolRegistry::from_tools。hosted specs 只追加到前一条分支。于是同一份 plan 生成两张用途不同的表:
model_visible_specs -> Prompt.tools -> Responses request是模型能力视图。registry -> handler是本地可执行能力视图。
模型只收到第一张表。它看不到 registry、handler、Session、审批对象、沙箱策略、cancellation token 或 turn diff tracker。那些数据直到模型已经输出 call、本地开始构造 ToolInvocation 时才进入执行路径。
两张表故意不相等
下面这张 exposure 表以 planner 的基础投影为准;具体 mode、provider 与 feature gate 仍可能继续收窄模型视图。
| planner 输入 | 模型初始可见 | 本地 registry | 关键边界 |
|---|---|---|---|
Direct | 是 | 是 | 普通直接工具;启用 Code Mode 时也可进入 nested surface |
DirectModelOnly | 是 | 是 | 仍是普通模型工具,但明确不进入 Code Mode nested surface |
Deferred | 否 | 是 | 初始 specs 省略,保留 search metadata,发现后才能调用 |
Hidden | 否 | 是 | 只为 dispatch 或兼容路径保留 |
| hosted spec | 是 | 否 | provider 执行,local runtime 不拥有它 |
Direct、DirectModelOnly、Deferred、Hidden 与 hosted spec 的不对称是有意设计。ToolExposure 是 planner policy,不是 Responses API field。模型收到的是已经投影后的 ToolSpec,wire 里不会再携带一个 exposure 枚举让 provider 决定本地注册。
还有两处容易误读。第一,多个 namespace runtime 产生的模型规格会按 namespace 合并,再对 nested function 排序;registry 仍按完整 ToolName 保存每个 runtime。第二,provider 不支持 namespace tools 时,planner 会从 model-visible specs 中滤掉 ToolSpec::Namespace,但 registry 已经从 runtimes 建好,因此可能出现“只注册、不暴露”。Code Mode 怎样把工具投影成 nested surface、怎样改名和二次 dispatch,留到第 23 章。
Deferred 的负向测试正好把差异钉住:extension_echo 不在 visible specs,却存在于 registry,exposure 仍是 Deferred;模型初始只看到用于发现它的 tool_search。
所以正确不变量是:local direct spec/runtime 同源;其他差异必须能由 exposure policy、provider capability 或 hosted ownership 解释。spec names 不等于 registry names,不能把两个 name set 的相等当成健康检查。
FunctionCall 还不是 ToolInvocation
模型发回的 ResponseItem::FunctionCall 只有 namespace、name、arguments 与 call_id 等协议字段。ToolRouter::build_tool_call 先把它收窄为 core ToolCall { tool_name, call_id, payload };custom call 则形成 ToolPayload::Custom { input }。这里仍没有 Session、step 或取消所有权。
ToolCallRuntime 才把 request-scoped 执行依赖带进来。它在 sampling 开始时捕获 router、Session、同一 StepContext、turn diff tracker 与并行 gate;每个 call 另收一枚 child cancellation token。进入 router 的 inner dispatch 后,完整对象才出现:
ToolInvocation {
session,
turn: Arc::clone(&step_context.turn),
step_context,
cancellation_token,
tracker,
call_id,
tool_name,
source,
payload,
}
接着是 ToolRouter -> ToolRegistry -> CoreToolRuntime::handle,但 router 之后并不是“所有权瞬间完全交给 handler”。registry 先按 ToolName 查 runtime、检查 payload kind,再在 handler 前后挂上 PreToolUse/PostToolUse registry hook。PreToolUse 可以 block,也可以让 runtime 用 with_updated_hook_input 重建 ToolInvocation,handler 只看到改写后的输入。PostToolUse 只在 handler 成功返回且结果被判为 success 时运行;它可以阻断结果、用 feedback 替换模型可见输出,并追加 context,但不能撤销已经发生的工具副作用。
审批、permission profile 和 sandbox 的完整决策留到第 17 章,本章只保留一条会影响错误归因的边界:initial approval denial 发生在 handler 已进入 orchestrator、但第一次 ToolRuntime::run 尚未开始时;sandbox retry approval denial 则发生在第一次 attempt 已返回 denial 之后、第二次 attempt 之前。后者不能写成“整个 handler 尚未执行、一定没有副作用”。
对已经成功构造成 ToolCall 的路径,原始 call_id 必须穿过整个闭环。registry 在调用 handler 前复制它;handler 返回 ToolOutput 后,AnyToolResult { call_id, payload, result } 继续保存它;into_response() 最终把同一个 id 交给 ToolOutput::to_response_item。function、custom 与 tool-search payload 会形成各自 matching ResponseInputItem,下一次 request 才能把 output 与原 call 配对。
并行许可与本地调度是两件事
Prompt.parallel_tool_calls 来自 ModelInfo.supports_parallel_tool_calls,表示这一份 Responses 请求是否允许模型生成并行 tool calls。它不保证本地 handler 可以并行。每个 runtime 的 supports_parallel_tool_calls() 默认是 false,registry 再按本次 ToolCall.tool_name 查询这一局部能力。
ToolCallRuntime 用一个共享 RwLock<()> 执行实际 admission。支持并行的 runtime 取 read guard;不支持并行的 runtime 取 write guard。多个 read guard 可以重叠,一个 write guard 则会等待其他调用退出并独占执行。因此不能写成“打开 parallel_tool_calls 后所有工具都会并发”。
并发 handler 仍可能乱序完成,但它们不会按完成顺序直接追加 history。OutputItemDone 依模型 call 的到达顺序把 futures push_back 到 FuturesOrdered;stream 结束后,recording drain 也按这一个入队顺序取结果,先按顺序更新 live history,再尝试 append rollout。于是 parallel_tool_calls、local supports_parallel_tool_calls/RwLock 调度、FuturesOrdered 回填分别拥有三层语义:模型生成许可、本地执行 admission、live-history 顺序;rollout 持久化不是这条顺序保证的一部分。
这个 ordered recording drain 与 follow-up 的完整控制过程已经由第 12 章负责。本章只补上 future 内部为什么可能并发完成,却仍按 call 入队顺序交回第 12 章;rollout append 的失败边界由第 12、13 章负责。
错误在哪一层变成模型可见 output
最实用的排障顺序不是先看错误字符串,而是看错误在哪一层形成:
| 失败位置 | 当前错误形态 | 是否形成 matching output | 后续边界 |
|---|---|---|---|
| registry miss | FunctionCallError::RespondToModel | 是 | ToolCallRuntime::failure_response 保留 call id,模型可见失败 |
| payload mismatch | FunctionCallError::Fatal | 否 | 转成 future Err(CodexErr::Fatal),接回第 13 章 |
| handler returns | Ok(ToolOutput) | 是 | 包成 AnyToolResult,再按 payload 生成 response item |
| handler returns | 普通 RespondToModel | 是 | 包成 success: false 的模型可见 output |
| handler returns | Fatal | 否 | future Err;不会伪装成普通工具输出 |
registry miss、RespondToModel、payload mismatch、Fatal 与 handler result 是三道不同的错误门。registry 查不到名字,通常说明模型调用了本地不可执行的工具,runtime 把诊断写回模型;registry 找到了名字但 payload kind 不兼容,说明内部路由合同已经破裂,所以升级为 Fatal;handler 自己返回的普通失败仍可成为下一轮模型输入。
取消还有独立语义。若 call 的 terminal outcome 尚未被 handler 抢先完成,ToolCallRuntime 会中止 task,或按 runtime policy 等待 teardown,然后构造 AbortedToolOutput。这个 AnyToolResult 继续携带原 call id,最终成为模型可见的 aborted output;它不是自动升级成 future Err。已经发生的工具副作用不会因此自动回滚。
这套 runtime 也没有通用“工具失败自动 retry”。模型在看到失败 output 后可以决定再次调用,但那是一次新的模型行为;local handler 的这次副作用不会由 registry rollback。第 13 章已经说明 future Err 与 retry/no-rollback 的外层边界,这里不重复恢复矩阵。
用 current-time 工具验证两次 request
CurrentTimeHandler 是一个足够小的闭环样本。它以 namespace clock.curr_time 注册,spec 与 handler 来自同一个 runtime;handler 从 Session service 中取得 time provider,把结果包装为 CurrentTimeOutput,再由 ToolOutput::to_response_item 使用传入的 call id。
这条工具默认并不存在于模型表。Feature::CurrentTimeReminder 在当前固定版本是 under-development 且 default_enabled: false;fixture 必须显式开启 feature。planner 看到 feature 后才加入 CurrentTimeHandler。自动 time reminder 与 clock tool 是两条路径:前者在 sampling 前向 context 写 reminder,后者只在模型发出 clock.curr_time call 后通过 registry 执行。它们共享 TimeProvider,但不能混写成一个动作。
exact fixture 使用 deterministic TimeProvider:每次 current_time() 把固定时间推进 60 秒。它架两次 mock Responses request。request 0 暴露 namespace clock.curr_time,mock response 用 CALL_ID = "current-time" 发回 function call;request 1 用同一 call_id 查到 output,内容必须是 SECOND_REMINDER。第一份时间已经被自动 reminder 路径读取,工具 handler 再读取下一份时间,所以断言是 second reminder。
本章不再复制 archive 与 lockfile 校准脚本。每个实验块都 source 本仓库的 scripts/codex-handbook-part3.sh,在自己的 shell 中调用 prepare_codex_part3,并由调用方注册 cleanup trap;没有博客 checkout 时,把该 helper 文件保存到任意路径,再通过 CODEX_HANDBOOK_PART3_HELPER 指向它。这样跳读本章或单独复制任一块都不会依赖另一个已经退出的 shell。下面的 fail-closed 断言确保测试只在 disposable 副本中运行,Cargo target 也不会落进固定 checkout:
SOURCE_ROOT="${SOURCE_ROOT:-/tmp/codex-handbook-final-rust-v0.144.6}"
SOURCE_DIR="$SOURCE_ROOT/codex-rs"
CODEX_HANDBOOK_PART3_HELPER="${CODEX_HANDBOOK_PART3_HELPER:-$PWD/scripts/codex-handbook-part3.sh}"
test -f "$CODEX_HANDBOOK_PART3_HELPER"
source "$CODEX_HANDBOOK_PART3_HELPER"
prepare_codex_part3
trap 'cleanup_codex_part3 "$?"' EXIT
ARCHIVE_CODEX_RS="$ARCHIVE_DIR/codex-rs"
export CARGO_TARGET_DIR="$ARCHIVE_DIR/target"
test -d "$ARCHIVE_CODEX_RS"
test "$CARGO_TARGET_DIR" = "$ARCHIVE_DIR/target"
test -z "$(git -C "$SOURCE_ROOT" status --porcelain)"
cd "$ARCHIVE_CODEX_RS"
run_checked_test current-time 1 \
just --set rust_min_stack 16777216 test --locked -p codex-core --test all --no-capture \
-E 'test(=suite::current_time_reminder::current_time_tool_returns_the_latest_time)'
关键输出为:
PASS suite::current_time_reminder::current_time_tool_returns_the_latest_time
Summary: 1 test run: 1 passed
共享 runner 会同时拒绝 zero-match 与 fixture skip。这个 fixture 只证明两次本机 mock request、显式 feature gate、namespace spec、deterministic TimeProvider、同一 call id 与 SECOND_REMINDER 的 round trip。它不证明真实模型会选择该工具,不经过真实 Responses API、墙钟、审批或沙箱,也没有覆盖 retry 与 concurrency。
负向 exact test 还能单独证明 Deferred exposure:
set -euo pipefail
SOURCE_ROOT="${SOURCE_ROOT:-/tmp/codex-handbook-final-rust-v0.144.6}"
SOURCE_DIR="$SOURCE_ROOT/codex-rs"
CODEX_HANDBOOK_PART3_HELPER="${CODEX_HANDBOOK_PART3_HELPER:-$PWD/scripts/codex-handbook-part3.sh}"
test -f "$CODEX_HANDBOOK_PART3_HELPER"
source "$CODEX_HANDBOOK_PART3_HELPER"
prepare_codex_part3
trap 'cleanup_codex_part3 "$?"' EXIT
cd "$ARCHIVE_CODEX_RS"
run_checked_test deferred-tool 1 \
just test --locked -p codex-core --lib --no-capture \
-E 'test(=tools::spec_plan::tests::deferred_extension_tools_are_discoverable_with_tool_search)'
它检查 deferred runtime 在 registry 中、初始 visible specs 中没有;不把这个局部测试扩大成所有 provider 或所有 tool-search source 的结论。
get_context_remaining 只追直接接线
get_context_remaining 在本章只占一条直接 wiring。Feature::TokenBudget 打开时,planner 注册 GetContextRemainingHandler;handler 调用 context_window_token_status(session, turn),把 tokens_until_compaction 渲染成模型可见 output。这里足以说明 handler 怎样从 invocation 读 Session/Turn,再走同一 call-id output 闭环。
本章不复盘它的提交演进,不展开 Code Mode output schema,也不重新计算 BodyAfterPrefix accounting。Code Mode projection 留到第 23 章,历史演进与验证矩阵留到第 36 章,token accounting 仍由第 6 章已有边界负责。普通 ToolSpec.output_schema 在 Responses 序列化时被跳过的细节也已经属于第 10 章。
把完整 invocation 交给 apply_patch
到这里,第 15 章不再需要从一个抽象“工具调用”开场。它会接到一份已经完成通用路由的具体对象:
ToolInvocation {
session,
turn: Arc::clone(&step_context.turn),
step_context,
cancellation_token,
tracker,
call_id,
tool_name: ToolName::plain("apply_patch"),
source: ToolCallSource::Direct,
payload: ToolPayload::Custom { input },
}
从这一点向内,通用分析轴是 parse -> permission -> execute -> event。但 apply_patch 的 begin/terminal event、patch 解析、部分写入、FileChange 与 TurnDiff 的真实先后都属于第 15 章:apply_patch 怎样改变工作树。本章不会预判它是事务,也不会把取消或失败写成自动 rollback。