diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 06c826c158..17d544473b 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -19660,6 +19660,111 @@ ], "type": "object" }, + "ToolSearchInspectOutputTool": { + "properties": { + "namespace": { + "type": [ + "string", + "null" + ] + }, + "toolNames": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "toolNames" + ], + "type": "object" + }, + "ToolSearchInspectResult": { + "properties": { + "index": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "rank": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "score": { + "format": "float", + "type": [ + "number", + "null" + ] + }, + "searchableText": { + "type": "string" + }, + "source": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ToolSearchInspectSource" + }, + { + "type": "null" + } + ] + }, + "tools": { + "items": { + "$ref": "#/definitions/v2/ToolSearchInspectTool" + }, + "type": "array" + } + }, + "required": [ + "index", + "rank", + "searchableText", + "tools" + ], + "type": "object" + }, + "ToolSearchInspectSource": { + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "ToolSearchInspectTool": { + "properties": { + "canonicalName": { + "type": "string" + }, + "name": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "canonicalName", + "name" + ], + "type": "object" + }, "ToolsV2": { "properties": { "web_search": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index b5d882644c..a0e29fea97 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -17439,6 +17439,111 @@ ], "type": "object" }, + "ToolSearchInspectOutputTool": { + "properties": { + "namespace": { + "type": [ + "string", + "null" + ] + }, + "toolNames": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "toolNames" + ], + "type": "object" + }, + "ToolSearchInspectResult": { + "properties": { + "index": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "rank": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "score": { + "format": "float", + "type": [ + "number", + "null" + ] + }, + "searchableText": { + "type": "string" + }, + "source": { + "anyOf": [ + { + "$ref": "#/definitions/ToolSearchInspectSource" + }, + { + "type": "null" + } + ] + }, + "tools": { + "items": { + "$ref": "#/definitions/ToolSearchInspectTool" + }, + "type": "array" + } + }, + "required": [ + "index", + "rank", + "searchableText", + "tools" + ], + "type": "object" + }, + "ToolSearchInspectSource": { + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "ToolSearchInspectTool": { + "properties": { + "canonicalName": { + "type": "string" + }, + "name": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "canonicalName", + "name" + ], + "type": "object" + }, "ToolsV2": { "properties": { "web_search": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ToolSearchInspectOutputTool.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ToolSearchInspectOutputTool.ts new file mode 100644 index 0000000000..8f5547c770 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ToolSearchInspectOutputTool.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ToolSearchInspectOutputTool = { namespace: string | null, toolNames: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ToolSearchInspectResult.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ToolSearchInspectResult.ts new file mode 100644 index 0000000000..42ebb5b19e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ToolSearchInspectResult.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ToolSearchInspectSource } from "./ToolSearchInspectSource"; +import type { ToolSearchInspectTool } from "./ToolSearchInspectTool"; + +export type ToolSearchInspectResult = { rank: number, index: number, score: number | null, source: ToolSearchInspectSource | null, tools: Array, searchableText: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ToolSearchInspectSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ToolSearchInspectSource.ts new file mode 100644 index 0000000000..ef8c636f10 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ToolSearchInspectSource.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ToolSearchInspectSource = { name: string, description: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ToolSearchInspectTool.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ToolSearchInspectTool.ts new file mode 100644 index 0000000000..d35332d52b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ToolSearchInspectTool.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ToolSearchInspectTool = { namespace: string | null, name: string, canonicalName: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 57c08391c7..5dc1b949d1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -466,6 +466,10 @@ export type { ToolRequestUserInputOption } from "./ToolRequestUserInputOption"; export type { ToolRequestUserInputParams } from "./ToolRequestUserInputParams"; export type { ToolRequestUserInputQuestion } from "./ToolRequestUserInputQuestion"; export type { ToolRequestUserInputResponse } from "./ToolRequestUserInputResponse"; +export type { ToolSearchInspectOutputTool } from "./ToolSearchInspectOutputTool"; +export type { ToolSearchInspectResult } from "./ToolSearchInspectResult"; +export type { ToolSearchInspectSource } from "./ToolSearchInspectSource"; +export type { ToolSearchInspectTool } from "./ToolSearchInspectTool"; export type { ToolsV2 } from "./ToolsV2"; export type { Turn } from "./Turn"; export type { TurnCompletedNotification } from "./TurnCompletedNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 51a37fbaa9..c9ff7d8898 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -961,6 +961,12 @@ client_request_definitions! { serialization: global("mcp-registry"), response: v2::ListMcpServerStatusResponse, }, + #[experimental("toolSearch/inspect")] + ToolSearchInspect => "toolSearch/inspect" { + params: v2::ToolSearchInspectParams, + serialization: thread_id(params.thread_id), + response: v2::ToolSearchInspectResponse, + }, McpResourceRead => "mcpServer/resource/read" { params: v2::McpResourceReadParams, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs index 1097b03811..581a2a6750 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs @@ -24,6 +24,7 @@ mod remote_control; mod review; mod thread; mod thread_data; +mod tool_search; mod turn; mod windows_sandbox; @@ -52,6 +53,7 @@ pub use review::*; pub use shared::*; pub use thread::*; pub use thread_data::*; +pub use tool_search::*; pub use turn::*; pub use windows_sandbox::*; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tool_search.rs b/codex-rs/app-server-protocol/src/protocol/v2/tool_search.rs new file mode 100644 index 0000000000..367a96740d --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/tool_search.rs @@ -0,0 +1,65 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ToolSearchInspectParams { + pub thread_id: String, + pub query: String, + /// Optional result limit. Defaults to the runtime `tool_search` default. + #[ts(optional = nullable)] + pub limit: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ToolSearchInspectResponse { + pub indexed_tool_count: u32, + pub matching_tool_count: u32, + pub requested_limit: u32, + pub effective_limit: u32, + pub top_k_truncated: bool, + pub results: Vec, + pub output_tools: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ToolSearchInspectResult { + pub rank: u32, + pub index: u32, + pub score: Option, + pub source: Option, + pub tools: Vec, + pub searchable_text: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ToolSearchInspectSource { + pub name: String, + pub description: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ToolSearchInspectTool { + pub namespace: Option, + pub name: String, + pub canonical_name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ToolSearchInspectOutputTool { + pub namespace: Option, + pub tool_names: Vec, +} diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 17f34a7b29..3600de18ca 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1382,6 +1382,9 @@ impl MessageProcessor { .mcp_server_status_list(&request_id, params) .await } + ClientRequest::ToolSearchInspect { params, .. } => { + self.mcp_processor.tool_search_inspect(params).await + } ClientRequest::McpResourceRead { params, .. } => { self.mcp_processor .mcp_resource_read(&request_id, params) diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 509cbeb2e4..461559c0cf 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -266,6 +266,12 @@ use codex_app_server_protocol::ThreadUnarchivedNotification; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; use codex_app_server_protocol::ThreadUnsubscribeStatus; +use codex_app_server_protocol::ToolSearchInspectOutputTool; +use codex_app_server_protocol::ToolSearchInspectParams; +use codex_app_server_protocol::ToolSearchInspectResponse; +use codex_app_server_protocol::ToolSearchInspectResult; +use codex_app_server_protocol::ToolSearchInspectSource; +use codex_app_server_protocol::ToolSearchInspectTool; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnEnvironmentParams; use codex_app_server_protocol::TurnError; @@ -449,6 +455,7 @@ use codex_thread_store::ThreadMetadataPatch as StoreThreadMetadataPatch; use codex_thread_store::ThreadSortKey as StoreThreadSortKey; use codex_thread_store::ThreadStore; use codex_thread_store::ThreadStoreError; +use codex_tools::TOOL_SEARCH_DEFAULT_LIMIT; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use std::collections::BTreeMap; diff --git a/codex-rs/app-server/src/request_processors/mcp_processor.rs b/codex-rs/app-server/src/request_processors/mcp_processor.rs index 08d464598b..da30e6cd4d 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -53,6 +53,15 @@ impl McpRequestProcessor { .map(|()| None) } + pub(crate) async fn tool_search_inspect( + &self, + params: ToolSearchInspectParams, + ) -> Result, JSONRPCErrorError> { + self.tool_search_inspect_response(params) + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn mcp_resource_read( &self, request_id: &ConnectionRequestId, @@ -109,6 +118,32 @@ impl McpRequestProcessor { Ok((thread_id, thread)) } + async fn tool_search_inspect_response( + &self, + params: ToolSearchInspectParams, + ) -> Result { + let ToolSearchInspectParams { + thread_id, + query, + limit, + } = params; + let query = query.trim(); + if query.is_empty() { + return Err(invalid_request("query must not be empty")); + } + let limit = limit.unwrap_or(TOOL_SEARCH_DEFAULT_LIMIT as u32); + if limit == 0 { + return Err(invalid_request("limit must be greater than zero")); + } + + let (_, thread) = self.load_thread(&thread_id).await?; + let inspection = thread + .inspect_tool_search(query, limit as usize) + .await + .map_err(|err| internal_error(format!("failed to inspect tool_search: {err}")))?; + tool_search_inspection_to_response(inspection) + } + async fn mcp_server_oauth_login_response( &self, params: McpServerOauthLoginParams, @@ -466,3 +501,63 @@ fn with_mcp_tool_call_thread_id_meta( other => other, } } + +fn tool_search_inspection_to_response( + inspection: codex_core::ToolSearchInspection, +) -> Result { + Ok(ToolSearchInspectResponse { + indexed_tool_count: usize_to_u32(inspection.indexed_tool_count, "indexed_tool_count")?, + matching_tool_count: usize_to_u32(inspection.matching_tool_count, "matching_tool_count")?, + requested_limit: usize_to_u32(inspection.requested_limit, "requested_limit")?, + effective_limit: usize_to_u32(inspection.effective_limit, "effective_limit")?, + top_k_truncated: inspection.top_k_truncated, + results: inspection + .results + .into_iter() + .map(tool_search_result_to_response) + .collect::, _>>()?, + output_tools: inspection + .output_tools + .into_iter() + .map(tool_search_output_tool_to_response) + .collect(), + }) +} + +fn tool_search_result_to_response( + result: codex_core::ToolSearchInspectionResult, +) -> Result { + Ok(ToolSearchInspectResult { + rank: usize_to_u32(result.rank, "rank")?, + index: usize_to_u32(result.index, "index")?, + score: result.score, + source: result.source.map(|source| ToolSearchInspectSource { + name: source.name, + description: source.description, + }), + tools: result + .tools + .into_iter() + .map(|tool| ToolSearchInspectTool { + namespace: tool.namespace, + name: tool.name, + canonical_name: tool.canonical_name, + }) + .collect(), + searchable_text: result.searchable_text, + }) +} + +fn tool_search_output_tool_to_response( + output_tool: codex_core::ToolSearchInspectionOutputTool, +) -> ToolSearchInspectOutputTool { + ToolSearchInspectOutputTool { + namespace: output_tool.namespace, + tool_names: output_tool.tool_names, + } +} + +fn usize_to_u32(value: usize, field: &str) -> Result { + u32::try_from(value) + .map_err(|_| internal_error(format!("tool_search inspection field {field} exceeds u32"))) +} diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index dccfd4a763..3f9dc33d36 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -635,6 +635,14 @@ impl CodexThread { .await } + pub async fn inspect_tool_search( + &self, + query: &str, + limit: usize, + ) -> CodexResult { + self.codex.session.inspect_tool_search(query, limit).await + } + pub fn enabled(&self, feature: Feature) -> bool { self.codex.enabled(feature) } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 519d11d1ca..f4481b1f3b 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -101,6 +101,12 @@ pub(crate) use skills::maybe_emit_implicit_skill_invocation; pub(crate) use skills::skills_load_input_from_config; mod stream_events_utils; pub use stream_events_utils::image_generation_artifact_path; +mod tool_search_debug; +pub use tool_search_debug::ToolSearchInspection; +pub use tool_search_debug::ToolSearchInspectionOutputTool; +pub use tool_search_debug::ToolSearchInspectionResult; +pub use tool_search_debug::ToolSearchInspectionSource; +pub use tool_search_debug::ToolSearchInspectionTool; pub mod test_support; mod unified_exec; pub mod windows_sandbox; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 9db1df0449..2de5994e8c 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -45,6 +45,7 @@ use crate::session::turn_context::TurnEnvironment; use crate::session_prefix::format_inter_agent_completion_message; use crate::skills::SkillRenderSideEffects; use crate::skills_load_input_from_config; +use crate::tool_search_debug::ToolSearchInspection; use crate::turn_metadata::TurnMetadataState; use crate::turn_timing::now_unix_timestamp_ms; use async_channel::Receiver; @@ -1983,6 +1984,26 @@ impl Session { .map(|task| Arc::clone(&task.turn_context)) } + pub async fn inspect_tool_search( + &self, + query: &str, + limit: usize, + ) -> CodexResult { + let active = self.active_turn_context_and_cancellation_token().await; + let (turn_context, cancellation_token) = match active { + Some(active) => active, + None => (self.new_default_turn().await, CancellationToken::new()), + }; + let step_context = + StepContext::new(Arc::clone(&turn_context), turn_context.environments.clone()); + let router = + crate::session::turn::built_tools(self, &step_context, &cancellation_token).await?; + + router.inspect_tool_search(query, limit).ok_or_else(|| { + CodexErr::InvalidRequest("tool_search is not available for this thread".to_string()) + }) + } + async fn active_turn_context_and_cancellation_token( &self, ) -> Option<(Arc, CancellationToken)> { diff --git a/codex-rs/core/src/tool_search_debug.rs b/codex-rs/core/src/tool_search_debug.rs new file mode 100644 index 0000000000..a476c2f711 --- /dev/null +++ b/codex-rs/core/src/tool_search_debug.rs @@ -0,0 +1,39 @@ +#[derive(Debug, Clone, PartialEq)] +pub struct ToolSearchInspection { + pub indexed_tool_count: usize, + pub matching_tool_count: usize, + pub requested_limit: usize, + pub effective_limit: usize, + pub top_k_truncated: bool, + pub results: Vec, + pub output_tools: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ToolSearchInspectionResult { + pub rank: usize, + pub index: usize, + pub score: Option, + pub source: Option, + pub tools: Vec, + pub searchable_text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolSearchInspectionSource { + pub name: String, + pub description: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolSearchInspectionTool { + pub namespace: Option, + pub name: String, + pub canonical_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolSearchInspectionOutputTool { + pub namespace: Option, + pub tool_names: Vec, +} diff --git a/codex-rs/core/src/tools/handlers/tool_search.rs b/codex-rs/core/src/tools/handlers/tool_search.rs index 272da2f226..48bf11438b 100644 --- a/codex-rs/core/src/tools/handlers/tool_search.rs +++ b/codex-rs/core/src/tools/handlers/tool_search.rs @@ -11,6 +11,7 @@ use bm25::Language; use bm25::SearchEngine; use bm25::SearchEngineBuilder; use codex_tools::LoadableToolSpec; +use codex_tools::ResponsesApiNamespaceTool; use codex_tools::TOOL_SEARCH_DEFAULT_LIMIT; use codex_tools::TOOL_SEARCH_TOOL_NAME; use codex_tools::ToolName; @@ -22,6 +23,12 @@ use std::sync::Arc; use std::sync::Mutex; use tracing::instrument; +use crate::tool_search_debug::ToolSearchInspection; +use crate::tool_search_debug::ToolSearchInspectionOutputTool; +use crate::tool_search_debug::ToolSearchInspectionResult; +use crate::tool_search_debug::ToolSearchInspectionSource; +use crate::tool_search_debug::ToolSearchInspectionTool; + pub struct ToolSearchHandler { search_infos: Vec, spec: ToolSpec, @@ -152,7 +159,56 @@ impl ToolSearchHandler { } } -impl CoreToolRuntime for ToolSearchHandler {} +impl ToolSearchHandler { + fn inspect(&self, query: &str, limit: usize) -> ToolSearchInspection { + let results = self.search_engine.search(query, None); + let matching_tool_count = results.len(); + let effective_limit = limit.min(matching_tool_count); + let result_entries = results + .iter() + .take(effective_limit) + .enumerate() + .filter_map(|(rank, result)| { + let search_info = self.search_infos.get(result.document.id)?; + Some(inspect_result( + rank + 1, + result.document.id, + result.score, + search_info, + )) + }) + .collect::>(); + let output_tools = self + .search_output_tools( + results + .iter() + .take(effective_limit) + .map(|result| result.document.id) + .filter_map(|id| self.search_infos.get(id)) + .map(|search_info| &search_info.entry), + ) + .unwrap_or_default() + .iter() + .map(inspect_output_tool) + .collect(); + + ToolSearchInspection { + indexed_tool_count: self.search_infos.len(), + matching_tool_count, + requested_limit: limit, + effective_limit, + top_k_truncated: matching_tool_count > limit, + results: result_entries, + output_tools, + } + } +} + +impl CoreToolRuntime for ToolSearchHandler { + fn inspect_tool_search(&self, query: &str, limit: usize) -> Option { + Some(self.inspect(query, limit)) + } +} impl ToolSearchHandler { fn search( @@ -180,6 +236,70 @@ impl ToolSearchHandler { } } +fn inspect_result( + rank: usize, + index: usize, + score: f32, + search_info: &ToolSearchInfo, +) -> ToolSearchInspectionResult { + ToolSearchInspectionResult { + rank, + index, + score: score.is_finite().then_some(score), + source: search_info + .source_info + .as_ref() + .map(|source_info| ToolSearchInspectionSource { + name: source_info.name.clone(), + description: source_info.description.clone(), + }), + tools: inspect_entry_tools(&search_info.entry.output), + searchable_text: search_info.entry.search_text.clone(), + } +} + +fn inspect_entry_tools(output: &LoadableToolSpec) -> Vec { + match output { + LoadableToolSpec::Function(tool) => vec![ToolSearchInspectionTool { + namespace: None, + name: tool.name.clone(), + canonical_name: tool.name.clone(), + }], + LoadableToolSpec::Namespace(namespace) => namespace + .tools + .iter() + .map(|tool| { + let ResponsesApiNamespaceTool::Function(tool) = tool; + ToolSearchInspectionTool { + namespace: Some(namespace.name.clone()), + name: tool.name.clone(), + canonical_name: format!("{}.{}", namespace.name, tool.name), + } + }) + .collect(), + } +} + +fn inspect_output_tool(output: &LoadableToolSpec) -> ToolSearchInspectionOutputTool { + match output { + LoadableToolSpec::Function(tool) => ToolSearchInspectionOutputTool { + namespace: None, + tool_names: vec![tool.name.clone()], + }, + LoadableToolSpec::Namespace(namespace) => ToolSearchInspectionOutputTool { + namespace: Some(namespace.name.clone()), + tool_names: namespace + .tools + .iter() + .map(|tool| { + let ResponsesApiNamespaceTool::Function(tool) = tool; + tool.name.clone() + }) + .collect(), + }, + } +} + #[cfg(test)] mod tests { use super::*; @@ -325,7 +445,154 @@ mod tests { ); } + #[test] + fn inspect_reports_broad_query_top_k_truncation_and_omission() { + let mut search_infos = (0..10) + .map(|index| { + McpHandler::new(tool_info( + &format!("distractor_{index}"), + "semantic_match", + "OpenAI Topics list topics", + )) + .expect("MCP tool should convert") + .search_info() + .expect("MCP handler should return search info") + }) + .collect::>(); + search_infos.push( + McpHandler::new(tool_info( + "openai_topics", + "list_topics", + "Connector metadata", + )) + .expect("MCP tool should convert") + .search_info() + .expect("MCP handler should return search info"), + ); + let handler = ToolSearchHandler::new(search_infos); + + let inspection = handler.inspect("OpenAI Topics list topics", 8); + + assert_eq!(inspection.indexed_tool_count, 11); + assert_eq!(inspection.matching_tool_count, 10); + assert_eq!(inspection.requested_limit, 8); + assert_eq!(inspection.effective_limit, 8); + assert!(inspection.top_k_truncated); + assert_eq!(inspection.results.len(), 8); + assert!( + inspection + .results + .iter() + .all(|result| result.tools[0].canonical_name != "mcp__openai_topics.list_topics") + ); + } + + #[test] + fn inspect_named_openai_topics_query_recovers_target_first() { + let search_infos = vec![ + McpHandler::new(tool_info("calendar", "list_events", "Calendar events")) + .expect("MCP tool should convert") + .search_info() + .expect("MCP handler should return search info"), + McpHandler::new(tool_info( + "openai_topics", + "list_topics", + "List OpenAI topics", + )) + .expect("MCP tool should convert") + .search_info() + .expect("MCP handler should return search info"), + ]; + let handler = ToolSearchHandler::new(search_infos); + + let inspection = handler.inspect("list topics", 8); + + assert_eq!( + inspection.results[0].tools[0].canonical_name, + "mcp__openai_topics.list_topics" + ); + assert_eq!(inspection.results[0].rank, 1); + assert!(inspection.results[0].score.is_some()); + } + + #[test] + fn inspect_output_tools_match_namespace_coalescing() { + let search_infos = vec![ + McpHandler::new(tool_info("calendar", "create_event", "events")) + .expect("MCP tool should convert") + .search_info() + .expect("MCP handler should return search info"), + McpHandler::new(tool_info("calendar", "list_events", "events")) + .expect("MCP tool should convert") + .search_info() + .expect("MCP handler should return search info"), + ]; + let handler = ToolSearchHandler::new(search_infos); + + let inspection = handler.inspect("events", 2); + let mut tool_names = inspection.output_tools[0].tool_names.clone(); + tool_names.sort(); + + assert_eq!( + inspection.output_tools[0].namespace, + Some("mcp__calendar".to_string()) + ); + assert_eq!( + tool_names, + vec!["create_event".to_string(), "list_events".to_string()] + ); + } + + #[test] + fn inspect_exposes_metadata_without_arguments_or_results() { + let search_infos = vec![ + McpHandler::new(tool_info_with_schema( + "secrets", + "lookup", + "Lookup metadata", + serde_json::json!({ + "type": "object", + "properties": { + "api_token": { "type": "string" } + }, + "additionalProperties": false, + }), + )) + .expect("MCP tool should convert") + .search_info() + .expect("MCP handler should return search info"), + ]; + let handler = ToolSearchHandler::new(search_infos); + + let inspection = handler.inspect("api_token", 8); + let debug = format!("{inspection:?}"); + + assert!(inspection.results[0].searchable_text.contains("api_token")); + assert!(!debug.contains("arguments:")); + assert!(!debug.contains("tool_input")); + assert!(!debug.contains("tool_response")); + assert!(!debug.contains("secret-token-value")); + } + fn tool_info(server_name: &str, tool_name: &str, description_prefix: &str) -> ToolInfo { + tool_info_with_schema( + server_name, + tool_name, + description_prefix, + serde_json::json!({ + "type": "object", + "properties": {}, + "additionalProperties": false, + }), + ) + } + + fn tool_info_with_schema( + server_name: &str, + tool_name: &str, + description_prefix: &str, + input_schema: serde_json::Value, + ) -> ToolInfo { ToolInfo { server_name: server_name.to_string(), supports_parallel_tool_calls: false, @@ -336,11 +603,7 @@ mod tests { 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, - }))), + Arc::new(rmcp::model::object(input_schema)), ), connector_id: None, connector_name: None, diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 2220d4244a..86d5e7fa38 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -13,6 +13,7 @@ use crate::memory_usage::emit_metric_for_tool_read; use crate::sandbox_tags::permission_profile_policy_tag; use crate::sandbox_tags::permission_profile_sandbox_tag; use crate::session::turn_context::TurnContext; +use crate::tool_search_debug::ToolSearchInspection; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; @@ -142,6 +143,10 @@ pub(crate) trait CoreToolRuntime: ToolExecutor { fn create_diff_consumer(&self) -> Option> { None } + + fn inspect_tool_search(&self, _query: &str, _limit: usize) -> Option { + None + } } /// Consumes streamed argument diffs for a tool call and emits protocol events @@ -317,6 +322,10 @@ impl CoreToolRuntime for ExposureOverride { fn create_diff_consumer(&self) -> Option> { self.handler.create_diff_consumer() } + + fn inspect_tool_search(&self, query: &str, limit: usize) -> Option { + self.handler.inspect_tool_search(query, limit) + } } pub struct ToolRegistry { @@ -389,6 +398,15 @@ impl ToolRegistry { Some(tool.waits_for_runtime_cancellation()) } + pub(crate) fn inspect_tool_search( + &self, + name: &ToolName, + query: &str, + limit: usize, + ) -> Option { + self.tool(name)?.inspect_tool_search(query, limit) + } + #[allow(dead_code)] pub(crate) async fn dispatch_any( &self, diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 4bdd121f6e..12a2746202 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -1,6 +1,7 @@ use crate::function_tool::FunctionCallError; use crate::session::session::Session; use crate::session::step_context::StepContext; +use crate::tool_search_debug::ToolSearchInspection; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; @@ -14,6 +15,7 @@ use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::ResponseItem; use codex_protocol::models::SearchToolCallParams; use codex_tools::DiscoverableTool; +use codex_tools::TOOL_SEARCH_TOOL_NAME; use codex_tools::ToolCall as ExtensionToolCall; use codex_tools::ToolExecutor; use codex_tools::ToolName; @@ -109,6 +111,15 @@ impl ToolRouter { .unwrap_or(false) } + pub(crate) fn inspect_tool_search( + &self, + query: &str, + limit: usize, + ) -> Option { + self.registry + .inspect_tool_search(&ToolName::plain(TOOL_SEARCH_TOOL_NAME), query, limit) + } + #[instrument(level = "trace", skip_all, err)] pub fn build_tool_call(item: ResponseItem) -> Result, FunctionCallError> { match item {