上一篇分析了 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。

本篇目标

阅读完成后,你应该能够:

  1. 区分 ToolSpecToolExecutorCoreToolRuntimeToolRegistryToolRouter
  2. 解释模型可见工具与本地可执行工具为什么不是同一集合。
  3. 区分 DirectDeferredDirectModelOnlyHidden
  4. 跟踪内建、MCP、Dynamic 和 Extension Tool 的规划顺序。
  5. 解释 Namespace 如何参与结构化查找、合并与冲突隔离。
  6. 说明 Tool Search 如何让 Deferred Tool 在需要时才进入上下文。
  7. 区分 Hosted Tool 与 Client-Executed Tool。
  8. 跟踪一个 ResponseItem 从 Tool Call 到 Handler Output 的完整路径。
  9. 解释全局 Parallel Flag 与单工具 Parallel Capability 的区别。
  10. 说明工具可以并行执行,但结果仍按模型调用顺序回填的原因。
  11. 理解取消、清理和生命周期通知如何避免重复终态。
  12. 实现并测试一个只读的最小内建 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 ToolExecutorToolExposure
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 相关语义。

这种方式避免为了 DeferredHidden 复制整套 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

前开始运行。

集成测试:

tool_parallelism.rs

会延迟 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 时先回答六个问题

  1. Tool 是 Function、Namespace 还是 Freeform?
  2. 它属于 Direct、Deferred、DirectModelOnly 还是 Hidden?
  3. 名称是否可能与内建、MCP、Dynamic 或 Extension 冲突?
  4. 它是否真的线程安全并支持并行?
  5. 取消时是否需要等待资源清理?
  6. 错误应该回给模型,还是属于 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 的能力快照。

核心结论如下:

  1. ToolSpec 定义模型协议面,ToolExecutor 绑定声明与实现。
  2. CoreToolRuntime 增加 Hook、Telemetry、Payload Kind 和 Diff Consumer。
  3. ToolRegistry 保存结构化 ToolName -> Runtime 映射。
  4. ToolRouter 同时持有 Model Visible Specs 与 Registry。
  5. 模型可见集合和本地可执行集合不要求相等。
  6. Hosted Web Search 可以只有 Spec,没有本地 Runtime。
  7. Hidden 和 Deferred Tool 可以只存在于 Registry。
  8. DirectDeferredDirectModelOnlyHidden 描述不同暴露平面。
  9. ToolRouter 每个 Sampling 构建一次,网络重试复用同一快照。
  10. 工具来源按内建、MCP、Extension、Dynamic 和 Hosted 顺序规划。
  11. Environment、Model、Provider、Feature 和 Session Source 都会改变最终 Tool Surface。
  12. Namespace 是结构化身份,不只是字符串前缀。
  13. Extension 使用 Reserved Set,Dynamic Tool 还经过 Thread Start Validation。
  14. 最终重复 ToolName 在 Debug Build 中会被视为 Panic 级不变量错误。
  15. MCP Tool 同时保留原始 Server Name 和模型可调用 Canonical Name。
  16. Tool Search 使用 BM25 索引 Deferred Tool Metadata。
  17. Tool Search 返回 Loadable Spec,目标工具仍由原 Registry Runtime 执行。
  18. Code Mode Nested Tool 复用同一个 Router/Registry,但使用 Turn Worker 独立的 ToolCallRuntime 并发门。
  19. 请求级 Parallel Flag 与 Handler Parallel Capability 是两个层次。
  20. RwLock 允许 Parallel Tool 共享读锁,Serial Tool 使用全局写锁。
  21. Tool Future 可以在 response.completed 前开始执行。
  22. FuturesOrdered 保证结果按模型 Call 顺序回填。
  23. Pre Hook 可以阻止或改写输入,Post Hook 不能撤销已发生的副作用。
  24. RespondToModel 用于可恢复失败,Fatal 用于内部不变量损坏。
  25. 取消仍会产生配对的 Aborted Tool Output。
  26. Atomic Terminal Flag 保证 Lifecycle 只发布一个最终状态。
  27. Argument Diff Consumer 可以提供流式 UI 预览,但不提前执行工具。
  28. ToolRouter 负责能力组织,审批、权限和沙箱属于更底层执行链。

下一篇将继续沿 exec_commandshell_commandapply_patch 深入,分析命令执行、
审批策略、权限提升、ToolOrchestrator,以及 macOS、Linux 和 Windows 沙箱如何共同
限制真实副作用。

更多推荐