codex cli 源码教程 | 第十一篇:ToolRouter 如何组织所有能力
上一篇分析了 Conversation History、项目指令和自动压缩。
当 Prompt 即将发送给模型时,还缺少一组关键输入:
pub struct Prompt {
pub input: Vec<ResponseItem>,
pub(crate) tools: Vec<ToolSpec>,
pub(crate) parallel_tool_calls: bool,
pub base_instructions: BaseInstructions,
pub output_schema: Option<Value>,
pub output_schema_strict: bool,
}
其中:
tools
parallel_tool_calls
共同定义了模型能够请求哪些动作。
但模型看到一个 Tool Spec,并不意味着 Codex 本地一定有对应 Handler。
反过来,本地 Registry 中存在一个 Handler,也不意味着它一定出现在当前模型请求中。
Codex 同时支持:
Shell
Unified Exec
Apply Patch
Plan
Request User Input
View Image
MCP
Dynamic Tool
Extension Tool
Tool Search
Code Mode
Hosted Web Search
Multi-Agent
这些能力还会受到以下条件影响:
ModelInfo
Provider Capability
Feature Flag
Session Source
Environment Count
Tool Mode
MCP Policy
App Policy
Authentication
Thread Dynamic Tool Configuration
因此,工具系统不能只是:
HashMap<String, fn(...)>
它需要同时解决:
Spec 生成
能力规划
可见性控制
Namespace 合并
名称冲突
延迟发现
运行时分发
Hook
生命周期通知
并发门禁
取消清理
有序结果回填
本篇将从 ToolRouter 出发,完整拆解 Codex 如何把所有能力组织成一次可执行的
Sampling Tool Surface。
本篇目标
阅读完成后,你应该能够:
- 区分
ToolSpec、ToolExecutor、CoreToolRuntime、ToolRegistry和ToolRouter。 - 解释模型可见工具与本地可执行工具为什么不是同一集合。
- 区分
Direct、Deferred、DirectModelOnly和Hidden。 - 跟踪内建、MCP、Dynamic 和 Extension Tool 的规划顺序。
- 解释 Namespace 如何参与结构化查找、合并与冲突隔离。
- 说明 Tool Search 如何让 Deferred Tool 在需要时才进入上下文。
- 区分 Hosted Tool 与 Client-Executed Tool。
- 跟踪一个
ResponseItem从 Tool Call 到 Handler Output 的完整路径。 - 解释全局 Parallel Flag 与单工具 Parallel Capability 的区别。
- 说明工具可以并行执行,但结果仍按模型调用顺序回填的原因。
- 理解取消、清理和生命周期通知如何避免重复终态。
- 实现并测试一个只读的最小内建 Tool Handler。
1. 本篇源码地图
工具规划与路由
| 文件 | 职责 |
|---|---|
| tools/spec_plan.rs | 汇总工具来源、应用 Exposure 和 Feature Gate |
| tools/router.rs | 保存 Spec/Registry,解析模型 Tool Call |
| tools/registry.rs | 查找 Handler、运行 Hook 和生命周期 |
| tools/parallel.rs | 并发门禁、取消和失败输出 |
| tools/context.rs | Invocation、Payload 和 Core Output |
| tools/mod.rs | Tool Mode 和旧边界名称适配 |
共享工具抽象
| 文件 | 职责 |
|---|---|
| tools/src/tool_executor.rs | ToolExecutor 与 ToolExposure |
| tools/src/tool_spec.rs | Responses API Tool Spec |
| tools/src/responses_api.rs | Function、Namespace 与 Loadable Spec |
| tools/src/tool_payload.rs | Function、Tool Search 与 Custom Payload |
| tools/src/tool_output.rs | Tool Output 公共契约 |
| protocol/src/tool_name.rs | 结构化 ToolName |
工具来源
| 文件 | 职责 |
|---|---|
| tools/handlers | 内建 Handler |
| mcp_tool_exposure.rs | MCP Direct/Deferred 划分 |
| handlers/mcp.rs | MCP Tool Adapter |
| handlers/dynamic.rs | App Server Dynamic Tool |
| handlers/extension_tools.rs | Extension Tool Adapter |
| extension-api/contributors.rs | ToolContributor 扩展点 |
延迟发现与 Code Mode
| 文件 | 职责 |
|---|---|
| handlers/tool_search.rs | BM25 Tool Search |
| tools/src/tool_search.rs | Search Metadata 与 Loadable Spec |
| tools/code_mode/mod.rs | Code Mode Service 与 Nested Tool |
| tools/code_mode/delegate.rs | Runtime 到 ToolRouter 的反向调用 |
| tools/src/code_mode.rs | Tool Spec 到 Code Mode Definition |
Sampling 与测试
| 文件 | 职责 |
|---|---|
| session/turn.rs | 构建 Router、启动 Tool Future、回填结果 |
| spec_plan_tests.rs | 工具规划矩阵 |
| router_tests.rs | Namespace 与路由测试 |
| registry_tests.rs | Hook、生命周期和 Registry 测试 |
| tool_parallelism.rs | 并行与有序回填集成测试 |
完整路径是:
StepContext
-> built_tools
-> MCP Snapshot
-> Plugin / Connector Snapshot
-> Extension Tool Contributors
-> Dynamic Tool Specs
-> build_tool_router
-> PlannedTools
-> Exposure Override
-> Tool Search Executor
-> Code Mode Executors
-> Model Visible Specs
-> ToolRegistry
-> Prompt.tools
-> Model ResponseItem
-> ToolRouter::build_tool_call
-> ToolCallRuntime
-> ToolRegistry::dispatch_any
-> CoreToolRuntime::handle
-> ToolOutput
-> FuturesOrdered
-> Conversation History
-> 下一次 Sampling
2. 先区分五个核心对象
工具系统最容易混淆的是以下五层。
ToolSpec
ToolSpec 是给模型看的协议声明:
Name
Description
Input Schema
Namespace
Freeform Grammar
Hosted Tool Configuration
它回答:
模型应该如何请求这个能力?
ToolExecutor
ToolExecutor<Invocation> 把 Spec 与执行实现绑定在一起:
pub trait ToolExecutor<Invocation>: Send + Sync {
fn tool_name(&self) -> ToolName;
fn spec(&self) -> ToolSpec;
fn exposure(&self) -> ToolExposure;
fn search_info(&self) -> Option<ToolSearchInfo>;
fn supports_parallel_tool_calls(&self) -> bool;
fn handle(&self, invocation: Invocation)
-> ToolExecutorFuture<'_>;
}
它回答:
这个工具叫什么、如何声明、如何执行?
CoreToolRuntime
CoreToolRuntime 在共享 ToolExecutor 上增加 Core 需要的控制面:
Payload Kind 检查
取消清理策略
Telemetry Tags
Pre/Post Hook Payload
Hook Input Rewrite
Argument Diff Consumer
ToolRegistry
ToolRegistry 保存:
HashMap<ToolName, Arc<dyn CoreToolRuntime>>
它回答:
收到一个结构化 ToolName 后,由哪个 Runtime 执行?
ToolRouter
ToolRouter 同时持有:
pub struct ToolRouter {
registry: ToolRegistry,
model_visible_specs: Vec<ToolSpec>,
}
它把:
模型协议面
本地执行面
放在同一个 Sampling 快照中。
3. ToolRouter 对象关系图
ToolRouter
/ \
/ \
v v
model_visible_specs ToolRegistry
Vec<ToolSpec> |
| v
| HashMap<ToolName, Runtime>
| |
v v
Responses Request CoreToolRuntime
|
v
ToolExecutor
/ | \
spec exposure handle
模型侧只接触:
Vec<ToolSpec>
执行侧只通过:
ToolName
ToolPayload
ToolInvocation
进入 Registry。
4. 模型可见集合与可执行集合不是同一个集合
设:
V = 当前 Prompt 中的 model_visible_specs
R = 当前 ToolRegistry 中已注册的 Runtime
Codex 不要求:
V == R
实际存在三种关系。
同时可见且可执行
例如:
exec_command
apply_patch
update_plan
view_image
已注册但当前不可见
例如:
Hidden Legacy shell_command
Deferred MCP Tool
CodeModeOnly 下的 Nested Tool
可见但没有本地 Runtime
例如 Hosted Responses Tool:
web_search
它由模型服务端执行,不经过本地 ToolRegistry。
因此:
模型可见
!=
本地可 Dispatch
是设计本身,不是状态不一致。
5. ToolSpec 有五种协议形态
ToolSpec 定义为:
pub enum ToolSpec {
Function(ResponsesApiTool),
Namespace(ResponsesApiNamespace),
ToolSearch {
execution: String,
description: String,
parameters: JsonSchema,
},
WebSearch { /* hosted options */ },
Freeform(FreeformTool),
}
它们分别服务:
| 形态 | 典型用途 |
|---|---|
Function |
JSON 参数函数 |
Namespace |
一组结构化命名的函数 |
ToolSearch |
延迟工具发现 |
WebSearch |
服务端 Hosted Search |
Freeform |
apply_patch、Code Mode Script |
ToolRouter 不会把所有工具强制压成普通 Function。
6. ResponsesApiTool 同时携带 Wire 和本地元数据
Function Tool 的核心结构为:
pub struct ResponsesApiTool {
pub name: String,
pub description: String,
pub strict: bool,
pub defer_loading: Option<bool>,
pub parameters: JsonSchema,
#[serde(skip)]
pub output_schema: Option<Value>,
}
注意:
output_schema
被 serde(skip) 标记。
它当前主要供本地 Code Mode Tool Definition 使用,不直接进入普通 Responses API
Tool JSON。
因此,不要把结构体中的每个字段都理解为 Wire Field。
7. Function Tool 参数仍以字符串进入 Core
模型返回:
ResponseItem::FunctionCall {
name,
namespace,
arguments,
call_id,
..
}
其中:
arguments: String
内容通常是 JSON 文本。
Router 不会在第一时间把每个工具解析为不同 Rust 类型,而是先构造:
ToolPayload::Function { arguments }
具体 Handler 再使用:
parse_arguments<T>(&arguments)
完成类型化反序列化。
8. Freeform Tool 不走 JSON 参数
apply_patch 的 Spec 是:
ToolSpec::Freeform(FreeformTool {
name: "apply_patch",
format: FreeformToolFormat {
r#type: "grammar",
syntax: "lark",
definition,
},
..
})
模型返回:
ResponseItem::CustomToolCall {
input,
..
}
Router 将其转换为:
ToolPayload::Custom { input }
Handler 必须显式声明自己接受 Custom Payload。
9. ToolPayload 是 Router 与 Handler 的协议中间层
共享枚举只有三种:
pub enum ToolPayload {
Function {
arguments: String,
},
ToolSearch {
arguments: SearchToolCallParams,
},
Custom {
input: String,
},
}
它避免 Registry 直接依赖整个 ResponseItem。
这条边界很重要:
Responses Protocol
-> Router 解析
-> Canonical ToolPayload
-> Runtime 执行
10. ToolName 保留 Namespace 结构
ToolName 不是扁平字符串:
pub struct ToolName {
pub name: String,
pub namespace: Option<String>,
}
例如:
ToolName::plain("exec_command")
ToolName::namespaced(
"mcp__codex_apps__calendar",
"create_event",
)
Registry 的 Hash Key 使用完整结构。
所以:
plain:create_event
calendar:create_event
gmail:create_event
是三个不同工具。
11. 不要用 Display 结果做核心匹配
ToolName::Display 会连接:
namespace + name
旧 Hook、Telemetry 和兼容边界也会使用 flat_tool_name。
但源码明确要求:
比较
排序
Registry 查找
继续使用结构化 ToolName。
扁平字符串只用于必须兼容旧接口的边界。
12. ToolExecutor 把 Spec 和 Runtime 绑在同一个对象上
早期工具系统常见的风险是:
Spec 在一个表
Handler 在另一个表
两边独立维护
这样很容易出现:
Schema 已更新
Handler 仍按旧字段解析
Codex 的 ToolExecutor 要求同一个实现同时提供:
tool_name()
spec()
handle()
这不能完全消除语义漂移,但把可见声明和执行实现放到了同一个所有权边界中。
13. CoreToolRuntime 提供默认 Function 行为
CoreToolRuntime 的默认 matches_kind 接受:
Function
ToolSearch
默认 Pre/Post Hook 也只针对:
ToolPayload::Function
Freeform Handler 必须覆盖:
fn matches_kind(&self, payload: &ToolPayload) -> bool
例如 ApplyPatchHandler 只接受:
ToolPayload::Custom
14. ToolInvocation 携带一次调用所需的完整快照
Registry 接收:
pub struct ToolInvocation {
pub session: Arc<Session>,
pub turn: Arc<TurnContext>,
pub(crate) step_context: Arc<StepContext>,
pub cancellation_token: CancellationToken,
pub tracker: SharedTurnDiffTracker,
pub call_id: String,
pub tool_name: ToolName,
pub source: ToolCallSource,
pub payload: ToolPayload,
}
它同时包含:
Session 服务
Turn 配置
Step 级环境与 MCP 快照
取消信号
文件差异追踪器
调用 ID
结构化工具名
调用来源
参数 Payload
Handler 不需要再从全局单例拼装运行上下文。
15. StepContext 固定了工具看到的运行环境
第十篇介绍过 StepContext。
Tool Runtime 保存:
step_context: Arc<StepContext>
源码注释明确说明:
Tool calls may run later,
so retain the step whose tool list advertised them.
这保证:
模型看到工具时的环境快照
==
工具真正执行时使用的环境快照
避免流式响应期间环境变化导致 Spec 与执行目标错位。
16. ToolRouter 是 Sampling 级快照
run_sampling_request 会先调用:
let router = built_tools(
sess.as_ref(),
step_context.as_ref(),
&cancellation_token,
).await?;
随后同一次逻辑 Sampling 的网络重试会复用这个 Router。
下一次 Follow-up Sampling 会重新:
捕获 StepContext
构建 ToolRouter
因此其生命周期更接近:
Sampling Step
而不是永久 Session Registry。
17. built_tools 先收集运行时输入
built_tools 负责收集:
Step MCP Tool Snapshot
Loaded Plugins
Connector Snapshot
App Enabled State
Tool Suggest Candidates
Extension Tool Executors
Turn Dynamic Tool Specs
然后构造:
ToolRouterParams {
mcp_tools,
deferred_mcp_tools,
tool_suggest_candidates,
extension_tool_executors,
dynamic_tools,
}
spec_plan.rs 不负责异步加载所有外部目录,它消费已经准备好的规划输入。
18. 工具规划分为六个阶段
build_tool_specs_and_registry 的顺序是:
1. add_tool_sources
2. apply_direct_model_only_namespace_overrides
3. append_tool_search_executor
4. prepend_code_mode_executors
5. build model-visible specs
6. build ToolRegistry
顺序不能随意交换。
例如 Tool Search 必须在所有 Deferred Runtime 收集完成后构建索引。
Code Mode Executors 必须在普通 Runtime 已经可枚举后,才能把它们转换成 Nested Tool
Definitions。
19. PlannedTools 是规划阶段的中间表示
PlannedTools 保存:
struct PlannedTools {
runtimes: Vec<Arc<dyn CoreToolRuntime>>,
hosted_specs: Vec<ToolSpec>,
}
两部分的差异是:
runtimes
-> 本地可执行,可进入 Registry
hosted_specs
-> 只进入模型请求,由 Provider 执行
这正是模型可见集合与本地执行集合分离的第一个明确边界。
20. add_tool_sources 的默认来源顺序
普通 Session 按以下顺序追加:
Shell Tools
MCP Resource Tools
Core Utility Tools
Collaboration Tools
MCP Runtime Tools
Extension Tools
Dynamic Tools
Hosted Model Tool Specs
这个顺序会影响:
重复名称时谁先出现
顶层 Spec 顺序
Namespace 首次出现的位置
因此新增工具时要理解自己处于哪个来源层,而不是随便在文件末尾 push。
21. Guardian Reviewer 使用受限工具面
如果 Session Source 是 Guardian Reviewer,规划会提前走受限分支。
有可用 Environment 时只加入:
exec_command
write_stdin
view_image
然后立即返回。
它不会继续加入:
普通 MCP
Dynamic Tool
Multi-Agent
Extension Tool
Plan Tool
这说明工具面首先是安全和角色策略的结果,然后才是能力全集。
22. ToolExposure 有四种状态
pub enum ToolExposure {
Direct,
Deferred,
DirectModelOnly,
Hidden,
}
这不是简单的:
visible: bool
因为 Code Mode 和 Tool Search 引入了多个暴露平面。
23. Direct 同时服务普通模型和 Code Mode
Direct 的语义是:
进入初始 Model Tool List
在 Code Mode 开启时也可成为 Nested Tool
这是大多数内建工具的默认值。
例如:
update_plan
exec_command
apply_patch
view_image
是否真正进入最终 Prompt,还要继续经过 Tool Mode 和 Provider Capability 过滤。
24. Deferred 已注册,但初始不直接暴露
Deferred 的语义是:
进入 Registry
不进入初始 Model Tool List
提供 ToolSearchInfo
通过 tool_search 按需加载
典型来源包括:
启用 Tool Search 后的 MCP Tool
defer_loading = true 的 Dynamic Tool
Deferred Extension Tool
Multi-Agent v1 Tool
Deferred 不是不可执行。
它只是推迟了 Spec 进入模型上下文的时间。
25. DirectModelOnly 只允许模型直接调用
DirectModelOnly 的语义是:
进入初始 Model Tool List
不进入 Code Mode Nested Tool Surface
当前典型工具包括:
request_user_input
new_context_window
配置为 direct_only 的 Namespace
它适合:
必须由模型显式发起
不应该被脚本运行时嵌套调用
的控制类能力。
26. Hidden 只保留 Dispatch 能力
Hidden 的语义是:
不进入初始 Model Tool List
不进入 Code Mode
不参与 Tool Search
仍保留在 Registry
当前重要例子是:
Unified Exec 可见时的 Legacy shell_command
这样旧 History、兼容路径或已有调用仍能找到 Handler,但新 Prompt 不再鼓励模型使用旧工具。
27. Exposure 与集合关系表
| Exposure | 初始 Model List | Tool Search | Code Mode Nested | Registry |
|---|---|---|---|---|
Direct |
是 | 否 | 是 | 是 |
Deferred |
否 | 是 | 可提供延迟提示 | 是 |
DirectModelOnly |
是 | 否 | 否 | 是 |
Hidden |
否 | 否 | 否 | 是 |
注意:
Hosted Tool
不属于这四类,因为它没有本地 Runtime。
28. ExposureOverride 不复制 Handler
规划层通过:
override_tool_exposure(handler, exposure)
返回一个包装器。
包装器继续委托:
tool_name
spec
search_info
handle
hook methods
diff consumer
只覆盖 Exposure 相关语义。
这种方式避免为了 Deferred 或 Hidden 复制整套 Handler。
29. Hidden Runtime 会被强制视为不可并行
ExposureOverride 中:
fn supports_parallel_tool_calls(&self) -> bool {
self.exposure != ToolExposure::Hidden
&& self.handler.supports_parallel_tool_calls()
}
所以即使底层 Legacy Shell Handler 支持并行:
Hidden shell_command
通过兼容路径被调用时也不会取得并行读锁。
这是一条容易忽略的 Exposure 副作用。
30. Shell Tool 由 Model 与 Feature 共同选择
规划层调用:
shell_type_for_model_and_features
结果可能是:
UnifiedExec
Disabled
Default
Local
ShellCommand
在 Unified Exec 模式中:
exec_command
write_stdin
直接可见,同时:
shell_command
以 Hidden 方式保留在 Registry。
31. Environment 数量会改变 Tool Spec
没有 Environment 时,不加入:
Shell
Apply Patch
View Image
Request Permissions
多个 Environment 时,相关 Spec 会增加:
environment_id
参数。
所以 Tool Spec 不是只由工具类型决定,还取决于当前 StepContext。
32. Unified Exec 的 shell 参数也会动态变化
当本地使用 Zsh Fork 且没有远程 Environment 时:
exec_command.shell
可以从 Spec 中省略。
如果同一 Turn 包含远程 Environment,则重新加入 shell 参数。
这说明 Spec Planner 不只是开关工具,也会根据运行时拓扑塑造参数 Schema。
33. Core Utility Tools 分别受独立条件控制
add_core_utility_tools 中常见规则包括:
update_plan
-> 默认加入
wait_for_environment
-> DeferredExecutor
request_user_input
-> experimental config
-> DirectModelOnly
request_permissions
-> 有 Environment
-> RequestPermissionsTool Feature
new_context_window
-> TokenBudget
-> DirectModelOnly
get_context_remaining
-> TokenBudget
current time / sleep
-> CurrentTimeReminder 配置
apply_patch
-> 有 Environment
-> ModelInfo 支持 Patch Tool
view_image
-> 有 Environment
不存在一个统一的“开启全部内建工具”开关。
34. View Image 的 Spec 与 Runtime 都检查能力
规划时,view_image 会根据 ModelInfo 决定是否提供:
detail = original
参数。
运行时仍会再次检查:
InputModality::Image
原因是:
Spec Gate
不能替代执行时的防御性校验。
35. Multi-Agent 工具面由版本和深度共同决定
collab_tools_enabled 会检查:
MultiAgentVersion
Thread Spawn Depth
agent_max_depth
v1 与 v2 还会选择不同 Tool Family。
v1 在 Tool Search 可用时通常转为:
Deferred
v2 可以使用配置的 Namespace,并可在特定配置下改为:
DirectModelOnly
36. Agent Job 工具还会检查 Session Source
spawn_agents_on_csv 需要:
SpawnCsv Feature
Collaboration Enabled
report_agent_job_result 还要求当前 Session Source 是:
agent_job:* 子 Agent
同一个二进制中的工具面会因当前线程角色而不同。
37. Hosted Web Search 只有 Spec,没有 Registry Runtime
hosted_model_tool_specs 会创建:
ToolSpec::WebSearch { ... }
但只调用:
planned_tools.add_hosted_spec(spec)
不会加入 Runtime。
模型服务端完成搜索后返回:
WebSearchCall
Core 将它作为已完成 Item 处理,而不是本地 Dispatch。
38. Responses Lite 不接收 Hosted Tool Spec
如果:
model_info.use_responses_lite = true
hosted_model_tool_specs 直接返回空集合。
Responses Lite 会把 Client Tool JSON 放入:
AdditionalTools Developer Prefix
而不是普通 tools 请求字段。
但 Hosted Responses Tool 不适用这条兼容路径。
39. Standalone Web Search 会抑制 Hosted Web Search
如果 Extension 提供:
ToolName::namespaced("web", "run")
并满足 Standalone Web Search 条件,规划层不再加入 Hosted:
web_search
最终二选一:
Provider Hosted Search
或
Client-Executed web/run Extension
避免模型同时看到语义重叠的搜索入口。
40. Extension Tool 来自 ToolContributor
扩展 API 定义:
pub trait ToolContributor: Send + Sync {
fn tools(
&self,
session_store: &ExtensionData,
thread_store: &ExtensionData,
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>>;
}
当前可通过 Extension 提供工具的能力包括:
Web Search
Image Generation
Memories
Goals
Skills
Core 不需要依赖每个扩展的具体 Handler 类型。
41. ExtensionToolAdapter 把公共调用转换为 Core Invocation
扩展工具使用:
codex_tools::ToolCall
Core 使用:
core::tools::context::ToolInvocation
ExtensionToolAdapter 负责转换,并向扩展提供:
Turn ID
Call ID
ToolName
Model
Truncation Policy
Conversation History Snapshot
Turn Item Emitter
Environment 与 Sandbox Context
Payload
扩展因此可以实现原生能力,而不需要直接依赖整个 Core 内部状态。
42. Extension Tool 只接收可表示为本机路径的 Environment
Adapter 构建 ToolEnvironment 时会尝试:
PathUri -> AbsolutePathBuf
当前不能转换为本机路径的 Foreign Environment 会被跳过。
源码中保留了迁移到 PathUri 的 TODO。
所以扩展拿到的 Environment 列表可能少于 Core StepContext 中的原始列表。
43. Extension Tool 名称先经过 Reserved Set
加入扩展前,规划层收集已存在的 Runtime Name,并额外保留:
Code Mode exec
Code Mode wait
tool_search
如果 Extension Tool 与保留名称冲突:
warn
skip extension tool
这是一层可恢复的冲突处理。
44. Dynamic Tool 来自 Thread 配置
Dynamic Tool Spec 存在两种形态:
pub enum DynamicToolSpec {
Function(DynamicToolFunctionSpec),
Namespace(DynamicToolNamespaceSpec),
}
Function 包含:
name
description
input_schema
defer_loading
它由 App Server Client 在 thread/start 时提供,随后保存在 Session/Turn 配置中。
45. Dynamic Tool 由 App Server Client 执行
DynamicToolHandler 收到调用后:
注册 oneshot Sender
发出 DynamicToolCall ItemStarted
App Server 转成反向 Server Request
Client 执行能力
Client 返回 DynamicToolCallResponse
Core 提交 Op::DynamicToolResponse
oneshot Receiver 恢复
发出 ItemCompleted
完整链路是:
Model
-> Core ToolRouter
-> App Server
-> IDE / Client
-> App Server
-> Core Pending Dynamic Tool
-> Model Tool Output
46. Dynamic Tool 输入在 Thread Start 时先校验
App Server 会检查:
名称非空
无首尾空白
只含字母、数字、_、-
名称最长 128 字符
Namespace 最长 64 字符
Namespace Description 最长 1024 字符
Schema 可转换
同 Namespace 内名称不重复
它还拒绝:
mcp
mcp__*
Responses 保留 Namespace
这在进入 Tool Planner 前先消除一批非法状态。
47. Deferred Dynamic Tool 必须有 Namespace
校验规则明确要求:
defer_loading = true
-> namespace 必须存在
同名 Function 可以存在于不同 Namespace。
但同一个 Namespace 内不能重复。
因此 Dynamic Tool 应优先使用结构化 Namespace,而不是在 Name 中手工拼前缀。
48. DynamicToolHandler 把 defer_loading 转成 Exposure
构建 Handler 时:
defer_loading = false
-> ToolExposure::Direct
defer_loading = true
-> ToolExposure::Deferred
Handler 生成普通 Spec 时会先清除 Wire 中的:
defer_loading
真正通过 Tool Search 返回 Loadable Spec 时,再统一写入:
defer_loading = true
Exposure 才是 Core 内部的单一事实源。
49. Dynamic Tool Response 支持文本和图片
返回内容可以包含:
InputText
InputImage
App Server 会拒绝 Remote Image URL,只允许安全支持的图片形式。
Handler 最终转换为:
FunctionCallOutputContentItem
并保留:
success: bool
给模型和生命周期日志。
50. MCP Tool 先经过 Exposure Policy
build_mcp_tool_exposure 先过滤:
普通 MCP Server 的 model_visible Tool
Codex Apps MCP Tool
Connector Allowlist
App Tool Policy
Destructive/Open World Annotation
然后根据 Tool Search 是否可用决定:
Search 不可用
-> 全部 eligible MCP Tool 直接暴露
Search 可用
-> 全部 eligible MCP Tool 延迟暴露
MCP 并不是无条件把 Server 返回的所有工具交给模型。
51. MCP 保留原始名和模型可调用名
ToolInfo 同时保存:
server_name
raw tool.name
callable_namespace
callable_name
其中:
raw tool.name
-> 发回 MCP Server
callable_namespace + callable_name
-> Responses API 与 Registry
canonical_tool_name() 返回结构化:
ToolName::namespaced(
callable_namespace,
callable_name,
)
这样名称清洗不会破坏真正的 MCP 路由标识。
52. MCP Handler 把每个 Tool 变成 Namespace Spec
McpHandler::create_tool_spec 生成:
ToolSpec::Namespace(ResponsesApiNamespace {
name: callable_namespace,
description,
tools: vec![Function(tool)],
})
一个 MCP Server 的多个 Tool 会先形成多个单 Tool Namespace Spec。
后面的:
merge_into_namespaces
再把同名 Namespace 合并。
53. 非法 MCP Schema 会被局部跳过
如果 McpHandler::new 无法把 MCP Schema 转成 Responses Tool:
记录 warn
跳过该 Tool
继续规划其他工具
它不会因为单个第三方 Tool Schema 非法而让整个 Sampling 失败。
这与最终 Registry 的重复名称不变量不同。
54. MCP Resource Tool 与 MCP Runtime Tool 不是一回事
内建 Resource Tool 包括:
list_mcp_resources
list_mcp_resource_templates
read_mcp_resource
它们操作 MCP Resource 协议。
MCP Runtime Tool 则来自:
tools/list
Tool Search 文案还会明确告诉模型:
发现 MCP Tool 应使用 tool_search
不要使用 Resource Listing 替代 Tool Discovery
55. Tool Search 需要 Model 与 Provider 同时支持
search_tool_enabled 要求:
model_info.supports_search_tool
AND
provider.capabilities().namespace_tools
只有 Feature Flag 并不够。
因为搜索结果使用:
Loadable Namespace Tool Spec
Provider 必须理解 Namespace Tool。
56. 没有 Deferred Runtime 时不会加入 tool_search
规划层只收集:
runtime.exposure() == ToolExposure::Deferred
并要求它能返回:
ToolSearchInfo
如果搜索条目为空:
不构建 ToolSearchHandler
不向模型暴露 tool_search
避免提供永远返回空结果的入口。
57. ToolSearchInfo 包含索引文本和 Loadable Spec
pub struct ToolSearchInfo {
pub entry: ToolSearchEntry,
pub source_info: Option<ToolSearchSourceInfo>,
}
其中 Entry 包含:
search_text
output: LoadableToolSpec
默认搜索文本会组合:
Tool Name
Name 的空格化形式
Description
参数名
参数 Description
数组和 anyOf 子 Schema
MCP Handler 还会加入 Server、Connector、Plugin Display Name 等信息。
58. Tool Search 使用 BM25
ToolSearchHandler::new 会把每个 Search Text 构造成:
bm25::Document<usize>
并使用:
Language::English
建立 Search Engine。
调用参数为:
query
limit
默认 Limit:
TOOL_SEARCH_DEFAULT_LIMIT = 8
59. Tool Search 返回的是 Spec,不是执行结果
成功输出:
ResponseInputItem::ToolSearchOutput {
execution: "client",
tools: Vec<Value>,
..
}
这些 Tool Spec 会在后续模型请求中成为可加载工具。
执行真正的 Deferred Tool 时,仍然通过原本已经注册的 Runtime。
所以 Tool Search 的作用是:
发现并加载声明
不是代替目标工具执行。
60. Search Output 会合并同名 Namespace
BM25 可能命中同一 Namespace 下的多个 Tool。
coalesce_loadable_tool_specs 会把:
Namespace calendar/create_event
Namespace calendar/list_event
合并为一个:
Namespace calendar [
create_event,
list_event,
]
Function Spec 保持独立。
61. Deferred Search Spec 会清理不适用字段
ToolSearchInfo::from_spec 会:
设置 defer_loading = true
清除 output_schema
对 Namespace 内每个 Function 都执行同样处理。
同时:
Freeform
WebSearch
ToolSearch
不能变成 Loadable Tool Search Result。
62. Tool Search Handler 有 Session 级缓存
ToolSearchHandlerCache 保存:
Option<Arc<ToolSearchHandler>>
如果新的 search_infos 与缓存完全相等:
复用 Handler 与 BM25 Index
如果来源变化:
重新构建
替换缓存
它还在写入前做第二次相等检查,避免并发构建后覆盖相同结果。
63. Direct-Only Namespace 可以覆盖 Deferred Exposure
配置:
[features.code_mode]
direct_only_tool_namespaces = ["history"]
会把该 Namespace 中的:
Direct
或 Deferred
统一改为:
DirectModelOnly
结果是:
直接出现在 Model Tool List
不进入 Code Mode Nested Surface
不再依赖 tool_search
64. Code Mode 有三种整体模式
pub enum ToolMode {
Direct,
CodeMode,
CodeModeOnly,
}
effective_tool_mode 的优先级是:
Guardian Reviewer
-> 强制 Direct
ModelInfo.tool_mode
-> 优先
Feature::CodeModeOnly
-> CodeModeOnly
Feature::CodeMode
-> CodeMode
否则
-> Direct
Model Metadata 可以覆盖本地 Feature 推导。
65. Code Mode 会额外注册两个入口
启用 Code Mode 后,规划层在 Runtime 列表头部插入:
exec
wait
其中 exec 是 Freeform Script Tool。
它的描述中包含可以从脚本调用的 Nested Tool Definitions。
wait 用于等待仍在运行的 Code Cell。
66. CodeModeOnly 隐藏普通 Nested Tool Spec
在 CodeModeOnly 中:
exec
wait
DirectModelOnly Tools
Hosted Tools
仍可直接出现在模型请求中。
普通 Direct Tool 如果可作为 Nested Tool,则不会再单独出现在顶层 Model List。
但它仍然存在于 Registry,等待 Code Runtime 反向调用。
67. CodeMode 模式可以同时保留两条调用路径
普通 CodeMode 中,Direct Tool 可以:
被模型直接 Function Call
被 exec Script 作为 Nested Tool 调用
为帮助模型正确使用 Script API,直接 Spec 的 Description 可能被:
augment_tool_spec_for_code_mode
补充 Code Mode 示例。
Runtime 本身不需要复制。
68. Code Mode 不允许 exec 调用自身
Nested Tool 请求进入:
call_nested_tool
如果目标是:
exec
立即返回 RespondToModel。
否则根据 Tool Kind 构造:
Function -> JSON Object Payload
Freeform -> String Payload
再进入 Code Mode Turn Worker 自己的 ToolCallRuntime。
69. Nested Tool 共享 Router,但使用独立并发门
Code Mode 的调用来源会标记为:
ToolCallSource::CodeMode {
cell_id,
runtime_tool_call_id,
}
随后仍然经过:
Parallel Gate
Registry Lookup
Kind Check
Lifecycle
Hook
Handler
Output
Turn Worker 复用当前 Sampling 的:
Arc<ToolRouter>
StepContext
TurnDiffTracker
但会调用一次新的:
ToolCallRuntime::new(...)
因此:
Direct Model Tool Calls
-> 一个 ToolCallRuntime / RwLock
Code Mode Nested Tool Calls
-> 另一个 ToolCallRuntime / RwLock
Nested Calls 之间共享 Code Mode Worker 的门禁,但不与顶层 exec 共用同一把RwLock。这避免串行 exec 持有写锁时,脚本内的 Nested Tool 因等待同一把锁而
自锁。
Code Mode 仍然复用同一个 Router、Registry、Hook 和 Handler 体系,不是绕过
ToolRouter 的第二套工具实现。
70. Code Mode 的结果可以保持结构化
普通模型需要:
ResponseInputItem
Code Mode 需要:
serde_json::Value
ToolOutput 同时提供:
fn to_response_item(...)
fn code_mode_result(...)
默认实现可以从 Response Item 转换。
特殊 Output 可以覆盖,保留更稳定的 Typed Result。
71. Namespace Spec 会在最终阶段合并
merge_into_namespaces 使用:
BTreeMap<NamespaceName, FirstIndex>
遇到重复 Namespace 时:
保留第一次出现的位置
追加后续 Tool
优先采用第一个非空 Description
最终 Namespace 内 Function 按:
tool.name
排序。
这让请求顺序更稳定,有利于测试和 Prompt Cache。
72. Namespace Capability 会过滤最终 Spec
合并后还会执行:
Provider 支持 Namespace
-> 保留 Namespace Spec
Provider 不支持 Namespace
-> 删除 Namespace Spec
Registry Runtime 不因此自动删除。
所以某个 Runtime 可能仍注册,但当前 Provider 无法把它作为 Namespace Tool 暴露给模型。
73. Model Visible Spec 先按结构化 ToolName 去重
生成 Spec 时维护:
HashSet<ToolName>
相同结构化名称的后续 Runtime 不再产生第二份 Model Spec。
但这只是模型可见面去重。
Registry 随后还会独立验证 Runtime 名称唯一性。
74. Registry 把重复 ToolName 当作不变量错误
ToolRegistry::from_tools 中:
if tools_by_name.contains_key(&name) {
error_or_panic(
format!("tool {name} already registered")
);
continue;
}
行为是:
Debug Build
-> Panic
Non-Debug Build
-> 记录 Error
-> 保留第一个 Runtime
-> 跳过后续 Runtime
因此名称冲突不是一个可靠的覆盖机制。
75. 名称冲突依赖多层防线
当前防线包括:
结构化 ToolName
MCP Name 清洗与 Namespace
Dynamic Tool Start-Time Validation
Extension Reserved Set
Model Visible Name 去重
Registry Duplicate Guard
但最后一层仍然可能被触发。
尤其是 Dynamic Tool 在 Extension Tool 之后加入,规划时无法保证外部 Client 不与某个
Extension Name 相撞。
新增外部工具时应主动选择稳定 Namespace,不能依赖“后注册覆盖前注册”。
76. build_prompt 只读取 Model Visible Specs
最终 Prompt 构建:
Prompt {
input,
tools: router.model_visible_specs(),
parallel_tool_calls:
turn_context.model_info
.supports_parallel_tool_calls,
..
}
model_visible_specs() 会 Clone 当前 Spec Vector。
Registry 中的 Hidden 和 Deferred Runtime 不会因为已经注册而自动进入 Prompt。
77. Router 只解析三类本地 Tool Call
ToolRouter::build_tool_call 识别:
FunctionCall
Client ToolSearchCall
CustomToolCall
分别生成:
ToolPayload::Function
ToolPayload::ToolSearch
ToolPayload::Custom
其他 ResponseItem 返回:
Ok(None)
78. Server Tool Search 不进入本地 Dispatch
只有:
execution == "client"
且 call_id 存在
的 ToolSearchCall 才会构造本地调用。
服务端执行的 Tool Search:
ResponseItem::ToolSearchCall
-> Ok(None)
与 Hosted Web Search 一样,它不查本地 Registry。
79. Router 不会猜测 Namespace Alias
模型返回:
namespace = calendar
name = create_event
Router 精确构造:
ToolName::namespaced(
"calendar",
"create_event",
)
Registry 不会退回查找:
plain:create_event
其他_namespace:create_event
这种精确匹配避免同名工具误路由到错误 Server。
80. Dispatch 前先构造 Canonical Invocation
ToolRouter::dispatch_tool_call... 将:
ToolCall
Session
StepContext
CancellationToken
Diff Tracker
Source
组合成 ToolInvocation。
兼容字段:
invocation.turn
与:
invocation.step_context.turn
指向同一个 Turn 状态,等待旧 Handler 完成迁移。
81. Registry 在查找前记录 Tool Call 数量
dispatch_any_with_terminal_outcome 首先更新 Active Turn:
turn_state.tool_calls += 1
然后才查 Registry。
所以:
未知 Tool Call
也会计入模型本轮尝试过的 Tool Call 数量。
82. 未注册工具会返回模型可见错误
查找失败时构造:
unsupported call: <tool>
或:
unsupported custom tool call: <tool>
错误类型为:
FunctionCallError::RespondToModel
模型可以在下一次 Sampling 中看到失败并修正。
83. Payload Kind 不兼容是 Fatal
Registry 找到 Handler 后会检查:
tool.matches_kind(&invocation.payload)
不匹配意味着:
Router/Spec/Runtime 内部契约损坏
因此返回:
FunctionCallError::Fatal
而不是普通 Tool Failure。
84. Tool Lifecycle 在 Pre Hook 之前开始
通过 Registry Kind Check 后:
notify_tool_start
PreToolUse Hook
Handler
PostToolUse Hook
notify_tool_finish
Lifecycle Contributor 可以观察:
Direct 或 CodeMode 来源
Call ID
ToolName
Completed
Blocked
Failed
Aborted
它不负责读取或修改 Tool Payload。
85. PreToolUse Hook 可以阻止执行
Pre Hook 返回:
Blocked(message)
时:
Handler 不执行
Lifecycle = Blocked
错误回给模型
这与执行后失败不同:
handler_executed = false
86. PreToolUse Hook 可以改写输入
Hook 也可以返回:
Continue {
updated_input: Some(Value)
}
默认 Function Runtime 会把它重新序列化为:
ToolPayload::Function.arguments
Shell 和 Apply Patch 会使用自己的稳定 Hook Contract,只改写:
command
字段或 Freeform Patch 文本。
87. Hook 改写后仍由 Handler 做最终参数校验
通用改写只负责:
Value -> JSON String
它不会提前保证符合具体 Tool Schema。
最终:
parse_arguments<T>
业务校验
权限校验
仍在 Handler 内执行。
这保证 Hook 不能绕过 Runtime 的类型与安全边界。
88. 某些传输型工具会关闭默认 Pre Hook
write_stdin 不运行第二次 Bash Pre Hook。
原因是:
它只是继续一个已存在的 Exec Session
原始命令已经运行过 PreToolUse
空写入还可能只是后台轮询
Code Mode wait 也不会使用普通 Function 的默认 Hook Payload。
Tool 类型相同不代表 Hook 语义相同。
89. Handler Output 先保存在 AnyToolResult
执行成功后,Registry 组装:
pub struct AnyToolResult {
pub(crate) call_id: String,
pub(crate) payload: ToolPayload,
pub(crate) result: Box<dyn ToolOutput>,
pub(crate) post_tool_use_payload:
Option<PostToolUsePayload>,
}
这样同一个结果可以走两条输出路径:
Direct Model
-> into_response()
Code Mode
-> code_mode_result()
90. ToolOutput 同时控制日志和模型输出
公共 Trait 包含:
log_preview
success_for_logging
contains_external_context
to_response_item
post_tool_use_id
post_tool_use_input
post_tool_use_response
code_mode_result
日志 Preview 与模型 Output 是两个不同预算。
例如 Core 会限制 Telemetry Preview,但 Tool Result 进入 History 时使用模型的
Truncation Policy。
91. 外部上下文 Tool 可以污染 Memory Mode
如果 Output 声明:
contains_external_context = true
且配置:
memories.disable_on_external_context
Registry 会把当前 Thread 的 Memory Mode 标记为:
polluted
这防止外部检索内容被误当作用户稳定记忆。
92. PostToolUse 只对成功结果运行
Registry 使用:
success_for_logging()
判断是否运行 Post Hook。
失败 Output 或 Handler Error 不会进入普通 PostToolUse Payload 路径。
因此 Handler 应正确实现 Success 语义,而不是所有结果都返回 true。
93. PostToolUse 可以屏蔽结果,但不能撤销副作用
源码注释明确说明:
A PostToolUse block rejects the result,
not the already-completed tool execution.
如果 Post Hook Block:
Shell/Patch/MCP 已经执行
原结果不再返回模型
模型收到 Hook Feedback Error
不能把 Post Hook 当成执行前授权。
真正的阻止必须发生在 Pre Hook 或更底层审批策略中。
94. Post Hook Feedback 可以替换模型可见输出
如果 Post Hook 返回 Feedback Message,但不 Block:
Direct Model
-> 看到 Feedback Text
Code Mode
-> 仍获得原始 Typed Result
PostToolUseFeedbackOutput 同时持有:
original
model_visible
避免为了给模型追加策略反馈而破坏脚本运行时的数据类型。
95. Lifecycle Outcome 反映原始 Handler 执行
Post Hook 在 Handler 完成后运行。
Lifecycle 的 Completed/Failed 判断基于原始执行结果。
因此 Post Hook Block 时可能出现:
Lifecycle = Completed
模型收到 RespondToModel Error
这不是矛盾:
执行层已经完成
结果发布层被策略拒绝
96. FunctionCallError 只有两类
pub enum FunctionCallError {
RespondToModel(String),
Fatal(String),
}
RespondToModel
适用于:
参数错误
未知工具
用户拒绝
策略阻止
外部服务业务失败
会变成 Tool Output,让模型有机会恢复。
Fatal
适用于:
任务 Join 失败
Payload Kind 内部不变量损坏
无法序列化必须生成的内部数据
会终止当前执行路径。
97. 不同 Payload 的失败输出类型不同
ToolCallRuntime::failure_response 根据原调用生成:
Function
-> FunctionCallOutput(success = false)
Custom
-> CustomToolCallOutput(success = false)
ToolSearch
-> completed ToolSearchOutput(tools = [])
所以失败也必须保持 Call/Output 协议配对。
98. 并行控制有两个不同层次
第一层是请求级:
Prompt.parallel_tool_calls =
model_info.supports_parallel_tool_calls
它告诉模型:
可以在同一响应中生成多个 Tool Call
第二层是 Runtime 级:
handler.supports_parallel_tool_calls()
它决定:
本地 Handler 是否可以与其他调用重叠执行
两者不能混为一个开关。
99. Responses Lite 会关闭请求级 Parallel Flag
最终 Request 使用:
parallel_tool_calls =
prompt.parallel_tool_calls
&& !model_info.use_responses_lite
即使 ModelInfo 支持并行:
Responses Lite
也不会在普通请求字段中发送 Parallel Tool Calls。
Runtime 仍保留自己的防御性并发门禁。
100. ToolCallRuntime 使用一个共享 RwLock
每个 ToolCallRuntime 实例持有:
parallel_execution: Arc<RwLock<()>>
执行前:
let _guard = if supports_parallel {
lock.read().await
} else {
lock.write().await
};
语义是:
Parallel Tool
-> 共享读锁
Serial Tool
-> 独占写锁
101. 一个 Serial Tool 会阻塞所有其他 Tool
写锁不仅阻止另一个 Serial Tool。
它也阻止:
所有正在等待读锁的 Parallel Tool
所以这里的模型不是:
每个 Tool Name 一把锁
而是:
单个 ToolCallRuntime 实例的全局读写门
这提供保守的线程安全边界。
102. 当前显式支持并行的内建工具
源码中明确返回 true 的典型 Handler 包括:
exec_command
shell_command
view_image
tool_search
MCP Resource Tools
test_sync_tool
request_plugin_install
MCP Tool 的规则是:
Server 显式支持并行
OR
Tool Annotation read_only_hint = true
其他 Handler 默认串行。
103. read_only_hint 是 MCP 并行的信任契约
McpHandler 认为正确实现的 MCP Server 应允许只读 Tool 并行。
因此即使 Server 没有全局声明 Parallel:
read_only_hint = true
也会启用并行。
这依赖第三方 Server 正确标注。
错误地把写操作标成 Read-Only 可能制造真实竞态。
104. 工具 Future 在 Response Completed 前就能启动
模型产生 OutputItemDone(FunctionCall) 时:
记录 Tool Call
构造 Tool Future
ToolCallRuntime 内部立即 tokio::spawn
加入 FuturesOrdered
继续消费 Response Stream
因此 Handler 可以在服务端尚未发送:
response.completed
前开始运行。
集成测试:
会延迟 Completed Event,并验证多个 Shell 已经写入时间戳。
105. 并行执行不等于乱序写回
Tool Future 存在:
FuturesOrdered<BoxFuture<...>>
即使:
Call 2 先完成
Call 1 后完成
Drain 顺序仍是:
Call 1 Output
Call 2 Output
它保持模型产生 Tool Call 的顺序。
106. 所有 Call 会先记录,再统一记录 Output
Tool Call 在 OutputItemDone 时立即写入 History。
Tool Output 在流结束后的:
drain_in_flight
中统一写入。
所以最终 History 形态是:
Call 1
Call 2
Call 3
Output 1
Output 2
Output 3
而不是:
Call 1
Output 1
Call 2
Output 2
107. 并发与回填的完整时序
Response Stream
|
|-- Call 1 Done
| `-- Spawn Handler 1
|
|-- Call 2 Done
| `-- Spawn Handler 2
|
|-- Assistant/Reasoning/Other Items
|
`-- response.completed
|
`-- drain FuturesOrdered
|-- await Output 1
|-- record Output 1
|-- await Output 2
`-- record Output 2
执行时间可以重叠,持久化顺序保持确定。
108. Fatal Tool Future 在 Drain 中是内部错误
handle_tool_call 会把:
FunctionCallError::Fatal
转换为 CodexErr。
drain_in_flight 遇到这种错误会调用:
error_or_panic
因此:
Debug Build
-> Panic
Non-Debug Build
-> 记录错误并继续 Drain
普通可恢复失败应尽量使用 RespondToModel,以保持 Output 配对。
109. 取消会竞争 Handler 的终态
ToolCallRuntime 同时等待:
Dispatch JoinHandle
CancellationToken
如果取消发生时 Handler 已完成或已经声明终态:
返回真实结果
否则:
中止或等待清理
返回 Aborted Tool Output
这避免“结果已经完成但被晚到取消覆盖”。
110. waits_for_runtime_cancellation 控制清理策略
默认:
false
取消时直接 Abort Dispatch Task。
如果 Runtime 返回:
true
外层会:
触发 CancellationToken
等待 Handler 完成清理
忽略其正常 Output
最终返回 Aborted Output
当前 Legacy shell_command 使用这条路径,以便完成进程清理。
111. AtomicBool 保证只发布一个生命周期终态
Registry 和取消分支共享:
terminal_outcome_reached: AtomicBool
可能竞争的终态包括:
Completed
Failed
Blocked
Aborted
每个分支先尝试 Claim。
只有第一个成功者发送:
notify_tool_finish
从而避免同一 Call 同时出现 Completed 和 Aborted。
112. 取消仍然返回协议配对 Output
取消不是简单丢弃 Future。
Runtime 会构造:
aborted by user after Xs
Shell 类工具还会使用:
Wall time: X seconds
aborted by user
最终写入与原 Payload 匹配的 Tool Output。
这让下一次 Sampling 的 History 仍满足 Call/Output 不变量。
113. Tool Timing 区分排队和执行
ToolCallTimingGuard 记录:
dispatch_duration_ms
handler_duration_ms
total_duration_ms
execution_started
取得 RwLock 后才设置:
execution_started_at
因此:
等待 Serial Tool 释放锁
会计入 Dispatch Duration,而不是 Handler Duration。
114. Code Mode Nested Call 不重复记录顶层 Timing
Code Mode Script 本身已有一个 Direct Tool Call Timing。
Nested Tool 使用:
ToolCallSource::CodeMode
时,不创建第二个顶层 codex.tool_call Timing Guard。
否则消费者可能把嵌套重叠事件误认为相互独立的顶层延迟。
Lifecycle Source 仍会保留 Code Mode Cell 信息。
115. Custom Tool 可以消费流式参数 Delta
Response Stream 可能先发送:
OutputItemAdded(CustomToolCall)
再连续发送:
ToolCallInputDelta
Router 可以按 ToolName 创建:
ToolArgumentDiffConsumer
在完整 Tool Call 完成前生成 UI 事件。
116. Apply Patch 使用 StreamingPatchParser
ApplyPatchArgumentDiffConsumer 会:
增量解析 Lark Patch
提取 Hunk
转换成 FileChange
发送 PatchApplyUpdated
事件最小间隔:
Duration::from_millis(500)
间隔内的新变化暂存为 Pending,完成时再 Flush。
117. Argument Diff 不是提前执行 Patch
流式 Diff Consumer 只生成:
预览事件
真正执行仍等待:
CustomToolCall 完整完成
完整 Patch 校验
Environment 解析
权限与沙箱判断
ApplyPatch Runtime
不要把 UI 中看到的增量 FileChange 当成已经提交的文件修改。
118. 内建 Handler 通常拆成 Spec 与 Runtime 两部分
例如:
handlers/shell_spec.rs
handlers/unified_exec/exec_command.rs
handlers/apply_patch_spec.rs
handlers/apply_patch.rs
handlers/view_image_spec.rs
handlers/view_image.rs
Spec 文件负责:
Name
Description
JSON Schema / Grammar
Output Schema
Runtime 文件负责:
Parse
Validate
Execute
Emit Event
Build Output
这是一种代码组织方式,不改变 ToolExecutor 最终仍同时暴露两者的契约。
119. Shell Handler 不直接等同于进程执行器
Shell/Exec Handler 还要处理:
Environment 选择
Working Directory
Path Convention
Permission Profile
Pre Hook
Implicit Skill
Approval
Sandbox
PTY
Process Manager
Output Truncation
Turn Diff
真正的沙箱与审批编排位于更底层:
ToolOrchestrator
Tool Runtime
Sandbox Manager
ToolRouter 只负责把调用送到正确边界。
120. Apply Patch 是 Freeform Runtime
ApplyPatchHandler 会:
检查 Custom Payload
解析 Patch Grammar
选择 Environment
在目标 FileSystem 上验证
计算权限
决定直接应用或委托 Runtime
记录 FileChange 与 Turn Diff
它说明一个 Handler 可以拥有复杂执行链,但不应该把这些策略塞回 Router。
121. Plan Tool 是状态事件,不是文件写入
update_plan:
解析 UpdatePlanArgs
发送 EventMsg::PlanUpdate
返回 "Plan updated"
在 Plan Mode 中反而禁止使用,因为它是 TODO/Checklist Tool,不是正式 Plan 文档生成器。
同一个“计划”词在协议中可能代表不同产品语义。
122. Request User Input 是 DirectModelOnly 控制工具
它会:
拒绝非 Root Agent
检查当前 Collaboration Mode
规范化问题
等待 Client Response
序列化回答
因为调用会暂停 Turn 等待用户,所以 Token Count 事件也要等 Pending Tool 结束后再发送。
这类工具不适合被 Code Mode Script 隐式嵌套调用。
123. Web Search 有 Hosted 与 Extension 两种实现
Hosted web_search
-> Provider 执行
-> 只有 ToolSpec
Extension web/run
-> Client 执行
-> 有 ToolExecutor
-> 经过 Registry、Hook、并发和取消
调试 Web Search 时必须先确认当前 Prompt 中是哪一种形态。
不能只搜索字符串:
web_search
就推断执行路径。
124. Image Generation Extension 也受 Planner Gate
即使 Extension Registry 能生成:
image_gen/imagegen
Core 仍会检查:
认证方式
Provider Image Generation Capability
Model Image Input Modality
Namespace Capability
ImageGeneration Feature
任何一项不满足都会跳过该 Extension Runtime。
扩展可用不等于当前 Turn 可见。
125. ToolRouter 不是安全沙箱
Router 负责:
规划
命名
查找
Hook
并发
取消
输出
它不负责最终决定:
某个路径是否可写
命令是否需要审批
网络是否允许
进程应运行在哪种沙箱
这些内容将在第十二篇进入:
ToolOrchestrator
Approval
Sandbox
Exec Runtime
126. 新增 Handler 时先回答六个问题
- Tool 是 Function、Namespace 还是 Freeform?
- 它属于 Direct、Deferred、DirectModelOnly 还是 Hidden?
- 名称是否可能与内建、MCP、Dynamic 或 Extension 冲突?
- 它是否真的线程安全并支持并行?
- 取消时是否需要等待资源清理?
- 错误应该回给模型,还是属于 Fatal 内部不变量?
如果这些问题没有明确答案,先不要把 Handler 加进 add_core_utility_tools。
127. 一个只读最小 Handler
下面实现:
read_only_echo
它只返回输入文本,不访问文件、网络或 Session 可变状态。
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use serde::Deserialize;
use std::collections::BTreeMap;
use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
#[derive(Deserialize)]
struct ReadOnlyEchoArgs {
text: String,
}
pub struct ReadOnlyEchoHandler;
先把参数解析抽成一个单一职责函数:
fn parse_echo_text(
arguments: &str,
) -> Result<String, FunctionCallError> {
let args: ReadOnlyEchoArgs =
parse_arguments(arguments)?;
Ok(args.text)
}
128. 为最小 Handler 定义 Spec
fn create_read_only_echo_tool() -> ToolSpec {
let properties = BTreeMap::from([(
"text".to_string(),
JsonSchema::string(Some(
"Text returned unchanged.".to_string(),
)),
)]);
ToolSpec::Function(ResponsesApiTool {
name: "read_only_echo".to_string(),
description:
"Returns the provided text without side effects."
.to_string(),
strict: true,
defer_loading: None,
parameters: JsonSchema::object(
properties,
Some(vec!["text".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}
使用 strict: true 时,应确保:
properties
required
additionalProperties = false
三者一致。
129. 实现 ToolExecutor 与 CoreToolRuntime
impl ToolExecutor<ToolInvocation>
for ReadOnlyEchoHandler
{
fn tool_name(&self) -> ToolName {
ToolName::plain("read_only_echo")
}
fn spec(&self) -> ToolSpec {
create_read_only_echo_tool()
}
fn supports_parallel_tool_calls(&self) -> bool {
true
}
fn handle(
&self,
invocation: ToolInvocation,
) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(async move {
let ToolPayload::Function { arguments } =
invocation.payload
else {
return Err(
FunctionCallError::RespondToModel(
"read_only_echo requires function arguments"
.to_string(),
),
);
};
let text = parse_echo_text(&arguments)?;
Ok(boxed_tool_output(
FunctionToolOutput::from_text(
text,
Some(true),
),
))
})
}
}
impl CoreToolRuntime for ReadOnlyEchoHandler {}
只有确认它:
无共享可变状态
无外部副作用
无顺序依赖
后,才返回 supports_parallel_tool_calls = true。
130. 在 Planner 中注册最小 Handler
如果它是普通内建工具,可在:
add_core_utility_tools
中加入:
planned_tools.add(ReadOnlyEchoHandler);
如果需要 Feature Gate:
if features.enabled(Feature::SomeFeature) {
planned_tools.add(ReadOnlyEchoHandler);
}
不要直接修改 ToolRegistry。
Registry 应继续由统一规划结果构建。
131. 为参数解析添加最小测试
#[test]
fn read_only_echo_parses_text() {
let text = parse_echo_text(
r#"{"text":"hello"}"#,
)
.expect("valid echo arguments");
assert_eq!(text, "hello");
}
#[test]
fn read_only_echo_rejects_missing_text() {
let error = parse_echo_text("{}")
.expect_err("text is required");
assert!(
error.to_string()
.contains("failed to parse function arguments")
);
}
这先固定 Handler 的参数边界。
132. 为 Exposure 添加 Planner 测试
在 spec_plan_tests.rs 的测试辅助框架中加入:
#[tokio::test]
async fn read_only_echo_is_visible_and_registered() {
let plan = probe(|_| {}).await;
plan.assert_visible_contains(&[
"read_only_echo",
]);
plan.assert_registered_contains(&[
"read_only_echo",
]);
assert_eq!(
plan.exposure("read_only_echo"),
ToolExposure::Direct,
);
}
如果工具带 Feature Gate,还要补:
Enabled Case
Disabled Case
不能只测试 Handler 本身。
133. 为并行行为添加重叠测试
仅断言:
supports_parallel_tool_calls() == true
不能证明真实执行会重叠。
可以复用 tool_parallelism.rs 的方式:
两个调用
共享 Barrier(participants = 2)
每个调用通过 Barrier 后 Sleep
整体耗时应接近单次耗时
同时再加入一个默认 Serial Handler,验证:
Serial Tool
会取得写锁
阻塞 Read-Parallel Tool
134. 为结果顺序添加集成测试
构造模型依次返回:
Call A -> 慢
Call B -> 快
Call C -> 中
下一次 Mock Request 应满足:
所有 Call 在所有 Output 之前
Output A
Output B
Output C
不要只检查三个 Output 都存在。
真正需要固定的是:
Call ID 对应关系
稳定回填顺序
135. 为错误输出添加测试
至少覆盖:
Malformed JSON
Missing Required Field
Unknown Tool
Incompatible Payload
Cancellation
预期分别区分:
RespondToModel Output
Fatal Turn Error
Aborted Output
这能提前发现 Call/Output 配对被破坏的问题。
136. 推荐断点顺序
调试“模型为什么看不到某个工具”:
session::turn::built_tools
spec_plan::add_tool_sources
对应 add_*_tools
apply_direct_model_only_namespace_overrides
append_tool_search_executor
prepend_code_mode_executors
build_model_visible_specs_and_registry
build_prompt
调试“模型调用了但执行失败”:
ToolRouter::build_tool_call
ToolCallRuntime::handle_tool_call_with_source
ToolRegistry::dispatch_any_with_terminal_outcome
CoreToolRuntime::matches_kind
PreToolUse Hook
Handler::handle
PostToolUse Hook
drain_in_flight
137. 推荐 Trace 字段
thread_id
turn_id
call_id
tool_name
tool_source
dispatch_duration_ms
handler_duration_ms
total_duration_ms
execution_started
aborted
sandbox
sandbox_policy
mcp_server
mcp_server_origin
判断并发问题时,重点比较:
dispatch_duration_ms
handler_duration_ms
如果前者很大,通常说明调用卡在 RwLock 门禁,而不是 Handler 本身慢。
138. 调试工具可见性的最小输出
在测试中同时打印:
router.model_visible_specs()
router.registered_tool_names_for_test()
router.tool_exposure_for_test(name)
只看 Prompt Tool JSON 会遗漏:
Hidden
Deferred
CodeMode Nested Runtime
只看 Registry 又会误以为所有 Runtime 都直接暴露给模型。
139. 常见误区
误区一:注册 Handler 就会进入 Prompt
错误。
Exposure、Code Mode 和 Provider Capability 都可能让它只存在于 Registry。
误区二:Prompt 中有 Tool Spec 就一定有本地 Handler
错误。
Hosted Web Search 只有 Spec,由服务端执行。
误区三:Deferred 等同于 Disabled
错误。
Deferred Runtime 已注册,只是通过 Tool Search 按需加载 Spec。
误区四:Namespace 只是名称前缀
错误。
它是 ToolName Hash Key 的结构化组成部分,也是 Responses API Namespace Spec。
误区五:同名工具以后注册的覆盖以前注册的
错误。
Debug Build 会 Panic,Non-Debug Build 保留第一个并记录错误。
误区六:Model 支持 Parallel 就代表所有 Handler 都能并发
错误。
请求级 Flag 与 Runtime 级 Capability 是两个层次。
误区七:并行完成会导致 Output 乱序
错误。
执行可以并行,FuturesOrdered 仍按 Call 顺序回填。
误区八:Post Hook Block 可以撤销工具副作用
错误。
Post Hook 发生在 Handler 执行完成之后。
误区九:ToolRouter 负责审批和沙箱
错误。
Router 负责组织与分发,安全执行由 Handler、Orchestrator 和 Sandbox Runtime 完成。
误区十:Dynamic Tool 在 Core 内执行
错误。
Core 通过 App Server 反向请求 Client,再等待 DynamicToolResponse。
误区十一:Tool Search 会执行搜索到的目标工具
错误。
它返回 Loadable Spec,目标调用仍通过原 Registry Runtime。
误区十二:Argument Delta 表示 Apply Patch 已经写入文件
错误。
Delta Consumer 只发送预览事件,完整 Patch 完成后才校验和执行。
140. 动手练习
练习一:输出双集合
在 spec_plan_tests.rs 中输出:
Model Visible Spec Names
Registered Runtime Names
Exposure
分别启用:
Unified Exec
Tool Search
CodeModeOnly
解释每个差异。
练习二:实现只读 Handler
实现本文的:
read_only_echo
要求:
严格 JSON Schema
无共享可变状态
允许并行
参数错误回给模型
Planner Enabled/Disabled 测试
练习三:验证 Hidden Legacy Shell
启用 Unified Exec,确认:
Prompt 有 exec_command / write_stdin
Prompt 无 shell_command
Registry 有 shell_command
Exposure = Hidden
练习四:验证 Deferred Dynamic Tool
创建 Namespace Dynamic Tool:
defer_loading = true
确认:
初始 Prompt 无目标 Tool
Prompt 有 tool_search
Search Output 含 defer_loading = true
目标 Runtime 已注册
练习五:验证 MCP Read-Only 并行
构造两个 MCP Tool:
Server Parallel = false
其中一个:
read_only_hint = true
验证只有它获得 Parallel Capability。
练习六:验证名称冲突
构造 Extension Tool 与 Dynamic Tool 使用相同 ToolName。
分别运行:
Debug Build
Release Build
记录 Registry Duplicate Guard 的差异,并改用 Namespace 消除冲突。
练习七:验证执行早于 Response Completed
使用 Streaming SSE:
先发送多个 Tool Call
延迟 response.completed
验证 Handler 在 Completed Event 前已经开始执行。
练习八:验证有序回填
让三个 Parallel Handler 按:
B
C
A
完成。
确认 History 仍按:
A
B
C
记录 Output。
练习九:验证取消清理
实现一个:
waits_for_runtime_cancellation = true
的测试 Handler。
取消后确认:
Cleanup 完成
模型收到 Aborted Output
Lifecycle 只出现一次 Aborted
练习十:验证 Post Hook 语义
让 Handler 产生真实副作用,再由 Post Hook Block。
确认:
副作用已经发生
Lifecycle 记录 Completed
模型看到 Hook Block Message
并解释为什么授权不能放在 Post Hook。
141. 推荐测试命令
Planner:
cargo test -p codex-core \
tools::spec_plan::tests
Router:
cargo test -p codex-core \
tools::router::tests
Registry:
cargo test -p codex-core \
tools::registry::tests
并行与取消:
cargo test -p codex-core \
tools::parallel::tests
集成测试:
cargo test -p codex-core \
--test all \
tool_parallelism
如果仓库使用 Nextest:
cargo nextest run -p codex-core \
-E 'test(/tool_parallelism/)'
142. 本篇小结
Codex 的 ToolRouter 不是一个简单函数表,而是一次 Sampling 的能力快照。
核心结论如下:
ToolSpec定义模型协议面,ToolExecutor绑定声明与实现。CoreToolRuntime增加 Hook、Telemetry、Payload Kind 和 Diff Consumer。ToolRegistry保存结构化ToolName -> Runtime映射。ToolRouter同时持有 Model Visible Specs 与 Registry。- 模型可见集合和本地可执行集合不要求相等。
- Hosted Web Search 可以只有 Spec,没有本地 Runtime。
- Hidden 和 Deferred Tool 可以只存在于 Registry。
Direct、Deferred、DirectModelOnly和Hidden描述不同暴露平面。- ToolRouter 每个 Sampling 构建一次,网络重试复用同一快照。
- 工具来源按内建、MCP、Extension、Dynamic 和 Hosted 顺序规划。
- Environment、Model、Provider、Feature 和 Session Source 都会改变最终 Tool Surface。
- Namespace 是结构化身份,不只是字符串前缀。
- Extension 使用 Reserved Set,Dynamic Tool 还经过 Thread Start Validation。
- 最终重复
ToolName在 Debug Build 中会被视为 Panic 级不变量错误。 - MCP Tool 同时保留原始 Server Name 和模型可调用 Canonical Name。
- Tool Search 使用 BM25 索引 Deferred Tool Metadata。
- Tool Search 返回 Loadable Spec,目标工具仍由原 Registry Runtime 执行。
- Code Mode Nested Tool 复用同一个 Router/Registry,但使用 Turn Worker 独立的 ToolCallRuntime 并发门。
- 请求级 Parallel Flag 与 Handler Parallel Capability 是两个层次。
- RwLock 允许 Parallel Tool 共享读锁,Serial Tool 使用全局写锁。
- Tool Future 可以在
response.completed前开始执行。 FuturesOrdered保证结果按模型 Call 顺序回填。- Pre Hook 可以阻止或改写输入,Post Hook 不能撤销已发生的副作用。
RespondToModel用于可恢复失败,Fatal用于内部不变量损坏。- 取消仍会产生配对的 Aborted Tool Output。
- Atomic Terminal Flag 保证 Lifecycle 只发布一个最终状态。
- Argument Diff Consumer 可以提供流式 UI 预览,但不提前执行工具。
- ToolRouter 负责能力组织,审批、权限和沙箱属于更底层执行链。
下一篇将继续沿 exec_command、shell_command 和 apply_patch 深入,分析命令执行、
审批策略、权限提升、ToolOrchestrator,以及 macOS、Linux 和 Windows 沙箱如何共同
限制真实副作用。
更多推荐



所有评论(0)