ToolSearch工具
tool_search 解决的是模型工具列表过大时的暴露问题:Deferred 工具先留在本地 registry,却不进入第一次模型请求的 tools 数组;模型用查询找到候选后,core 返回一个 tool_search_output,下一次模型请求从这条历史记录认识候选工具。它不是把 runtime 动态插入 router,也不是把命中的工具永久改成 Direct。
本文面向已经读过ToolRegistry数据结构、ToolSpec与函数规格和ToolRouter解析与分派的读者。本文只研究内置 tool_search 的注册、索引字段、BM25 查询、namespace 合并和后续调用,不展开 Apps、Plugins 或 MCP 各自的连接协议;这些来源只在说明它们怎样提供可搜索 metadata 时出现。读完后,读者应能解释一个 Deferred 工具为何在首个请求中不可见、查询命中后为何仍不出现在后续 tools 数组、以及 registry 变化时缓存如何失效。
1. 暴露边界
1.1 能力门槛
tool_search 只有在模型声明支持 search tool 且 provider 开启 namespace tools 时才有意义。search_tool_enabled 把两个条件集中起来;只满足其中一个,router 都不会建立搜索入口。
源码位置:codex-rs/core/src/tools/spec_plan.rs :: search_tool_enabled
pub(crate) fn search_tool_enabled(turn_context: &TurnContext) -> bool {
turn_context.model_info.supports_search_tool && namespace_tools_enabled(turn_context)
}
fn namespace_tools_enabled(turn_context: &TurnContext) -> bool {
turn_context.provider.capabilities().namespace_tools
}这里的 supports_search_tool 来自模型能力,namespace_tools 来自 provider capability;它们分别描述“模型能理解哪一种特殊工具”和“当前传输能否承载 namespace 工具”。不能用配置文件中的某个 Apps 开关替代这两个判断。
1.2 Deferred注册
普通 runtime 的 ToolExposure::Deferred 表示“保持可调用,但不进入初始模型工具表,并提供 search metadata”。ToolSearch 自身和 WebSearch 不会被转换为搜索条目,避免搜索工具递归搜索自己或把另一个 hosted search 当作普通候选。
源码位置:codex-rs/tools/src/tool_executor.rs :: ToolExposure
pub enum ToolExposure {
Direct,
Deferred,
DeferredModelOnly,
DirectModelOnly,
CodeModeOnly,
Hidden,
}
impl ToolExposure {
pub fn is_direct(self) -> bool {
matches!(self, Self::Direct | Self::DirectModelOnly)
}
pub fn is_deferred(self) -> bool {
matches!(self, Self::Deferred | Self::DeferredModelOnly)
}
}DeferredModelOnly 仍可搜索,但不进入 Code Mode;CodeModeOnly 反过来只给嵌套脚本使用,不进入搜索。暴露面是独立维度,不能只看 runtime 是否已注册。
图中 registry 是所有者,模型工具表只是一个按 exposure 投影出来的视图。搜索命中不会改变 D 节点的 exposure,因此后续请求仍可能没有该工具的直接 schema。
1.3 Router装配
router 在完成阶段先判断搜索能力,再检查 registry 中是否至少有一个 Deferred runtime 能生成 search_info。没有候选时不注册空的 tool_search;已有同名工具时先移除并记录 collision,防止外部工具伪装成特殊 wire tool。
源码位置:codex-rs/core/src/tools/spec_plan.rs :: finalize_tool_router
let tool_search_name = ToolName::plain(TOOL_SEARCH_TOOL_NAME);
if search_tool_enabled(turn_context)
&& registry.entries().any(|tool| {
tool.runtime.tool_name() != tool_search_name
&& tool.exposure.is_deferred()
&& tool.runtime.search_info().is_some()
})
{
if registry.remove(&tool_search_name).is_some() {
registry.record_collision(tool_search_name.clone());
}
append_tool_search_executor(turn_context, &mut registry, tool_search_handler_cache);
}真正的候选收集已下沉到 ToolSearchHandlerCache::get_or_build。append_tool_search_executor 只决定来源列表是否放进工具描述, 然后把当前 registry 整体交给 cache。这样 cache 可以区分 immutable runtime identity 与每 Turn 变化的 dynamic metadata。
源码位置:codex-rs/core/src/tools/spec_plan.rs :: append_tool_search_executor
let source_listing = if turn_context
.config
.features
.enabled(Feature::DeferredToolWorldState)
{
ToolSearchSourceListing::Omit
} else {
ToolSearchSourceListing::Include
};
let handler = tool_search_handler_cache.get_or_build(registry, source_listing);
registry.register_trusted(handler);DeferredToolWorldState 打开时,来源信息由 world-state 注入路径提供,ToolSearch 描述避免重复列出。feature 切换会改变 source_listing,强制重建 handler,即使候选 runtime 完全相同。
2. 索引模型
2.1 搜索条目
ToolSearchInfo 把可执行 ToolSpec 拆成两部分:用于检索的纯文本 search_text,以及搜索命中后返回的 LoadableToolSpec。它还可携带 ToolSearchSourceInfo,供工具描述展示来源名称和说明。
源码位置:codex-rs/tools/src/tool_search.rs :: ToolSearchInfo
pub struct ToolSearchEntry {
pub search_text: String,
pub output: LoadableToolSpec,
}
pub struct ToolSearchInfo {
pub entry: ToolSearchEntry,
pub source_info: Option<ToolSearchSourceInfo>,
}
impl ToolSearchInfo {
pub fn from_tool_spec(
spec: ToolSpec,
source_info: Option<ToolSearchSourceInfo>,
) -> Option<Self> {
let search_text = default_tool_search_text(&spec);
Self::from_spec(search_text, spec, source_info)
}
}这个结构没有保存 runtime 的 Arc 或调用状态。命中结果只是可序列化的模型规格;真正的执行仍由原 registry 根据后续 FunctionCall 查找 runtime。
2.2 文本组成
函数工具的索引文本包含原始名称、把下划线替换为空格后的名称、description 和参数 schema 的字段/描述。namespace 工具还加入 namespace 名称与 namespace description;custom tool 则加入 grammar syntax。这样查询既可以命中人类描述,也可以命中模型只知道的字段名。
源码位置:codex-rs/tools/src/tool_search.rs :: default_tool_search_text
fn append_function_search_text(tool: &ResponsesApiTool, parts: &mut Vec<String>) {
push_search_part(parts, tool.name.clone());
push_search_part(parts, tool.name.replace('_', " "));
push_search_part(parts, tool.description.clone());
append_schema_search_text(&tool.parameters, parts);
}
fn append_schema_search_text(schema: &JsonSchema, parts: &mut Vec<String>) {
if let Some(description) = &schema.description {
push_search_part(parts, description.clone());
}
if let Some(properties) = &schema.properties {
for (name, schema) in properties {
push_search_part(parts, name.clone());
append_schema_search_text(schema, parts);
}
}
if let Some(items) = &schema.items {
append_schema_search_text(items, parts);
}
if let Some(variants) = &schema.any_of {
for variant in variants {
append_schema_search_text(variant, parts);
}
}
}测试用 namespace description、函数 description、顶层参数 description、属性名和嵌套 timezone 字段分别查询,断言它们都能找到同一个工具。这证明索引覆盖这些 schema 文本,不证明 BM25 对任意自然语言同义词都能命中。
2.3 可加载规格
命中后返回的不是原始 ToolSpec。函数和 custom tool 会被包进默认 functions namespace;namespace 工具保留原 namespace。所有返回项都设置 defer_loading=true,函数工具的 output_schema 被清除,因为结果 schema 不是下一次模型继续调用所需的输入定义。
源码位置:codex-rs/tools/src/tool_search.rs :: ToolSearchInfo::from_spec
let output = match spec {
ToolSpec::Function(mut tool) => {
tool.defer_loading = Some(true);
tool.output_schema = None;
LoadableToolSpec::Namespace(ResponsesApiNamespace {
name: DEFAULT_FUNCTION_NAMESPACE.to_string(),
description: default_namespace_description(DEFAULT_FUNCTION_NAMESPACE),
tools: vec![ResponsesApiNamespaceTool::Function(tool)],
})
}
ToolSpec::Namespace(mut namespace) => {
if namespace.description.trim().is_empty() {
namespace.description = default_namespace_description(&namespace.name);
}
for tool in &mut namespace.tools {
match tool {
ResponsesApiNamespaceTool::Function(tool) => {
tool.defer_loading = Some(true);
tool.output_schema = None;
}
ResponsesApiNamespaceTool::Custom(tool) => {
tool.defer_loading = Some(true);
}
}
}
LoadableToolSpec::Namespace(namespace)
}
ToolSpec::ToolSearch { .. } | ToolSpec::WebSearch { .. } => return None,
ToolSpec::Freeform(mut tool) => {
tool.defer_loading = Some(true);
LoadableToolSpec::Namespace(ResponsesApiNamespace {
name: DEFAULT_FUNCTION_NAMESPACE.to_string(),
description: default_namespace_description(DEFAULT_FUNCTION_NAMESPACE),
tools: vec![ResponsesApiNamespaceTool::Custom(tool)],
})
}
};这个转换解释了为什么搜索结果中的 namespace 可能与 runtime 原来的 ToolName 表现不同:wire 层需要一个可加载的 schema 容器,dispatch 层仍使用原始 registry 名称。
3. 索引构建
3.1 BM25文档
ToolSearchHandler::new 为每个 ToolSearchInfo.entry.search_text 建立一个带整数 id 的 BM25 document。id 只是当前 handler 内的数组下标,命中后再通过 search_infos.get(id) 取回原条目;它不是跨 turn 持久化的工具 id。
源码位置:codex-rs/core/src/tools/handlers/tool_search.rs :: ToolSearchHandler::new
let documents: Vec<Document<usize>> = search_infos
.iter()
.map(|search_info| search_info.entry.search_text.clone())
.enumerate()
.map(|(idx, search_text)| Document::new(idx, search_text))
.collect();
let search_engine =
SearchEngineBuilder::<usize>::with_documents(Language::English, documents).build();当前语言参数固定为 Language::English。索引不会对 Rust 标识符做专门的语义解析,效果依赖 BM25 对文本 token 的处理;因此 calendar_timezone_option_99 能命中字段名,是因为字段名被原样放入文本,而不是因为系统理解了“日历时区”。
3.2 来源描述
同一个 handler 的 spec 是动态构造的。create_tool_search_tool 对来源按名称去重,保留第一次出现的非空描述,并把总描述限制在 512 KiB;Include 和 Omit 只影响这段说明,不影响 BM25 文档本身。
源码位置:codex-rs/core/src/tools/handlers/tool_search_spec.rs :: create_tool_search_tool
let source_section = match source_listing {
ToolSearchSourceListing::Include => {
let mut source_descriptions = BTreeMap::new();
for source in searchable_sources {
source_descriptions
.entry(source.name.clone())
.and_modify(|existing: &mut Option<String>| {
if existing.is_none() {
*existing = source.description.clone();
}
})
.or_insert(source.description.clone());
}
let source_descriptions = if source_descriptions.is_empty() {
"None currently enabled.".to_string()
} else {
let reserved_name_bytes = source_descriptions.keys().fold(
source_descriptions.len().saturating_sub(1),
|reserved, name| reserved.saturating_add(2).saturating_add(name.len()),
);
let mut description_budget =
MAX_TOOL_SEARCH_SOURCE_DESCRIPTION_BYTES.saturating_sub(reserved_name_bytes);
let mut rendered = String::new();
for (name, description) in source_descriptions {
let separator_bytes = usize::from(!rendered.is_empty());
let required = separator_bytes.saturating_add(2).saturating_add(name.len());
if required
> MAX_TOOL_SEARCH_SOURCE_DESCRIPTION_BYTES.saturating_sub(rendered.len())
{
continue;
}
if !rendered.is_empty() {
rendered.push('\n');
}
rendered.push_str("- ");
rendered.push_str(&name);
if let Some(description) = description && description_budget >= 2 {
rendered.push_str(": ");
description_budget -= 2;
let bounded_description =
take_bytes_at_char_boundary(&description, description_budget);
rendered.push_str(bounded_description);
description_budget -= bounded_description.len();
}
}
rendered
};
format!("\n\nYou have access to tools from the following sources:\n{source_descriptions}\n")
}
ToolSearchSourceListing::Omit => "\n\n".to_string(),
};实际实现把预算计算、字符边界截断和描述拼接都放在 create_tool_search_tool 内。它的关键不变量是:长来源说明不能无限膨胀模型工具描述,且来源名称不能被截成半个 UTF-8 字符。
3.3 缓存所有权
搜索 handler 由 Session service 的 ToolSearchHandlerCache 缓存。cache 不再保存单独的 Arc,而是保存 handler 与 source identity。immutable runtime 使用 Weak<dyn CoreToolRuntime> 指针身份,例如缓存 spec 的 MCP handler;dynamic runtime 则保存当前 ToolSearchInfo 值,例如每 Turn 重建的 Dynamic tool。这样等价 dynamic metadata 可以复用,而同规格但不同 immutable runtime 实例会重建。
源码位置:codex-rs/core/src/tools/handlers/tool_search.rs :: ToolSearchHandlerCache::get_or_build
pub(crate) fn get_or_build(
&self,
registry: &ToolRegistry,
source_listing: ToolSearchSourceListing,
) -> Arc<ToolSearchHandler> {
let sources = registry
.entries()
.filter(|tool| tool.exposure.is_deferred())
.filter_map(|tool| {
if tool.runtime.immutable_spec().is_some() {
Some(ToolSearchSource::Immutable(
Arc::downgrade(&tool.runtime),
))
} else {
tool.runtime
.search_info()
.map(Box::new)
.map(ToolSearchSource::Dynamic)
}
})
.collect::<Vec<_>>();
{
let cached = self.cached();
if let Some(cached) = cached.as_ref()
&& cached.handler.source_listing == source_listing
&& Self::sources_match(&cached.sources, &sources)
{
return Arc::clone(&cached.handler);
}
}
let search_infos = sources.iter().filter_map(|source| match source {
ToolSearchSource::Immutable(runtime) => {
runtime.upgrade().and_then(|runtime| runtime.search_info())
}
ToolSearchSource::Dynamic(search_info) => {
Some(search_info.as_ref().clone())
}
}).collect();
let handler = Arc::new(ToolSearchHandler::new(search_infos, source_listing));
let mut cached = self.cached();
if let Some(cached) = cached.as_ref()
&& cached.handler.source_listing == source_listing
&& Self::sources_match(&cached.sources, &sources)
{
return Arc::clone(&cached.handler);
}
*cached = Some(CachedToolSearchHandler {
handler: Arc::clone(&handler),
sources,
});
handler
}实际实现构建后仍进行第二次锁内 identity 检查,避免并发 writer 覆盖等价 handler。测试验证四种变化:listing 改变、immutable runtime 实例替换、Deferred 变 Direct,以及 dynamic description 刷新,都会按各自 identity 规则决定复用或重建。
4. 查询执行
4.1 调用解析
Responses item 的 execution 必须是 client,并且必须有 call_id;router 才会把它转成 ToolPayload::ToolSearch。参数反序列化失败会在 dispatch 前返回模型可见错误,缺少 call id 或 server execution 的 item 则不会被当作本地调用。
源码位置:codex-rs/core/src/tools/router.rs :: ToolRouter::build_tool_call
ResponseItem::ToolSearchCall {
call_id: Some(call_id),
execution,
arguments,
..
} if execution == "client" => {
let arguments: SearchToolCallParams =
serde_json::from_value(arguments).map_err(|err| {
FunctionCallError::RespondToModel(format!(
"failed to parse tool_search arguments: {err}"
))
})?;
Ok(Some(ToolCall {
tool_name: ToolName::plain("tool_search"),
call_id,
payload: ToolPayload::ToolSearch { arguments },
encrypted_function_args: None,
}))
}
ResponseItem::ToolSearchCall { .. } => Ok(None),SearchToolCallParams 的 query 必填、limit 可选。wire 层允许一个没有 call id 的 ToolSearch item 出现在历史中,但本地执行必须有 id,才能把结果和请求配对。
4.2 输入边界
handler 去掉 query 首尾空白,空 query 和 limit == 0 都返回 RespondToModel;省略 limit 使用 TOOL_SEARCH_DEFAULT_LIMIT,当前是 8。search infos 为空时返回成功的空工具数组,这代表“搜索功能存在但当前没有候选”,不是失败。
源码位置:codex-rs/core/src/tools/handlers/tool_search.rs :: ToolSearchHandler::handle_call
let query = args.query.trim();
if query.is_empty() {
return Err(FunctionCallError::RespondToModel(
"query must not be empty".to_string(),
));
}
let limit = args.limit.unwrap_or(TOOL_SEARCH_DEFAULT_LIMIT);
if limit == 0 {
return Err(FunctionCallError::RespondToModel(
"limit must be greater than zero".to_string(),
));
}
if self.search_infos.is_empty() {
return Ok(boxed_tool_output(ToolSearchOutput { tools: Vec::new() }));
}没有看到“至少命中一个工具”的断言。BM25 可以合法返回空结果,模型应根据下一次上下文决定换查询,而不是把空数组当作 handler 崩溃。
4.3 结果合并
BM25 返回 document id,handler 用 id 取回 ToolSearchEntry,再把多个结果交给 coalesce_loadable_tool_specs。同 namespace 的多个命中会合并到一个 namespace,并保持各工具的顺序;这一步只合并 wire spec,不改变 registry 中的 runtime。
源码位置:codex-rs/core/src/tools/handlers/tool_search.rs :: ToolSearchHandler::search
fn search(
&self,
query: &str,
limit: usize,
) -> Result<Vec<LoadableToolSpec>, FunctionCallError> {
let results = self
.search_engine
.search(query, limit)
.into_iter()
.map(|result| result.document.id)
.filter_map(|id| self.search_infos.get(id))
.map(|search_info| &search_info.entry);
self.search_output_tools(results)
}
fn search_output_tools<'a>(
&self,
results: impl IntoIterator<Item = &'a ToolSearchEntry>,
) -> Result<Vec<LoadableToolSpec>, FunctionCallError> {
Ok(coalesce_loadable_tool_specs(
results.into_iter().map(|entry| entry.output.clone()),
))
}limit 限制 BM25 文档命中数,合并后返回的 namespace 数量可能更少。反过来,一个 namespace 内可包含多个命中工具;不要用返回数组长度推断模型最终获得了多少函数。
4.4 Runtime边界
ToolSearch handler 声明支持并行调用,因为 BM25 索引和候选列表在构造后只读。它的 payload 是专用 ToolSearch,不是 Function, 所以 CoreToolRuntime 默认 PreToolUse/PostToolUse 投影都返回 None;普通 function hook 不会包围搜索调用。取消时则由 AbortedToolOutput 生成结构化空 ToolSearchOutput,保持协议配对。
源码位置:codex-rs/core/src/tools/handlers/tool_search.rs :: ToolExecutor for ToolSearchHandler
fn supports_parallel_tool_calls(&self) -> bool {
true
}
impl CoreToolRuntime for ToolSearchHandler {}这意味着搜索查询本身没有 control-tool analytics 或 function Hook 改写;真正命中的 MCP/Dynamic/Extension 工具在后续调用时, 仍按各自 runtime 的 Hook、审批和并行策略执行。
5. 输出生效
5.1 Wire输出
ToolSearchOutput 把每个 LoadableToolSpec 序列化成 JSON,固定写入 status=completed 和 execution=client。输出本身没有文本摘要,候选工具 schema 就是模型下一步要读的内容。
源码位置:codex-rs/core/src/tools/context.rs :: ToolSearchOutput::to_response_item
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
ResponseInputItem::ToolSearchOutput {
call_id: call_id.to_string(),
status: "completed".to_string(),
execution: "client".to_string(),
tools: self
.tools
.iter()
.map(|tool| {
serde_json::to_value(tool).unwrap_or_else(|err| {
JsonValue::String(format!("failed to serialize tool_search output: {err}"))
})
})
.collect(),
}
}序列化异常不会让 to_response_item 返回 Result,而是把错误字符串作为 tools 数组中的 JSON 值保留下来。正常的 LoadableToolSpec 都能序列化;这个分支属于防御性日志/协议降级,不应被解释为搜索命中成功。
5.2 历史配对
ToolSearchCall 和 ToolSearchOutput 通过 call id 配对。历史规范化会删除没有对应调用的 client output;没有 call id 的历史 output 和 server execution output有不同的保留规则。这样恢复或压缩时不会把一个孤立的候选 schema 当成仍然有效的搜索结果。
源码位置:codex-rs/core/src/context_manager/normalize.rs :: remove_orphan_outputs
ResponseItem::ToolSearchOutput {
call_id: Some(call_id),
..
} => {
let has_match = tool_search_call_ids.contains(call_id);
if !has_match {
error_or_panic(format!("Orphan tool search output for call id: {call_id}"));
}
has_match
}
ResponseItem::ToolSearchOutput { call_id: None, .. } => true,这里的“历史保留”不等于“runtime 可调用”。真正执行下一步 function call 时,router 仍需在当前 registry 找到对应 runtime;搜索结果只是让模型知道可调用名称和参数形状。
5.3 下一次请求
App/MCP 集成测试明确断言:第一次请求只包含 tool_search,第二次请求携带 tool_search_output,但 tools 数组仍不直接包含刚命中的 namespace 或 function;随后模型发出带 namespace 的 function call,registry 才按原 runtime 路由它。这个设计避免每次 sampling 都重复发送完整工具 schema。
源码位置:codex-rs/core/tests/suite/search_tool.rs :: tool_search_returns_deferred_tools_without_follow_up_tool_injection
assert!(
first_request_tools
.iter()
.any(|name| name == TOOL_SEARCH_TOOL_NAME)
);
assert!(
!first_request_tools
.iter()
.any(|name| name == CALENDAR_CREATE_TOOL)
);
assert!(
!second_request_tools
.iter()
.any(|name| name == CALENDAR_CREATE_TOOL),
"follow-up request should rely on tool_search_output history, not tool injection"
);测试还观察到 function call 进入 MCP event begin/end,说明命中结果没有停留在 prompt 文本里,而是能被后续 dispatch 消费。它证明的是当前 App/MCP fixture 的 round-trip,不证明任意 provider 都实现相同的 server-side search 语义。
6. 来源变化
6.1 工具来源
MCP 和 Dynamic handler 直接实现 search_info:MCP 优先使用 connector 名称,否则退回 server 名称;Dynamic 的来源固定为 Dynamic tools。ExtensionToolAdapter 则把 extension executor 的 exposure、parallel 和 search metadata 原样转发。具体 namespace 仍来自当前 ToolSpec,并进入搜索文本和返回规格,因此查询可按名称、description、namespace 或 schema 字段命中。
源码位置:codex-rs/core/src/tools/handlers/mcp.rs :: McpHandler::search_info
fn search_info(&self) -> Option<ToolSearchInfo> {
let source_name = self
.tool_info
.connector_name
.as_deref()
.map(str::trim)
.filter(|connector_name| !connector_name.is_empty())
.unwrap_or_else(|| self.tool_info.server_name.trim());
let source_info = (!source_name.is_empty()).then(|| ToolSearchSourceInfo {
name: source_name.to_string(),
description: self
.tool_info
.namespace_description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string),
});
ToolSearchInfo::from_spec(
build_mcp_search_text(&self.tool_info),
self.spec(),
source_info,
)
}源码位置:codex-rs/core/src/tools/handlers/dynamic.rs :: DynamicToolHandler::search_info
fn search_info(&self) -> Option<ToolSearchInfo> {
ToolSearchInfo::from_tool_spec(
self.spec(),
Some(ToolSearchSourceInfo {
name: "Dynamic tools".to_string(),
description: Some("Tools provided by the current Codex thread.".to_string()),
}),
)
}源码位置:codex-rs/core/src/tools/handlers/extension_tools.rs :: ExtensionToolAdapter::search_info
fn search_info(&self) -> Option<ToolSearchInfo> {
self.0.search_info()
}MCP 测试分别用原始工具名、description 和 schema 字段查询;Dynamic 测试用函数名、空格化名称、namespace 和 schema 字段查询。两组测试共同说明索引文本来自 handler 提供的当前 spec,而不是来自 archive 或一个独立的静态工具目录。
6.2 启用过滤
可搜索不等于所有配置中的工具都能命中。MCP 的 enabled/disabled 过滤在 handler 进入 registry 前完成;search_tool 只读取已经注册且 exposure 为 Deferred 的 runtime。测试配置同一 server 同时启用 echo、禁用 image,查询 image 时断言不会返回 image。
源码位置:codex-rs/core/tests/suite/search_tool.rs :: tool_search_indexes_only_enabled_non_app_mcp_tools
let echo_tools = tool_search_output_tools(&requests[1], echo_call_id);
assert!(
namespace_child_tool(&json!({ "tools": echo_tools }), "mcp__rmcp", "echo").is_some()
);
let image_tools = tool_search_output_tools(&requests[1], image_call_id);
assert!(
!image_tools.iter().any(|tool| {
tool.get("name").and_then(Value::as_str) == Some("mcp__rmcp")
&& tool.get("tools").and_then(Value::as_array)
.is_some_and(|tools| !tools.is_empty())
})
);这个断言证明 disabled tool 不会被索引,不是说搜索层会在命中后再次读取 MCP 配置。要排查“工具搜不到”,应先检查 runtime 是否注册、exposure 是否 Deferred,再检查 search_text。
6.3 App-only边界
App-only 工具即使与同一 connector 的普通工具共存,也不能因为 namespace 命中而被搜索返回。测试先搜索公开的 calendar 工具,再强制模型调用 app-only 名称;搜索结果不包含 app-only 工具,直接调用则产生 unsupported call,且没有到达 MCP server。
这条边界说明搜索结果不是权限提升机制。ToolSearch 只暴露 registry 已声明的模型面,不能把 client-only 或隐藏工具变成模型可调用工具。
6.4 Namespace元数据
Responses Lite 可选地把 tool_namespaces_info 写入 Turn metadata,记录每个函数是否 direct、deferred、Code Mode 名称和 MCP source。 这份 metadata 来自最终 registry 与 model-visible specs,不是 BM25 search result,也不会改变 exposure。对于 deferred function,只有 native tool_search 可见且 runtime 有 immutable spec 或 search_info 时,metadata 才标记 deferred=true。
源码位置:codex-rs/core/src/tools/tool_namespaces_info.rs :: collect_tool_namespaces_info
let deferred = exposure.is_deferred()
&& native_tool_search_visible
&& (runtime.immutable_spec().is_some()
|| runtime.search_info().is_some());
TurnToolFunctionInfo {
name: function_name.to_string(),
direct,
code_mode_name,
deferred,
source: match runtime.mcp_server_name() {
Some(server_name) => TurnToolSource::Mcp {
server_name: server_name.to_string(),
},
None => TurnToolSource::Harness,
},
}它与 DeferredToolWorldState 的来源说明属于相邻通道:一个帮助 Responses 请求理解 namespace ownership,另一个控制 tool_search 描述是否列出来源;都不能替代 tool_search_output 中的可加载 spec。
7. 异常路径
7.1 参数错误
空查询和零 limit 是模型输入错误,会返回可见错误;不支持的 payload 属于 runtime wiring 错误,handler 返回 Fatal。二者不能合并成“搜索失败”:前者允许模型修正参数,后者意味着 router 或调用来源违反了工具契约。
源码位置:codex-rs/core/src/tools/handlers/tool_search.rs :: ToolSearchHandler::handle_call
let args = match payload {
ToolPayload::ToolSearch { arguments } => arguments,
_ => {
return Err(FunctionCallError::Fatal(format!(
"{TOOL_SEARCH_TOOL_NAME} handler received unsupported payload"
)));
}
};
let query = args.query.trim();
if query.is_empty() {
return Err(FunctionCallError::RespondToModel(
"query must not be empty".to_string(),
));
}7.2 中断输出
工具执行被取消时,公共 AbortedToolOutput 对 ToolPayload::ToolSearch 仍构造一个 execution=client、status=completed、空 tools 的 output,而不是把普通文本错误塞进 function call output。这样模型历史中的 ToolSearch call 始终有结构匹配的 output。
源码位置:codex-rs/core/src/tools/context.rs :: AbortedToolOutput::to_response_item
match payload {
ToolPayload::ToolSearch { .. } => ResponseInputItem::ToolSearchOutput {
call_id: call_id.to_string(),
status: "completed".to_string(),
execution: "client".to_string(),
tools: Vec::new(),
},
_ => function_tool_response(
call_id,
payload,
vec![FunctionCallOutputContentItem::InputText {
text: self.message.clone(),
}],
None,
),
}这保证的是协议配对,不保证模型能从空数组知道取消原因。取消原因属于外围 turn 状态和事件流,搜索 output 本身只提供空候选。
7.3 历史恢复
压缩或恢复时,tool_search_output 仍必须与 call id 关系一致;如果历史中保留了没有 call id 的 output,则规范化按旧兼容规则保留,但它不应被新的本地 dispatch 当作一次可执行调用。恢复后的下一 sampling 仍由当前 router 重新收集 Deferred runtime,并可能因为 world state 改变而生成不同索引。
8. 源码练习
先复述这条主线:Deferred runtime 注册 → search_info 生成索引文本和可加载规格 → router 根据 model/provider 能力挂载缓存 handler → ToolSearchCall 解析 → BM25 命中并合并 namespace → ToolSearchOutput 进入历史 → 后续 function call 回到原 registry runtime。
再做两个只读验证:
- 在
core/src/tools/handlers/tool_search.rs中对比cache_reuses_immutable_handlers_and_rebuilds_for_current_registry_changes与cache_rechecks_dynamic_tool_metadata_while_reusing_immutable_mcp_handlers,说明 Weak identity 和 metadata equality 分别保护什么; - 在
core/tests/suite/search_tool.rs中找到tool_search_returns_deferred_tools_without_follow_up_tool_injection,指出哪两个断言证明命中结果依赖历史 output,而不是把工具追加到下一次tools数组。
在 Codex 源码仓库根目录运行:
rg -n "ToolSearchHandlerCache|ToolSearchInfo|tool_search_returns_deferred_tools_without_follow_up_tool_injection" codex-rs/core codex-rs/tools
RUST_MIN_STACK=16777216 cargo test -p codex-core --lib tools::handlers::tool_search::tests -- --nocapture
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all suite::search_tool::tool_search_returns_deferred_tools_without_follow_up_tool_injection -- --exact --nocapture第一个测试命令覆盖 handler cache 与 namespace coalesce 单元测试;第二个测试命令覆盖真实 Responses round-trip。测试依赖 mock provider,不能证明未配置的真实 Apps/MCP 服务一定可连接;但它能验证“搜索结果进入历史、后续调用仍由原 runtime 执行”这条核心关系。
