Skip to content

ExtensionTool处理器

追踪 Rust Extension 工具从 contributor 按 step 生成、Core adapter 桥接,到历史、环境、权限、事件与模型输出的完整路径。

基于rust-v0.150.0
CodexRustToolsExtensions

ExtensionTool处理器 ​

Extension Tool 与 Dynamic Tool 都能扩展模型工具表,但执行所有权完全不同。Dynamic Tool 把调用发给外部客户端并等待响应;Extension Tool 是宿主已经装配好的 Rust executor,Core 只需把自己的 ToolInvocation 转成扩展 API 的 ToolCall,然后直接等待 executor future。这里没有 pending response map,真正的复杂度集中在上下文快照和能力桥接。

本文面向已经读过ToolRegistry构建流程、工具运行时抽象和DynamicTool处理器的读者。本文只研究 ToolContributor 贡献的原生 Rust 工具如何进入当前 step、adapter 转发哪些 runtime 属性、扩展调用能看到哪些历史与环境、item 如何回到 Core,以及特殊工具 gate 和冲突顺序。不展开 Web Search、Image Generation 或 Skills 扩展各自的业务算法。

1. 所有权边界 ​

1.1 Registry构造 ​

扩展不是在每次调用时按名称动态加载。宿主先用 ExtensionRegistryBuilder 注册不同 contributor,build() 后得到不可变 registry;工具只是其中一种贡献类型。tool_contributors 的所有者是 Session services,Tool plan 只读取它。

源码位置:codex-rs/ext/extension-api/src/registry.rs :: ExtensionRegistryBuilder

rust
pub struct ExtensionRegistryBuilder<C: Sync> {
    registry: ExtensionRegistry<C>,
}

impl<C: Sync> ExtensionRegistryBuilder<C> {
    pub fn tool_contributor(&mut self, contributor: Arc<dyn ToolContributor>) {
        self.registry.tool_contributors.push(contributor);
    }

    pub fn build(self) -> ExtensionRegistry<C> {
        self.registry
    }
}

pub struct ExtensionRegistry<C: Sync> {
    event_sink: Arc<dyn ExtensionEventSink>,
    tool_contributors: Vec<Arc<dyn ToolContributor>>,
    tool_lifecycle_contributors: Vec<Arc<dyn ToolLifecycleContributor>>,
    turn_item_contributors: Vec<Arc<dyn TurnItemContributor>>,
}

ExtensionRegistry 保存 contributor,而不是保存一张永远不变的工具表。这允许 contributor 根据 session、thread 和 step store 返回不同 executor。

1.2 Step贡献 ​

ToolContributor::tools 是默认入口,tools_for_step 可以覆盖它并读取当前 step store。Core 在构造 tool router 时逐个调用 contributor,把三层 ExtensionData 都传进去。

源码位置:codex-rs/ext/extension-api/src/contributors.rs :: ToolContributor

rust
pub trait ToolContributor: Send + Sync {
    fn tools(
        &self,
        session_store: &ExtensionData,
        thread_store: &ExtensionData,
    ) -> Vec<Arc<dyn ToolExecutor<ToolCall>>>;

    fn tools_for_step(
        &self,
        session_store: &ExtensionData,
        thread_store: &ExtensionData,
        _step_store: &ExtensionData,
    ) -> Vec<Arc<dyn ToolExecutor<ToolCall>>> {
        self.tools(session_store, thread_store)
    }
}

这意味着 Extension 工具的可用性可以随 sampling step 变化,而 Dynamic Tool 定义来自 thread 配置。读源码时不能把二者都概括成“会话注册工具”。

2. Step装配 ​

2.1 Executor收集 ​

Core 的 extension_tool_executors 从 Session services 取得 immutable extension registry,再把 session、thread 和当前 step store 交给每个 contributor。返回值是 iterator,不复制 contributor 自身。

源码位置:codex-rs/core/src/tools/spec_plan.rs :: extension_tool_executors

rust
pub(crate) fn extension_tool_executors<'a>(
    session: &'a Session,
    step_store: &'a ExtensionData,
) -> impl Iterator<Item = Arc<dyn ToolExecutor<ExtensionToolCall>>> + 'a {
    session
        .services
        .extensions
        .tool_contributors()
        .iter()
        .flat_map(move |contributor| {
            contributor.tools_for_step(
                &session.services.session_extension_data,
                &session.services.thread_extension_data,
                step_store,
            )
        })
}

这里的 step store 与 StepContext 同属当前 sampling step。下一次 sampling 会重新收集 executor,因此 contributor 可以依据上一步写入的扩展状态改变工具集合。

2.2 注册顺序 ​

普通 turn 的工具来源顺序是 Core → MCP → Extension → Dynamic,然后才 finalize router。basic reviewer session source 不进入这些通用来源,只使用它自己的受限工具集合。Extension executor 被包装为 ExtensionToolAdapter 后,以 external runtime 注册;同名 key 已存在时,register_external 保留先到者。

源码位置:codex-rs/core/src/tools/spec_plan.rs :: build_core_tool_router

rust
let mut registry = ToolRegistry::default();
add_core_tool_sources(&context, &mut registry);

let hosted_specs = if crate::guardian::is_basic_session_source(
    &turn_context.session_source,
) {
    Vec::new()
} else {
    let registered_mcp_tools = session.services.mcp_handler_cache.append_mcp_tools(
        mcp,
        &turn_context.config,
        apps_enabled,
        &mcp.config().mcp_server_catalog,
        search_tool_enabled(turn_context),
        &mut registry,
    );
    apply_mcp_tool_exposure_policy(/* ... */, &mut registry);
    let standalone_web_search_tool = append_extension_tool_executors(
        turn_context,
        extension_tool_executors(session, step_store),
        &mut registry,
    );
    append_dynamic_tool_runtimes(&turn_context.dynamic_tools, &mut registry);
    hosted_model_tool_specs(turn_context, standalone_web_search_tool.as_slice())
};

冲突测试构造同名 MCP、Extension 和 Dynamic runtime,断言 MCP 保持获胜,Extension 的另一个 namespace 仍可注册,Dynamic 再排在后面。来源顺序既影响显示顺序,也影响 collision winner。

2.3 特殊Gate ​

大多数 Extension executor 直接包装注册;web.run 和 image_gen.imagegen 例外。Standalone Web Search 受 feature 与 web search mode 共同控制;Image Generation 还检查 feature、账户、provider、namespace capability 和模型 Image modality。

源码位置:codex-rs/core/src/tools/spec_plan.rs :: append_extension_tool_executors

rust
for executor in executors {
    let tool_name = executor.tool_name();
    let is_standalone_web_search = tool_name == ToolName::namespaced("web", "run");
    if is_standalone_web_search && (!standalone_web_search_enabled || !web_search_mode_on) {
        continue;
    }
    if tool_name == ToolName::namespaced(IMAGE_GEN_NAMESPACE, IMAGEGEN_TOOL_NAME)
        && !image_generation_available(turn_context)
    {
        continue;
    }
    let runtime = Arc::new(ExtensionToolAdapter::new(executor));
    if registry.register_external(runtime) && is_standalone_web_search {
        standalone_web_search_tool = Some(tool_name);
    }
}

这些 gate 位于 adapter 构造前。被跳过的 executor 不在 registry,后续不能靠 ToolSearch 或直接 function call 绕过。

3. Adapter契约 ​

3.1 属性透传 ​

ExtensionToolAdapter 是两个 invocation 类型之间的桥。tool name、spec、exposure、并行能力和 search info 全部委托给 extension executor;Core 不重新定义 extension 工具的模型契约。

源码位置:codex-rs/core/src/tools/handlers/extension_tools.rs :: ExtensionToolAdapter

rust
pub(crate) struct ExtensionToolAdapter(
    Arc<dyn codex_tools::ToolExecutor<ExtensionToolCall>>,
);

impl ToolExecutor<ToolInvocation> for ExtensionToolAdapter {
    fn tool_name(&self) -> ToolName {
        self.0.tool_name()
    }

    fn spec(&self) -> ToolSpec {
        self.0.spec()
    }

    fn exposure(&self) -> ToolExposure {
        self.0.exposure()
    }

    fn supports_parallel_tool_calls(&self) -> bool {
        self.0.supports_parallel_tool_calls()
    }

    fn search_info(&self) -> Option<ToolSearchInfo> {
        self.0.search_info()
    }

    fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
        Box::pin(async move {
            self.0.handle(to_extension_call(&invocation).await).await
        })
    }
}

extension executor 与 Core 共用 ToolOutput 和 FunctionCallError,所以 handle 结果无需转换。adapter 的主要成本全部在 to_extension_call。

3.2 Payload匹配 ​

普通 Function payload 对所有 Extension executor 都可接受。Custom payload 只有在 executor spec 是顶层 Freeform,或 namespace 中存在与当前 tool name 相同的 Custom tool 时才匹配;ToolSearch payload 永远拒绝。

源码位置:codex-rs/core/src/tools/handlers/extension_tools.rs :: CoreToolRuntime for ExtensionToolAdapter

rust
fn matches_kind(&self, payload: &ToolPayload) -> bool {
    match payload {
        ToolPayload::Function { .. } => true,
        ToolPayload::Custom { .. } => match self.0.spec() {
            ToolSpec::Freeform(_) => true,
            ToolSpec::Namespace(namespace) => namespace.tools.iter().any(|tool| {
                matches!(
                    tool,
                    ResponsesApiNamespaceTool::Custom(tool)
                        if tool.name == self.0.tool_name().name
                )
            }),
            ToolSpec::Function(_)
            | ToolSpec::ToolSearch { .. }
            | ToolSpec::WebSearch { .. } => false,
        },
        ToolPayload::ToolSearch { .. } => false,
    }
}

这一步发生在真正调用 executor 前,防止同一个 registry key 因 wire item 类型不同而把 raw custom input 错交给 function parser。

3.3 共享输出 ​

扩展 executor 返回 Box<dyn ToolOutput>。例如 JsonToolOutput 会根据输入 payload 自动选择 FunctionCallOutput 或 CustomToolCallOutput,并把 JSON 值作为模型输出、PostToolUse response 和 Code Mode result。

源码位置:codex-rs/tools/src/tool_output.rs :: JsonToolOutput

rust
impl ToolOutput for JsonToolOutput {
    fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
        let output = FunctionCallOutputPayload {
            body: FunctionCallOutputBody::Text(self.value.to_string()),
            success: self.success,
        };

        if matches!(payload, ToolPayload::Custom { .. }) {
            return ResponseInputItem::CustomToolCallOutput {
                call_id: call_id.to_string(),
                name: None,
                output,
            };
        }
        ResponseInputItem::FunctionCallOutput {
            call_id: call_id.to_string(),
            output,
        }
    }

    fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option<JsonValue> {
        Some(self.value.clone())
    }

    fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
        self.value.clone()
    }
}

Extension Tool 没有专属输出协议;具体 executor 可以返回 JSON、MCP CallToolResult 或其他实现了共享 trait 的类型。

4. Call快照 ​

4.1 数据结构 ​

扩展 API 的 ToolCall 是一次执行所需的只读快照。它包含 turn/call/tool identity、模型、ASCII JSON metadata、输出截断策略、调用来源、完整 conversation history、turn item emitter、环境列表和原始 payload。

源码位置:codex-rs/tools/src/tool_call.rs :: ToolCall

rust
pub struct ToolCall {
    pub turn_id: String,
    pub call_id: String,
    pub tool_name: ToolName,
    pub model: String,
    pub codex_turn_metadata: Option<String>,
    pub truncation_policy: TruncationPolicy,
    pub source: ToolCallSource,
    pub conversation_history: ConversationHistory,
    pub turn_item_emitter: Arc<dyn TurnItemEmitter>,
    pub environments: Vec<ToolEnvironment>,
    pub payload: ToolPayload,
}

它没有 Session、TurnContext、submission channel 或 active-turn 锁。扩展只能通过明确授予的历史、环境和 emitter 能力与宿主交互。

4.2 历史快照 ​

to_extension_call 调用 session.clone_history(),把 raw response items 包装成 immutable ConversationHistory。历史由 Arc<[ResponseItem]> 持有,扩展能读取但不能就地修改 Session history。

源码位置:codex-rs/core/src/tools/handlers/extension_tools.rs :: to_extension_call

rust
let conversation_history =
    ConversationHistory::new(invocation.session.clone_history().await.into_raw_items());
let codex_turn_metadata = invocation
    .turn
    .turn_metadata_state
    .current_meta_value_for_mcp_request(McpTurnMetadataContext {
        model: invocation.turn.model_info.slug.as_str(),
        reasoning_effort: invocation.turn.effective_reasoning_effort(),
    })
    .and_then(|metadata| to_ascii_json_string(&metadata).ok());

metadata 与 MCP 请求使用同一构造上下文,包含当前模型与推理强度;ASCII JSON 转换失败时字段为 None,不会阻断工具执行。

4.3 来源与预算 ​

ToolCallSource 区分模型直接调用与 Code Mode nested call。Core 的 DirectPlaintextMessage 在扩展边界归并为 Direct;Code Mode 则保留 cell_id 与 runtime 内部 call id。extension executor 因此无需接触 Core 私有枚举,也能判断输出面向模型正文还是 Code Mode typed result。

源码位置:

  • codex-rs/core/src/tools/lifecycle.rs :: extension_tool_call_source
  • codex-rs/tools/src/tool_call.rs :: ToolCallSource
rust
pub(crate) fn extension_tool_call_source(source: ToolCallSource) -> ExtensionToolCallSource {
    match source {
        ToolCallSource::Direct | ToolCallSource::DirectPlaintextMessage => {
            ExtensionToolCallSource::Direct
        }
        ToolCallSource::CodeMode {
            cell_id,
            runtime_tool_call_id,
        } => ExtensionToolCallSource::CodeMode {
            cell_id,
            runtime_tool_call_id,
        },
    }
}

来源还决定 response_byte_budget:Direct 取工具上限与模型截断预算的较小值,Code Mode typed result 不套用模型正文截断,只受工具自己的上限约束。Skills 的 list/read executor 已使用这个 API 分页或截断响应;因此 truncation_policy 不再只是被动 metadata。

源码位置:codex-rs/tools/src/tool_call.rs :: ToolCall::response_byte_budget

rust
pub fn response_byte_budget(&self, max_response_bytes: usize) -> usize {
    match &self.source {
        ToolCallSource::Direct => {
            max_response_bytes.min((self.truncation_policy * 1.2).byte_budget())
        }
        ToolCallSource::CodeMode { .. } => max_response_bytes,
    }
}

4.4 最终装配 ​

历史和环境准备完成后,Core 复制 identity 与 payload,并把 Session/Turn 的 Weak 引用封装进 emitter。executor 得到的是值对象;adapter 返回后,不会因为 emitter 而强行延长整个 turn 生命周期。

源码位置:codex-rs/core/src/tools/handlers/extension_tools.rs :: to_extension_call

rust
ExtensionToolCall {
    turn_id: invocation.turn.sub_id.clone(),
    call_id: invocation.call_id.clone(),
    tool_name: invocation.tool_name.clone(),
    model: invocation.turn.model_info.slug.clone(),
    codex_turn_metadata,
    truncation_policy: invocation.turn.model_info.truncation_policy.into(),
    source: extension_tool_call_source(invocation.source.clone()),
    conversation_history,
    turn_item_emitter: Arc::new(CoreTurnItemEmitter {
        session: Arc::downgrade(&invocation.session),
        turn: Arc::downgrade(&invocation.turn),
    }),
    environments,
    payload: invocation.payload.clone(),
}

5. 环境投影 ​

5.1 Native路径限制 ​

Core 遍历当前 StepContext 的所有 ready environments,但只把 cwd 能转换为本机 AbsolutePathBuf 的环境交给扩展。foreign environment 的 PathUri 当前无法进入 ToolEnvironment,会被跳过。

因此扩展看到的 environments 可能少于 TurnContext 中的环境数。当前源码在 to_extension_call 中用 environment.cwd().to_abs_path() 做转换,失败时直接 continue;文章不能把 Extension Tool 写成已经完整支持任意远程/foreign cwd。完整的权限与 sandbox 投影见下一小节。

5.2 Sticky权限 ​

每个环境在暴露给扩展前,Core 读取当前 Session/Turn 已授予的 sticky permissions,使用 SandboxPermissions::UseDefault 合并,再构造 filesystem sandbox context。扩展不会直接拿到无约束文件系统。

源码位置:codex-rs/core/src/tools/handlers/extension_tools.rs :: to_extension_call

rust
let additional_permissions = apply_granted_turn_permissions(
    invocation.session.as_ref(),
    &environment.selection.environment_id,
    native_cwd.as_path(),
    SandboxPermissions::UseDefault,
    /*additional_permissions*/ None,
)
.await
.additional_permissions;
let file_system_sandbox_context = environment.sandbox_context(additional_permissions);
environments.push(ToolEnvironment {
    environment_id: environment.selection.environment_id.clone(),
    cwd: native_cwd,
    file_system: environment.environment.get_filesystem(),
    file_system_sandbox_context,
});

ToolEnvironment.file_system 是执行环境的文件系统实现,file_system_sandbox_context 是这次 turn 的访问边界。扩展必须把二者一起使用,不能从 cwd 自行回退到宿主 std::fs。

6. Item通道 ​

6.1 Canonical item ​

扩展可以通过 TurnItemEmitter 发布 ExtensionItem。Core adapter 把它包装为 TurnItem::Extension,走正常的 item started/completed pipeline;扩展同时提供 legacy events,因为 Core 不理解每种扩展私有 payload,无法自行推导兼容事件。

源码位置:codex-rs/tools/src/tool_call.rs :: ExtensionTurnItem

rust
pub struct ExtensionTurnItem {
    pub item: ExtensionItem,
    pub legacy_events: Vec<EventMsg>,
}

pub trait TurnItemEmitter: Send + Sync {
    fn emit_started<'a>(&'a self, item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a>;
    fn emit_completed<'a>(&'a self, item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a>;
}

canonical item 由 extension-items crate 定义,Core 只拥有外层枚举。这是扩展类型与 core protocol 解耦的关键边界。

6.2 发送顺序 ​

Core 先发送 canonical item,再按扩展提供的顺序发送 legacy events。started 和 completed 使用同一规则。

源码位置:codex-rs/core/src/tools/handlers/extension_tools.rs :: CoreTurnItemEmitter

rust
fn emit_started<'a>(&'a self, item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a> {
    Box::pin(async move {
        let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else {
            return;
        };
        let ExtensionTurnItem {
            item,
            legacy_events,
        } = item;
        let item = TurnItem::Extension(item);
        session.emit_turn_item_started(turn.as_ref(), &item).await;
        emit_legacy_events(session.as_ref(), turn.as_ref(), legacy_events).await;
    })
}

Image Generation 测试断言事件顺序为 canonical started → legacy begin → canonical completed → legacy end,并确认 extension-owned saved_path 没有在 Core 包装过程中丢失。当前 item 还携带 typed failure,例如用量限制;Core 仍只负责原样包装,不把扩展失败类型改写成通用字符串。

6.3 Weak生命周期 ​

emitter 保存 Weak<Session> 与 Weak<TurnContext>。如果调用结束或 turn 已释放,upgrade() 失败后 item 直接丢弃,不复活旧 session。这个选择避免扩展长期持有 emitter 导致会话无法回收。

7. 执行路径 ​

7.1 直接调用 ​

router 集成测试使用 namespace extension/ 下的 echo 工具。它先验证 spec 对模型可见,再把模型 FunctionCall 解析为 registry key,dispatch 后检查 JSON output 中包含 arguments、call id 和 conversation history。

源码位置:codex-rs/core/src/tools/router_tests.rs :: extension_tool_executors_are_model_visible_and_dispatchable

rust
assert!(
    router.model_visible_specs().iter().any(
        |spec| matches!(spec, ToolSpec::Namespace(namespace)
        if namespace.name == "extension/"
            && namespace.tools.iter().any(|tool| matches!(
                tool,
                ResponsesApiNamespaceTool::Function(tool) if tool.name == "echo"
            )))
    )
);

let call = ToolRouter::build_tool_call(ResponseItem::FunctionCall {
    id: None,
    name: "echo".to_string(),
    namespace: Some("extension/".to_string()),
    arguments: json!({ "message": "hello" }).to_string(),
    call_id: "call-extension".to_string(),
    internal_chat_message_metadata_passthrough: None,
})?
.expect("function_call should produce a tool call");

这个测试验证了 namespace spec 与 registry key 必须一致。若 executor 的 tool_name() 和 spec() 描述不同名称,模型可能看到工具却无法找到 runtime。

7.2 Hook边界 ​

Extension adapter 复用 CoreToolRuntime 的通用 hook 逻辑。Function payload 会被解析为 JSON tool_input;输出可通过 post_tool_use_response 提供稳定 JSON。Custom payload 不走这条 function hook contract。PreToolUse 在 adapter 构造 ExtensionToolCall 前运行,因此 source、history 和 environment snapshot 对应的是重写后的实际 invocation;PostToolUse 只处理 success_for_logging=true 的正常输出。

源码位置:codex-rs/core/src/tools/handlers/extension_tools.rs :: exposes_generic_hook_payloads

rust
assert_eq!(
    CoreToolRuntime::pre_tool_use_payload(&handler, &invocation),
    Some(PreToolUsePayload {
        tool_name: HookToolName::new("extension_echo"),
        tool_input: json!({ "message": "hello" }),
    })
);
assert_eq!(
    CoreToolRuntime::post_tool_use_payload(&handler, &invocation, &output),
    Some(PostToolUsePayload {
        tool_name: HookToolName::new("extension_echo"),
        tool_use_id: "call-extension".to_string(),
        tool_input: json!({ "message": "hello" }),
        tool_response: json!({ "ok": true }),
    })
);

7.3 控制工具分类 ​

并非所有 Extension Tool 在宿主看来都是普通业务工具。Goal 的 get_goal/create_goal/update_goal,以及 History/Notes 的指定读写工具,被 adapter 标记为 builtin control tool。这样 PreToolUse block 和正常完成能进入统一的 control-tool analytics;分类依据是 canonical ToolName,不是 contributor 类型。

源码位置:codex-rs/core/src/tools/handlers/extension_tools.rs :: ExtensionToolAdapter::is_builtin_control_tool

rust
fn is_builtin_control_tool(&self) -> bool {
    let tool_name = self.0.tool_name();
    if tool_name.is_default_namespace() {
        return matches!(
            tool_name.name.as_str(),
            "get_goal" | "create_goal" | "update_goal"
        );
    }
    matches!(
        (tool_name.namespace.as_deref(), tool_name.name.as_str()),
        (Some("notes"), "list_files_by_prefix" | "read_file" | "search_contents"
            | "append_to_file" | "write_file")
        | (Some("history"), "list_windows" | "list_items" | "read_item"
            | "search_contents")
    )
}

App Server 中有两组测试分别尝试覆盖 Goal 与 History/Notes 的控制工具统计。当前运行时,两组测试都在 mock 服务未收到预期请求时结束,尚未执行 analytics 断言。因此本节能够由 adapter 的名称匹配源码确认分类规则,但不能把这次集成运行解释为统计链路已经通过。

7.4 Lifecycle观察 ​

ToolContributor 拥有工具实现,ToolLifecycleContributor 则观察宿主中的所有工具调用,两者不能混为一谈。PreToolUse 完成后、executor 运行前,Core 把最终 payload、只读 conversation history 和调用 source 交给 on_tool_start;结束时再用 Completed、Blocked、Failed 或 Aborted 描述结果。

源码位置:

  • codex-rs/core/src/tools/lifecycle.rs :: notify_tool_start
  • codex-rs/ext/extension-api/src/contributors/tool_lifecycle.rs :: ToolStartInput
rust
contributor
    .on_tool_start(ToolStartInput {
        session_store: &invocation.session.services.session_extension_data,
        thread_store,
        turn_store: invocation.turn.extension_data.as_ref(),
        turn_id: invocation.turn.sub_id.as_str(),
        call_id: invocation.call_id.as_str(),
        tool_name: &invocation.tool_name,
        payload: &invocation.payload,
        conversation_history: Arc::clone(&conversation_history),
        source: extension_tool_call_source(invocation.source.clone()),
    })
    .await;

这条观察链也覆盖 Core、MCP 和 Dynamic Tool,而不只覆盖 Extension Tool。它的用途是生命周期统计或扩展私有状态更新,不拥有 adapter,也不能改写 payload。

7.5 错误与取消 ​

adapter 不捕获 extension executor 的 FunctionCallError,错误直接进入统一 ToolOrchestrator/parallel failure path。它也不建立专属 pending sender;取消由外层工具任务取消和 future drop 传播。扩展若拥有额外资源,必须在自己的 future/drop 路径清理,不能依赖客户端 response map。

8. 搜索与模式 ​

8.1 Deferred搜索 ​

adapter 透传 executor 的 exposure 和 search_info,因此 Extension 工具可以声明 Deferred。tool plan 测试断言此时 extension_echo 已注册但不直接可见,模型只看到 tool_search。

ExtensionToolAdapter 没有实现 immutable_spec,所以 ToolSearch cache 把 Deferred Extension runtime 归为 dynamic source,以 ToolSearchInfo 值判断能否复用。即使 contributor 每个 step 返回新的 executor,只要 search metadata 相同仍可复用索引;spec、description 或 schema 改变则重建。

源码位置:

  • codex-rs/core/src/tools/handlers/extension_tools.rs :: CoreToolRuntime for ExtensionToolAdapter
  • codex-rs/core/src/tools/handlers/tool_search.rs :: ToolSearchHandlerCache::get_or_build
  • codex-rs/core/src/tools/spec_plan_tests.rs :: deferred_extension_tools_are_discoverable_with_tool_search
rust
let plan = probe_with(
    |turn| {
        turn.model_info.supports_search_tool = true;
    },
    ToolPlanInputs {
        extension_tool_executors: vec![Arc::new(DeferredExtensionTool)],
        ..ToolPlanInputs::default()
    },
)
.await;

plan.assert_visible_contains(&["tool_search"]);
plan.assert_visible_lacks(&["extension_echo"]);
plan.assert_registered_contains(&["extension_echo"]);
assert_eq!(plan.exposure("extension_echo"), ToolExposure::Deferred);

8.2 Code Mode ​

同一个 exposure 还决定 executor 是否进入 Code Mode 的 nested tool surface。adapter 不重新解释工具 namespace;Code Mode 名称规范化、冲突和输出转换都由共享工具框架处理。Custom extension 测试设计为先直接调用 namespace custom tool,再通过 tools.editor__apply_patch 执行 nested call。

源码位置:codex-rs/core/tests/suite/code_mode.rs :: code_mode_exposes_and_dispatches_namespaced_custom_tools

javascript
const tool = ALL_TOOLS.find(({ name }) => name === "editor__apply_patch");
const result = await tools.editor__apply_patch("nested patch");
text(JSON.stringify({
  name: tool?.name ?? null,
  description: tool?.description ?? null,
  result,
}));

测试中的 editor__apply_patch 是 Code Mode 对 namespace editor 的规范化名称;扩展 runtime 自己仍使用 editor.apply_patch。这说明 Code Mode 的标识转换发生在共享 Code Mode 层,不应反过来修改 Extension executor 的 registry key。

当前运行连续两次在首个模型请求的 description 断言处失败:实际值只有扩展提供的描述,测试期望值还追加 TypeScript declaration。失败发生在 nested dispatch 断言之前,所以它暴露了 Code Mode 描述增强与测试预期不一致,不能据此宣称 namespaced custom dispatch 已由该集成测试验证。

8.3 失败定位 ​

遇到“模型看不到 Extension 工具”,按以下顺序定位:

  1. contributor 是否已进入 Session 的 ExtensionRegistry;
  2. tools_for_step 是否在当前 step 返回 executor;
  3. 特殊 web/image gate 是否跳过;
  4. 同名 Core/MCP runtime 是否先占用 registry key;
  5. exposure 是否为 Deferred、CodeModeOnly 或 Hidden;
  6. tool_name() 与 spec() 中的 namespace/name 是否一致。

这比只搜索 ExtensionToolAdapter::handle 更有效,因为多数不可见问题发生在调用之前。

9. 源码练习 ​

先复述主线:ExtensionRegistryBuilder 注册 contributor → 当前 step 的三层 ExtensionData → executor 列表 → 特殊 gate 和 collision → ExtensionToolAdapter → source/history/metadata/environment 快照 → extension executor → shared ToolOutput 与 optional ExtensionItem。

再做三个只读验证:

  • 在 passes_turn_fields_and_scoped_turn_item_emitter_to_extension_call 中指出哪些断言证明扩展得到了历史、sandbox cwd 和 payload,哪些断言证明 emitter 没有强持有 Session/Turn;
  • 在 hosted_web_search_and_standalone_image_generation_follow_runtime_gates 中列出 Image Generation 被隐藏的三组输入,并解释为什么这些条件在 adapter 注册前检查;
  • 比较 Direct 与 Code Mode 的 response_byte_budget,说明为什么 Skills executor 可以对同一逻辑输出采用不同字节预算。

在 Codex 源码仓库的 codex-rs/ 目录运行:

bash
rg -n "ToolContributor|extension_tool_executors|ExtensionToolAdapter|to_extension_call" ext/extension-api core
cargo test -p codex-core --lib 'tools::handlers::extension_tools::tests' -- --test-threads=1
RUST_MIN_STACK=8388608 cargo test -p codex-core extension_tool_executors_are_model_visible_and_dispatchable -- --test-threads=1
RUST_MIN_STACK=8388608 cargo test -p codex-core --test all tool_start_receives_ -- --test-threads=1
RUST_MIN_STACK=8388608 cargo test -p codex-core --test all code_mode_exposes_and_dispatches_namespaced_custom_tools -- --test-threads=1

前三组检查 payload、hook、call 快照、item emitter、router dispatch 和 lifecycle history;Code Mode 测试当前会复现 description 差异。它们都不证明每个具体扩展的业务正确性,具体 Web Search、Image Generation、Skills 或 MCP Extension 仍需检查各自 crate 的实现和测试。