Codex Handbook
core/src/tools/handlers/tool_search.rs 343 lines
use crate::function_tool::FunctionCallError;use crate::tools::context::ToolInvocation;use crate::tools::context::ToolPayload;use crate::tools::context::ToolSearchOutput;use crate::tools::context::boxed_tool_output;use crate::tools::handlers::tool_search_spec::create_tool_search_tool;use crate::tools::registry::CoreToolRuntime;use crate::tools::registry::ToolExecutor;use bm25::Document;use bm25::Language;use bm25::SearchEngine;use bm25::SearchEngineBuilder;use codex_tools::LoadableToolSpec;use codex_tools::TOOL_SEARCH_DEFAULT_LIMIT;use codex_tools::TOOL_SEARCH_TOOL_NAME;use codex_tools::ToolName;use codex_tools::ToolSearchEntry;use codex_tools::ToolSearchInfo;use codex_tools::ToolSpec;use codex_tools::coalesce_loadable_tool_specs;use std::sync::Arc;use std::sync::Mutex;pub struct ToolSearchHandler {    search_infos: Vec<ToolSearchInfo>,    spec: ToolSpec,    search_engine: SearchEngine<usize>,}#[derive(Default)]pub(crate) struct ToolSearchHandlerCache {    cached: Mutex<Option<Arc<ToolSearchHandler>>>,}impl ToolSearchHandlerCache {    pub(crate) fn get_or_build(&self, search_infos: Vec<ToolSearchInfo>) -> Arc<ToolSearchHandler> {        {            let cached = self.cached();            if let Some(cached) = cached.as_ref()                && cached.search_infos == search_infos            {                return Arc::clone(cached);            }        }        let handler = Arc::new(ToolSearchHandler::new(search_infos));        let mut cached = self.cached();        if let Some(cached) = cached.as_ref()            && cached.search_infos == handler.search_infos        {            return Arc::clone(cached);        }        *cached = Some(Arc::clone(&handler));        handler    }    fn cached(&self) -> std::sync::MutexGuard<'_, Option<Arc<ToolSearchHandler>>> {        match self.cached.lock() {            Ok(cached) => cached,            Err(poisoned) => poisoned.into_inner(),        }    }}impl ToolSearchHandler {    pub(crate) fn new(search_infos: Vec<ToolSearchInfo>) -> Self {        let search_source_infos = search_infos            .iter()            .filter_map(|search_info| search_info.source_info.clone())            .collect::<Vec<_>>();        let spec = create_tool_search_tool(&search_source_infos, TOOL_SEARCH_DEFAULT_LIMIT);        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();        Self {            search_infos,            spec,            search_engine,        }    }}impl ToolExecutor<ToolInvocation> for ToolSearchHandler {    fn tool_name(&self) -> ToolName {        ToolName::plain(TOOL_SEARCH_TOOL_NAME)    }    fn spec(&self) -> ToolSpec {        self.spec.clone()    }    fn supports_parallel_tool_calls(&self) -> bool {        true    }    fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {        Box::pin(self.handle_call(invocation))    }}impl ToolSearchHandler {    async fn handle_call(        &self,        invocation: ToolInvocation,    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {        let ToolInvocation { payload, .. } = invocation;        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(),            ));        }        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() }));        }        let tools = self.search(query, limit)?;        Ok(boxed_tool_output(ToolSearchOutput { tools }))    }}impl CoreToolRuntime for ToolSearchHandler {}impl ToolSearchHandler {    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()),        ))    }}#[cfg(test)]mod tests {    use super::*;    use crate::tools::handlers::DynamicToolHandler;    use crate::tools::handlers::McpHandler;    use codex_mcp::ToolInfo;    use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;    use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;    use codex_tools::ResponsesApiNamespace;    use codex_tools::ResponsesApiNamespaceTool;    use codex_tools::ResponsesApiTool;    use pretty_assertions::assert_eq;    use rmcp::model::Tool;    use std::sync::Arc;    #[test]    fn cache_reuses_handler_for_identical_search_infos_and_rebuilds_for_changes() {        let cache = ToolSearchHandlerCache::default();        let search_infos = vec![            McpHandler::new(tool_info("calendar", "create_event", "Create events"))                .expect("MCP tool should convert")                .search_info()                .expect("MCP handler should return search info"),        ];        let first = cache.get_or_build(search_infos.clone());        let second = cache.get_or_build(search_infos.clone());        assert!(Arc::ptr_eq(&first, &second));        let mut changed_search_infos = search_infos;        changed_search_infos[0]            .entry            .search_text            .push_str(" changed");        let changed = cache.get_or_build(changed_search_infos);        assert!(!Arc::ptr_eq(&first, &changed));    }    #[test]    fn mixed_search_results_coalesce_mcp_namespaces() {        let dynamic_namespace = DynamicToolNamespaceSpec {            name: "codex_app".to_string(),            description: "Tools in the codex_app namespace.".to_string(),            tools: Vec::new(),        };        let dynamic_tools = [DynamicToolFunctionSpec {            name: "automation_update".to_string(),            description: "Create, update, view, or delete recurring automations.".to_string(),            input_schema: serde_json::json!({                "type": "object",                "properties": {                    "mode": { "type": "string" },                },                "required": ["mode"],                "additionalProperties": false,            }),            defer_loading: true,        }];        let mcp_tools = [            tool_info("calendar", "create_event", "Create events"),            tool_info("calendar", "list_events", "List events"),        ];        let mut search_infos = mcp_tools            .iter()            .map(|tool| {                McpHandler::new(tool.clone())                    .expect("MCP tool should convert")                    .search_info()                    .expect("MCP handler should return search info")            })            .collect::<Vec<_>>();        search_infos.extend(dynamic_tools.iter().map(|tool| {            DynamicToolHandler::new_in_namespace(&dynamic_namespace, tool)                .expect("dynamic tool should convert")                .search_info()                .expect("dynamic handler should return search info")        }));        let handler = ToolSearchHandler::new(search_infos);        let results = [            &handler.search_infos[0].entry,            &handler.search_infos[2].entry,            &handler.search_infos[1].entry,        ];        let tools = handler            .search_output_tools(results)            .expect("mixed search output should serialize");        assert_eq!(            tools,            vec![                LoadableToolSpec::Namespace(ResponsesApiNamespace {                    name: "mcp__calendar".to_string(),                    description: "Tools in the mcp__calendar namespace.".to_string(),                    tools: vec![                        ResponsesApiNamespaceTool::Function(ResponsesApiTool {                            name: "create_event".to_string(),                            description: "Create events desktop tool".to_string(),                            strict: false,                            defer_loading: Some(true),                            parameters: codex_tools::JsonSchema::object(                                Default::default(),                                /*required*/ None,                                Some(false.into()),                            ),                            output_schema: None,                        }),                        ResponsesApiNamespaceTool::Function(ResponsesApiTool {                            name: "list_events".to_string(),                            description: "List events desktop tool".to_string(),                            strict: false,                            defer_loading: Some(true),                            parameters: codex_tools::JsonSchema::object(                                Default::default(),                                /*required*/ None,                                Some(false.into()),                            ),                            output_schema: None,                        }),                    ],                }),                LoadableToolSpec::Namespace(ResponsesApiNamespace {                    name: "codex_app".to_string(),                    description: "Tools in the codex_app namespace.".to_string(),                    tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {                        name: "automation_update".to_string(),                        description: "Create, update, view, or delete recurring automations."                            .to_string(),                        strict: false,                        defer_loading: Some(true),                        parameters: codex_tools::JsonSchema::object(                            std::collections::BTreeMap::from([(                                "mode".to_string(),                                codex_tools::JsonSchema::string(/*description*/ None),                            )]),                            Some(vec!["mode".to_string()]),                            Some(false.into()),                        ),                        output_schema: None,                    })],                }),            ],        );    }    fn tool_info(server_name: &str, tool_name: &str, description_prefix: &str) -> ToolInfo {        ToolInfo {            server_name: server_name.to_string(),            supports_parallel_tool_calls: false,            server_origin: None,            callable_name: tool_name.to_string(),            callable_namespace: format!("mcp__{server_name}"),            namespace_description: None,            tool: Tool::new(                tool_name.to_string(),                format!("{description_prefix} desktop tool"),                Arc::new(rmcp::model::object(serde_json::json!({                    "type": "object",                    "properties": {},                    "additionalProperties": false,                }))),            ),            connector_id: None,            connector_name: None,            plugin_display_names: Vec::new(),        }    }}