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
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
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
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
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
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
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
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
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
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
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_sourcecodex-rs/tools/src/tool_call.rs :: ToolCallSource
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
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
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
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
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
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
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
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
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_startcodex-rs/ext/extension-api/src/contributors/tool_lifecycle.rs :: ToolStartInput
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 ExtensionToolAdaptercodex-rs/core/src/tools/handlers/tool_search.rs :: ToolSearchHandlerCache::get_or_buildcodex-rs/core/src/tools/spec_plan_tests.rs :: deferred_extension_tools_are_discoverable_with_tool_search
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
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 工具”,按以下顺序定位:
- contributor 是否已进入 Session 的 ExtensionRegistry;
tools_for_step是否在当前 step 返回 executor;- 特殊 web/image gate 是否跳过;
- 同名 Core/MCP runtime 是否先占用 registry key;
- exposure 是否为 Deferred、CodeModeOnly 或 Hidden;
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/ 目录运行:
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 的实现和测试。
