Skip to content

ToolSearch工具

从 Deferred 工具索引、BM25 查询到 tool_search_output 历史,追踪延迟工具如何被发现并继续执行。

基于rust-v0.150.0
CodexRustToolsSearch

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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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 源码仓库根目录运行:

bash
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 执行”这条核心关系。