From 77b0c75267de29deac2dd648057dbf8820e4d35d Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Wed, 11 Mar 2026 17:51:51 -0700 Subject: [PATCH] feat: search_tool migrate to bring you own tool of Responses API (#14274) ## Why to support a new bring your own search tool in Responses API(https://developers.openai.com/api/docs/guides/tools-tool-search#client-executed-tool-search) we migrating our bm25 search tool to use official way to execute search on client and communicate additional tools to the model. ## What - replace the legacy `search_tool_bm25` flow with client-executed `tool_search` - add protocol, SSE, history, and normalization support for `tool_search_call` and `tool_search_output` - return namespaced Codex Apps search results and wire namespaced follow-up tool calls back into MCP dispatch --- .../schema/json/ClientRequest.json | 82 ++ .../schema/json/EventMsg.json | 82 ++ .../codex_app_server_protocol.schemas.json | 82 ++ .../codex_app_server_protocol.v2.schemas.json | 82 ++ .../RawResponseItemCompletedNotification.json | 82 ++ .../schema/json/v2/ThreadResumeParams.json | 82 ++ .../schema/typescript/ResponseItem.ts | 2 +- .../tests/suite/v2/mcp_server_elicitation.rs | 2 +- codex-rs/codex-api/src/requests/responses.rs | 1 + codex-rs/codex-api/src/sse/responses.rs | 36 + codex-rs/core/src/agent/control.rs | 3 + codex-rs/core/src/arc_monitor.rs | 3 + codex-rs/core/src/client_common.rs | 81 ++ codex-rs/core/src/codex.rs | 111 +- codex-rs/core/src/codex_tests.rs | 170 +-- codex-rs/core/src/codex_tests_guardian.rs | 2 + codex-rs/core/src/compact_remote.rs | 2 + codex-rs/core/src/connectors.rs | 136 +- codex-rs/core/src/context_manager/history.rs | 10 +- .../core/src/context_manager/history_tests.rs | 98 ++ .../core/src/context_manager/normalize.rs | 79 ++ codex-rs/core/src/guardian_tests.rs | 2 + codex-rs/core/src/mcp_connection_manager.rs | 127 +- codex-rs/core/src/mcp_tool_call.rs | 4 +- codex-rs/core/src/rollout/policy.rs | 4 + codex-rs/core/src/rollout/truncation.rs | 5 +- codex-rs/core/src/state/session.rs | 173 +-- codex-rs/core/src/stream_events_utils.rs | 15 +- codex-rs/core/src/thread_manager.rs | 1 + codex-rs/core/src/tools/code_mode.rs | 37 +- codex-rs/core/src/tools/context.rs | 105 ++ .../core/src/tools/handlers/apply_patch.rs | 1 + codex-rs/core/src/tools/handlers/mod.rs | 8 +- .../core/src/tools/handlers/multi_agents.rs | 1 + codex-rs/core/src/tools/handlers/plan.rs | 1 + .../src/tools/handlers/search_tool_bm25.rs | 349 ----- .../core/src/tools/handlers/tool_search.rs | 390 ++++++ codex-rs/core/src/tools/js_repl/mod.rs | 46 +- codex-rs/core/src/tools/parallel.rs | 6 + codex-rs/core/src/tools/registry.rs | 101 +- codex-rs/core/src/tools/router.rs | 87 +- codex-rs/core/src/tools/spec.rs | 315 ++++- codex-rs/core/src/turn_timing.rs | 3 + .../templates/search_tool/tool_description.md | 25 +- .../core/tests/common/apps_test_server.rs | 53 +- codex-rs/core/tests/common/responses.rs | 52 +- codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/plugins.rs | 10 +- codex-rs/core/tests/suite/search_tool.rs | 1119 +++-------------- codex-rs/otel/src/events/session_telemetry.rs | 2 + codex-rs/protocol/src/models.rs | 233 ++++ codex-rs/rmcp-client/src/rmcp_client.rs | 4 + 52 files changed, 2619 insertions(+), 1890 deletions(-) create mode 100644 codex-rs/core/src/tools/handlers/tool_search.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 4e7cc8827..7950007e0 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1474,6 +1474,12 @@ "name": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ "function_call" @@ -1491,6 +1497,47 @@ "title": "FunctionCallResponseItem", "type": "object" }, + { + "properties": { + "arguments": true, + "call_id": { + "type": [ + "string", + "null" + ] + }, + "execution": { + "type": "string" + }, + "id": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "tool_search_call" + ], + "title": "ToolSearchCallResponseItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "execution", + "type" + ], + "title": "ToolSearchCallResponseItem", + "type": "object" + }, { "properties": { "call_id": { @@ -1580,6 +1627,41 @@ "title": "CustomToolCallOutputResponseItem", "type": "object" }, + { + "properties": { + "call_id": { + "type": [ + "string", + "null" + ] + }, + "execution": { + "type": "string" + }, + "status": { + "type": "string" + }, + "tools": { + "items": true, + "type": "array" + }, + "type": { + "enum": [ + "tool_search_output" + ], + "title": "ToolSearchOutputResponseItemType", + "type": "string" + } + }, + "required": [ + "execution", + "status", + "tools", + "type" + ], + "title": "ToolSearchOutputResponseItem", + "type": "object" + }, { "properties": { "action": { diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index dbf9fc8e9..cbf6f7476 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -5286,6 +5286,12 @@ "name": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ "function_call" @@ -5303,6 +5309,47 @@ "title": "FunctionCallResponseItem", "type": "object" }, + { + "properties": { + "arguments": true, + "call_id": { + "type": [ + "string", + "null" + ] + }, + "execution": { + "type": "string" + }, + "id": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "tool_search_call" + ], + "title": "ToolSearchCallResponseItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "execution", + "type" + ], + "title": "ToolSearchCallResponseItem", + "type": "object" + }, { "properties": { "call_id": { @@ -5392,6 +5439,41 @@ "title": "CustomToolCallOutputResponseItem", "type": "object" }, + { + "properties": { + "call_id": { + "type": [ + "string", + "null" + ] + }, + "execution": { + "type": "string" + }, + "status": { + "type": "string" + }, + "tools": { + "items": true, + "type": "array" + }, + "type": { + "enum": [ + "tool_search_output" + ], + "title": "ToolSearchOutputResponseItemType", + "type": "string" + } + }, + "required": [ + "execution", + "status", + "tools", + "type" + ], + "title": "ToolSearchOutputResponseItem", + "type": "object" + }, { "properties": { "action": { 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 6b4729ace..39d50f12c 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 @@ -13801,6 +13801,12 @@ "name": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ "function_call" @@ -13818,6 +13824,47 @@ "title": "FunctionCallResponseItem", "type": "object" }, + { + "properties": { + "arguments": true, + "call_id": { + "type": [ + "string", + "null" + ] + }, + "execution": { + "type": "string" + }, + "id": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "tool_search_call" + ], + "title": "ToolSearchCallResponseItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "execution", + "type" + ], + "title": "ToolSearchCallResponseItem", + "type": "object" + }, { "properties": { "call_id": { @@ -13907,6 +13954,41 @@ "title": "CustomToolCallOutputResponseItem", "type": "object" }, + { + "properties": { + "call_id": { + "type": [ + "string", + "null" + ] + }, + "execution": { + "type": "string" + }, + "status": { + "type": "string" + }, + "tools": { + "items": true, + "type": "array" + }, + "type": { + "enum": [ + "tool_search_output" + ], + "title": "ToolSearchOutputResponseItemType", + "type": "string" + } + }, + "required": [ + "execution", + "status", + "tools", + "type" + ], + "title": "ToolSearchOutputResponseItem", + "type": "object" + }, { "properties": { "action": { 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 793ed16a8..85eb8eee1 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 @@ -10411,6 +10411,12 @@ "name": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ "function_call" @@ -10428,6 +10434,47 @@ "title": "FunctionCallResponseItem", "type": "object" }, + { + "properties": { + "arguments": true, + "call_id": { + "type": [ + "string", + "null" + ] + }, + "execution": { + "type": "string" + }, + "id": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "tool_search_call" + ], + "title": "ToolSearchCallResponseItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "execution", + "type" + ], + "title": "ToolSearchCallResponseItem", + "type": "object" + }, { "properties": { "call_id": { @@ -10517,6 +10564,41 @@ "title": "CustomToolCallOutputResponseItem", "type": "object" }, + { + "properties": { + "call_id": { + "type": [ + "string", + "null" + ] + }, + "execution": { + "type": "string" + }, + "status": { + "type": "string" + }, + "tools": { + "items": true, + "type": "array" + }, + "type": { + "enum": [ + "tool_search_output" + ], + "title": "ToolSearchOutputResponseItemType", + "type": "string" + } + }, + "required": [ + "execution", + "status", + "tools", + "type" + ], + "title": "ToolSearchOutputResponseItem", + "type": "object" + }, { "properties": { "action": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index 94a6c8ba7..19f0fe34f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -496,6 +496,12 @@ "name": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ "function_call" @@ -513,6 +519,47 @@ "title": "FunctionCallResponseItem", "type": "object" }, + { + "properties": { + "arguments": true, + "call_id": { + "type": [ + "string", + "null" + ] + }, + "execution": { + "type": "string" + }, + "id": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "tool_search_call" + ], + "title": "ToolSearchCallResponseItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "execution", + "type" + ], + "title": "ToolSearchCallResponseItem", + "type": "object" + }, { "properties": { "call_id": { @@ -602,6 +649,41 @@ "title": "CustomToolCallOutputResponseItem", "type": "object" }, + { + "properties": { + "call_id": { + "type": [ + "string", + "null" + ] + }, + "execution": { + "type": "string" + }, + "status": { + "type": "string" + }, + "tools": { + "items": true, + "type": "array" + }, + "type": { + "enum": [ + "tool_search_output" + ], + "title": "ToolSearchOutputResponseItemType", + "type": "string" + } + }, + "required": [ + "execution", + "status", + "tools", + "type" + ], + "title": "ToolSearchOutputResponseItem", + "type": "object" + }, { "properties": { "action": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 191ff80a9..1b54a95a0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -554,6 +554,12 @@ "name": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ "function_call" @@ -571,6 +577,47 @@ "title": "FunctionCallResponseItem", "type": "object" }, + { + "properties": { + "arguments": true, + "call_id": { + "type": [ + "string", + "null" + ] + }, + "execution": { + "type": "string" + }, + "id": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "tool_search_call" + ], + "title": "ToolSearchCallResponseItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "execution", + "type" + ], + "title": "ToolSearchCallResponseItem", + "type": "object" + }, { "properties": { "call_id": { @@ -660,6 +707,41 @@ "title": "CustomToolCallOutputResponseItem", "type": "object" }, + { + "properties": { + "call_id": { + "type": [ + "string", + "null" + ] + }, + "execution": { + "type": "string" + }, + "status": { + "type": "string" + }, + "tools": { + "items": true, + "type": "array" + }, + "type": { + "enum": [ + "tool_search_output" + ], + "title": "ToolSearchOutputResponseItemType", + "type": "string" + } + }, + "required": [ + "execution", + "status", + "tools", + "type" + ], + "title": "ToolSearchOutputResponseItem", + "type": "object" + }, { "properties": { "action": { diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index dc4248593..2464037a5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -15,4 +15,4 @@ export type ResponseItem = { "type": "message", role: string, content: Array &str { match self { ToolSpec::Function(tool) => tool.name.as_str(), + ToolSpec::ToolSearch { .. } => "tool_search", ToolSpec::LocalShell {} => "local_shell", ToolSpec::ImageGeneration { .. } => "image_generation", ToolSpec::WebSearch { .. } => "web_search", @@ -268,10 +275,36 @@ pub(crate) mod tools { /// `required` and `additional_properties` must be present. All fields in /// `properties` must be present in `required`. pub(crate) strict: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) defer_loading: Option, pub(crate) parameters: JsonSchema, #[serde(skip)] pub(crate) output_schema: Option, } + + #[derive(Debug, Clone, Serialize, PartialEq)] + #[serde(tag = "type")] + pub(crate) enum ToolSearchOutputTool { + #[allow(dead_code)] + #[serde(rename = "function")] + Function(ResponsesApiTool), + #[serde(rename = "namespace")] + Namespace(ResponsesApiNamespace), + } + + #[derive(Debug, Clone, Serialize, PartialEq)] + pub(crate) struct ResponsesApiNamespace { + pub(crate) name: String, + pub(crate) description: String, + pub(crate) tools: Vec, + } + + #[derive(Debug, Clone, Serialize, PartialEq)] + #[serde(tag = "type")] + pub(crate) enum ResponsesApiNamespaceTool { + #[serde(rename = "function")] + Function(ResponsesApiTool), + } } pub struct ResponseStream { @@ -434,6 +467,7 @@ mod tests { ResponseItem::FunctionCall { id: None, name: "shell".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), }, @@ -462,6 +496,7 @@ mod tests { ResponseItem::FunctionCall { id: None, name: "shell".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), }, @@ -483,4 +518,50 @@ mod tests { ] ); } + + #[test] + fn tool_search_output_namespace_serializes_with_deferred_child_tools() { + let namespace = tools::ToolSearchOutputTool::Namespace(tools::ResponsesApiNamespace { + name: "mcp__codex_apps__calendar".to_string(), + description: "Plan events".to_string(), + tools: vec![tools::ResponsesApiNamespaceTool::Function( + tools::ResponsesApiTool { + name: "create_event".to_string(), + description: "Create a calendar event.".to_string(), + strict: false, + defer_loading: Some(true), + parameters: crate::tools::spec::JsonSchema::Object { + properties: Default::default(), + required: None, + additional_properties: None, + }, + output_schema: None, + }, + )], + }); + + let value = serde_json::to_value(namespace).expect("serialize namespace"); + + assert_eq!( + value, + serde_json::json!({ + "type": "namespace", + "name": "mcp__codex_apps__calendar", + "description": "Plan events", + "tools": [ + { + "type": "function", + "name": "create_event", + "description": "Create a calendar event.", + "strict": false, + "defer_loading": true, + "parameters": { + "type": "object", + "properties": {} + } + } + ] + }) + ); + } } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b2bbae4a7..2747cd070 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -207,8 +207,6 @@ use crate::mcp::maybe_prompt_and_install_mcp_dependencies; use crate::mcp::with_codex_apps_mcp; use crate::mcp_connection_manager::McpConnectionManager; use crate::mcp_connection_manager::codex_apps_tools_cache_key; -use crate::mcp_connection_manager::filter_codex_apps_mcp_tools_only; -use crate::mcp_connection_manager::filter_mcp_tools_by_name; use crate::mcp_connection_manager::filter_non_codex_apps_mcp_tools_only; use crate::memories; use crate::mentions::build_connector_slug_counts; @@ -287,7 +285,6 @@ use crate::tasks::SessionTask; use crate::tasks::SessionTaskContext; use crate::tools::ToolRouter; use crate::tools::context::SharedTurnDiffTracker; -use crate::tools::handlers::SEARCH_TOOL_BM25_TOOL_NAME; use crate::tools::js_repl::JsReplHandle; use crate::tools::js_repl::resolve_compatible_node; use crate::tools::network_approval::NetworkApprovalService; @@ -1880,26 +1877,6 @@ impl Session { } } - pub(crate) async fn merge_mcp_tool_selection(&self, tool_names: Vec) -> Vec { - let mut state = self.state.lock().await; - state.merge_mcp_tool_selection(tool_names) - } - - pub(crate) async fn set_mcp_tool_selection(&self, tool_names: Vec) { - let mut state = self.state.lock().await; - state.set_mcp_tool_selection(tool_names); - } - - pub(crate) async fn get_mcp_tool_selection(&self) -> Option> { - let state = self.state.lock().await; - state.get_mcp_tool_selection() - } - - pub(crate) async fn clear_mcp_tool_selection(&self) { - let mut state = self.state.lock().await; - state.clear_mcp_tool_selection(); - } - // Merges connector IDs into the session-level explicit connector selection. pub(crate) async fn merge_connector_selection( &self, @@ -1923,7 +1900,6 @@ impl Session { async fn record_initial_history(&self, conversation_history: InitialHistory) { let turn_context = self.new_default_turn().await; - self.clear_mcp_tool_selection().await; let is_subagent = { let state = self.state.lock().await; matches!( @@ -1939,8 +1915,6 @@ impl Session { } InitialHistory::Resumed(resumed_history) => { let rollout_items = resumed_history.history; - let restored_tool_selection = - Self::extract_mcp_tool_selection_from_rollout(&rollout_items); let reconstructed_rollout = self .reconstruct_history_from_rollout(&turn_context, &rollout_items) @@ -1986,9 +1960,6 @@ impl Session { let mut state = self.state.lock().await; state.set_token_info(Some(info)); } - if let Some(selected_tools) = restored_tool_selection { - self.set_mcp_tool_selection(selected_tools).await; - } // Defer seeding the session's initial context until the first turn starts so // turn/start overrides can be merged before we write to the rollout. @@ -1997,9 +1968,6 @@ impl Session { } } InitialHistory::Forked(rollout_items) => { - let restored_tool_selection = - Self::extract_mcp_tool_selection_from_rollout(&rollout_items); - let reconstructed_rollout = self .reconstruct_history_from_rollout(&turn_context, &rollout_items) .await; @@ -2027,9 +1995,6 @@ impl Session { let mut state = self.state.lock().await; state.set_token_info(Some(info)); } - if let Some(selected_tools) = restored_tool_selection { - self.set_mcp_tool_selection(selected_tools).await; - } // If persisting, persist all rollout items as-is (recorder filters) if !rollout_items.is_empty() { @@ -2063,54 +2028,6 @@ impl Session { }) } - fn extract_mcp_tool_selection_from_rollout( - rollout_items: &[RolloutItem], - ) -> Option> { - let mut search_call_ids = HashSet::new(); - let mut active_selected_tools: Option> = None; - - for item in rollout_items { - let RolloutItem::ResponseItem(response_item) = item else { - continue; - }; - match response_item { - ResponseItem::FunctionCall { name, call_id, .. } => { - if name == SEARCH_TOOL_BM25_TOOL_NAME { - search_call_ids.insert(call_id.clone()); - } - } - ResponseItem::FunctionCallOutput { call_id, output } => { - if !search_call_ids.contains(call_id) { - continue; - } - let Some(content) = output.body.to_text() else { - continue; - }; - let Ok(payload) = serde_json::from_str::(&content) else { - continue; - }; - let Some(selected_tools) = payload - .get("active_selected_tools") - .and_then(Value::as_array) - else { - continue; - }; - let Some(selected_tools) = selected_tools - .iter() - .map(|value| value.as_str().map(str::to_string)) - .collect::>>() - else { - continue; - }; - active_selected_tools = Some(selected_tools); - } - _ => {} - } - } - - active_selected_tools - } - async fn previous_turn_settings(&self) -> Option { let state = self.state.lock().await; state.previous_turn_settings() @@ -3852,7 +3769,20 @@ impl Session { .await } - pub(crate) async fn parse_mcp_tool_name(&self, tool_name: &str) -> Option<(String, String)> { + pub(crate) async fn parse_mcp_tool_name( + &self, + name: &str, + namespace: &Option, + ) -> Option<(String, String)> { + let tool_name = if let Some(namespace) = namespace { + if name.starts_with(namespace.as_str()) { + name + } else { + &format!("{namespace}{name}") + } + } else { + name + }; self.services .mcp_connection_manager .read() @@ -6068,7 +5998,7 @@ fn filter_codex_apps_mcp_tools( .iter() .filter(|(_, tool)| { if tool.server_name != CODEX_APPS_MCP_SERVER_NAME { - return true; + return false; } let Some(connector_id) = codex_apps_connector_id(tool) else { return false; @@ -6284,18 +6214,13 @@ async fn built_tools( ); let mut selected_mcp_tools = filter_non_codex_apps_mcp_tools_only(&mcp_tools); - - if let Some(selected_tools) = sess.get_mcp_tool_selection().await { - selected_mcp_tools.extend(filter_mcp_tools_by_name(&mcp_tools, &selected_tools)); - } - - selected_mcp_tools.extend(filter_codex_apps_mcp_tools_only( + selected_mcp_tools.extend(filter_codex_apps_mcp_tools( &mcp_tools, explicitly_enabled.as_ref(), + &turn_context.config, )); - mcp_tools = - connectors::filter_codex_apps_tools_by_policy(selected_mcp_tools, &turn_context.config); + mcp_tools = selected_mcp_tools; } Ok(Arc::new(ToolRouter::from_config( diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 69ce86b61..0265d810b 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -259,9 +259,19 @@ fn make_mcp_tool( connector_id: Option<&str>, connector_name: Option<&str>, ) -> ToolInfo { + let tool_namespace = if server_name == CODEX_APPS_MCP_SERVER_NAME { + connector_name + .map(crate::connectors::sanitize_name) + .map(|connector_name| format!("mcp__{server_name}__{connector_name}")) + .unwrap_or_else(|| server_name.to_string()) + } else { + server_name.to_string() + }; + ToolInfo { server_name: server_name.to_string(), tool_name: tool_name.to_string(), + tool_namespace, tool: Tool { name: tool_name.to_string().into(), title: None, @@ -276,25 +286,10 @@ fn make_mcp_tool( connector_id: connector_id.map(str::to_string), connector_name: connector_name.map(str::to_string), plugin_display_names: Vec::new(), + connector_description: None, } } -fn function_call_rollout_item(name: &str, call_id: &str) -> RolloutItem { - RolloutItem::ResponseItem(ResponseItem::FunctionCall { - id: None, - name: name.to_string(), - arguments: "{}".to_string(), - call_id: call_id.to_string(), - }) -} - -fn function_call_output_rollout_item(call_id: &str, output: &str) -> RolloutItem { - RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput { - call_id: call_id.to_string(), - output: FunctionCallOutputPayload::from_text(output.to_string()), - }) -} - #[test] fn validated_network_policy_amendment_host_allows_normalized_match() { let amendment = NetworkPolicyAmendment { @@ -547,8 +542,12 @@ fn non_app_mcp_tools_remain_visible_without_search_selection() { &explicitly_enabled_connectors, &HashMap::new(), ); - let apps_mcp_tools = filter_codex_apps_mcp_tools_only(&mcp_tools, &connectors); - selected_mcp_tools.extend(apps_mcp_tools); + let config = test_config(); + selected_mcp_tools.extend(filter_codex_apps_mcp_tools( + &mcp_tools, + &connectors, + &config, + )); let mut tool_names: Vec = selected_mcp_tools.into_keys().collect(); tool_names.sort(); @@ -557,7 +556,7 @@ fn non_app_mcp_tools_remain_visible_without_search_selection() { #[test] fn search_tool_selection_keeps_codex_apps_tools_without_mentions() { - let selected_tool_names = vec![ + let selected_tool_names = [ "mcp__codex_apps__calendar_create_event".to_string(), "mcp__rmcp__echo".to_string(), ]; @@ -577,7 +576,11 @@ fn search_tool_selection_keeps_codex_apps_tools_without_mentions() { ), ]); - let mut selected_mcp_tools = filter_mcp_tools_by_name(&mcp_tools, &selected_tool_names); + let mut selected_mcp_tools = mcp_tools + .iter() + .filter(|(name, _)| selected_tool_names.contains(name)) + .map(|(name, tool)| (name.clone(), tool.clone())) + .collect::>(); let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools); let explicitly_enabled_connectors = HashSet::new(); let connectors = filter_connectors_for_input( @@ -586,8 +589,12 @@ fn search_tool_selection_keeps_codex_apps_tools_without_mentions() { &explicitly_enabled_connectors, &HashMap::new(), ); - let apps_mcp_tools = filter_codex_apps_mcp_tools_only(&mcp_tools, &connectors); - selected_mcp_tools.extend(apps_mcp_tools); + let config = test_config(); + selected_mcp_tools.extend(filter_codex_apps_mcp_tools( + &mcp_tools, + &connectors, + &config, + )); let mut tool_names: Vec = selected_mcp_tools.into_keys().collect(); tool_names.sort(); @@ -602,7 +609,7 @@ fn search_tool_selection_keeps_codex_apps_tools_without_mentions() { #[test] fn apps_mentions_add_codex_apps_tools_to_search_selected_set() { - let selected_tool_names = vec!["mcp__rmcp__echo".to_string()]; + let selected_tool_names = ["mcp__rmcp__echo".to_string()]; let mcp_tools = HashMap::from([ ( "mcp__codex_apps__calendar_create_event".to_string(), @@ -619,7 +626,11 @@ fn apps_mentions_add_codex_apps_tools_to_search_selected_set() { ), ]); - let mut selected_mcp_tools = filter_mcp_tools_by_name(&mcp_tools, &selected_tool_names); + let mut selected_mcp_tools = mcp_tools + .iter() + .filter(|(name, _)| selected_tool_names.contains(name)) + .map(|(name, tool)| (name.clone(), tool.clone())) + .collect::>(); let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools); let explicitly_enabled_connectors = HashSet::new(); let connectors = filter_connectors_for_input( @@ -628,8 +639,12 @@ fn apps_mentions_add_codex_apps_tools_to_search_selected_set() { &explicitly_enabled_connectors, &HashMap::new(), ); - let apps_mcp_tools = filter_codex_apps_mcp_tools_only(&mcp_tools, &connectors); - selected_mcp_tools.extend(apps_mcp_tools); + let config = test_config(); + selected_mcp_tools.extend(filter_codex_apps_mcp_tools( + &mcp_tools, + &connectors, + &config, + )); let mut tool_names: Vec = selected_mcp_tools.into_keys().collect(); tool_names.sort(); @@ -642,106 +657,6 @@ fn apps_mentions_add_codex_apps_tools_to_search_selected_set() { ); } -#[test] -fn extract_mcp_tool_selection_from_rollout_reads_search_tool_output() { - let rollout_items = vec![ - function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-1"), - function_call_output_rollout_item( - "search-1", - &json!({ - "active_selected_tools": [ - "mcp__codex_apps__calendar_create_event", - "mcp__codex_apps__calendar_list_events", - ], - }) - .to_string(), - ), - ]; - - let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items); - assert_eq!( - selected, - Some(vec![ - "mcp__codex_apps__calendar_create_event".to_string(), - "mcp__codex_apps__calendar_list_events".to_string(), - ]) - ); -} - -#[test] -fn extract_mcp_tool_selection_from_rollout_latest_valid_payload_wins() { - let rollout_items = vec![ - function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-1"), - function_call_output_rollout_item( - "search-1", - &json!({ - "active_selected_tools": ["mcp__codex_apps__calendar_create_event"], - }) - .to_string(), - ), - function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-2"), - function_call_output_rollout_item( - "search-2", - &json!({ - "active_selected_tools": ["mcp__codex_apps__calendar_delete_event"], - }) - .to_string(), - ), - ]; - - let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items); - assert_eq!( - selected, - Some(vec!["mcp__codex_apps__calendar_delete_event".to_string(),]) - ); -} - -#[test] -fn extract_mcp_tool_selection_from_rollout_ignores_non_search_and_malformed_payloads() { - let rollout_items = vec![ - function_call_rollout_item("shell", "shell-1"), - function_call_output_rollout_item( - "shell-1", - &json!({ - "active_selected_tools": ["mcp__codex_apps__should_be_ignored"], - }) - .to_string(), - ), - function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-1"), - function_call_output_rollout_item("search-1", "{not-json"), - function_call_output_rollout_item( - "unknown-search-call", - &json!({ - "active_selected_tools": ["mcp__codex_apps__also_ignored"], - }) - .to_string(), - ), - function_call_output_rollout_item( - "search-1", - &json!({ - "active_selected_tools": ["mcp__codex_apps__calendar_list_events"], - }) - .to_string(), - ), - ]; - - let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items); - assert_eq!( - selected, - Some(vec!["mcp__codex_apps__calendar_list_events".to_string(),]) - ); -} - -#[test] -fn extract_mcp_tool_selection_from_rollout_returns_none_without_valid_search_output() { - let rollout_items = vec![function_call_rollout_item( - SEARCH_TOOL_BM25_TOOL_NAME, - "search-1", - )]; - let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items); - assert_eq!(selected, None); -} - #[tokio::test] async fn reconstruct_history_matches_live_compactions() { let (session, turn_context) = make_session_and_context().await; @@ -4238,6 +4153,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { tracker: Arc::clone(&turn_diff_tracker), call_id, tool_name: tool_name.to_string(), + tool_namespace: None, payload: ToolPayload::Function { arguments: serde_json::json!({ "command": params.command.clone(), @@ -4281,6 +4197,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { tracker: Arc::clone(&turn_diff_tracker), call_id: "test-call-2".to_string(), tool_name: tool_name.to_string(), + tool_namespace: None, payload: ToolPayload::Function { arguments: serde_json::json!({ "command": params2.command.clone(), @@ -4336,6 +4253,7 @@ async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() tracker: Arc::clone(&tracker), call_id: "exec-call".to_string(), tool_name: "exec_command".to_string(), + tool_namespace: None, payload: ToolPayload::Function { arguments: serde_json::json!({ "cmd": "echo hi", diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 6bed9fd37..4f8bfea9e 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -137,6 +137,7 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), call_id: "test-call".to_string(), tool_name: "shell".to_string(), + tool_namespace: None, payload: ToolPayload::Function { arguments: serde_json::json!({ "command": params.command.clone(), @@ -204,6 +205,7 @@ async fn guardian_allows_unified_exec_additional_permissions_requests_past_polic tracker: Arc::clone(&tracker), call_id: "exec-call".to_string(), tool_name: "exec_command".to_string(), + tool_namespace: None, payload: ToolPayload::Function { arguments: serde_json::json!({ "cmd": "echo hi", diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 473d91e54..fad1bd628 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -202,7 +202,9 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::WebSearchCall { .. } diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index b2b8ac109..405eb1868 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -294,7 +294,7 @@ pub fn connector_display_label(connector: &AppInfo) -> String { } pub fn connector_mention_slug(connector: &AppInfo) -> String { - connector_name_slug(&connector_display_label(connector)) + sanitize_name(&connector_display_label(connector)) } pub(crate) fn accessible_connectors_from_mcp_tools( @@ -307,10 +307,10 @@ pub(crate) fn accessible_connectors_from_mcp_tools( return None; } let connector_id = tool.connector_id.as_deref()?; - let connector_name = normalize_connector_value(tool.connector_name.as_deref()); Some(( connector_id.to_string(), - connector_name, + normalize_connector_value(tool.connector_name.as_deref()), + normalize_connector_value(tool.connector_description.as_deref()), tool.plugin_display_names.clone(), )) }); @@ -467,21 +467,13 @@ pub(crate) fn codex_app_tool_is_enabled( app_tool_policy( config, tool_info.connector_id.as_deref(), - &tool_info.tool_name, + &tool_info.tool.name, tool_info.tool.title.as_deref(), tool_info.tool.annotations.as_ref(), ) .enabled } -pub(crate) fn filter_codex_apps_tools_by_policy( - mut mcp_tools: HashMap, - config: &Config, -) -> HashMap { - mcp_tools.retain(|_, tool_info| codex_app_tool_is_enabled(config, tool_info)); - mcp_tools -} - const DISALLOWED_CONNECTOR_IDS: &[&str] = &[ "asdk_app_6938a94a61d881918ef32cb999ff937c", "connector_2b0a9009c9c64bf9933a3dae3f2b1254", @@ -611,23 +603,38 @@ fn app_tool_policy_from_apps_config( fn collect_accessible_connectors(tools: I) -> Vec where - I: IntoIterator, Vec)>, + I: IntoIterator, Option, Vec)>, { - let mut connectors: HashMap)> = HashMap::new(); - for (connector_id, connector_name, plugin_display_names) in tools { + let mut connectors: HashMap)> = HashMap::new(); + for (connector_id, connector_name, connector_description, plugin_display_names) in tools { let connector_name = connector_name.unwrap_or_else(|| connector_id.clone()); - if let Some((existing_name, existing_plugin_display_names)) = - connectors.get_mut(&connector_id) - { - if existing_name == &connector_id && connector_name != connector_id { - *existing_name = connector_name; + if let Some((existing, existing_plugin_display_names)) = connectors.get_mut(&connector_id) { + if existing.name == connector_id && connector_name != connector_id { + existing.name = connector_name; + } + if existing.description.is_none() && connector_description.is_some() { + existing.description = connector_description; } existing_plugin_display_names.extend(plugin_display_names); } else { connectors.insert( - connector_id, + connector_id.clone(), ( - connector_name, + AppInfo { + id: connector_id.clone(), + name: connector_name, + description: connector_description, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }, plugin_display_names .into_iter() .collect::>(), @@ -636,24 +643,12 @@ where } } let mut accessible: Vec = connectors - .into_iter() - .map( - |(connector_id, (connector_name, plugin_display_names))| AppInfo { - id: connector_id.clone(), - name: connector_name.clone(), - description: None, - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some(connector_install_url(&connector_name, &connector_id)), - is_accessible: true, - is_enabled: true, - plugin_display_names: plugin_display_names.into_iter().collect(), - }, - ) + .into_values() + .map(|(mut connector, plugin_display_names)| { + connector.plugin_display_names = plugin_display_names.into_iter().collect(); + connector.install_url = Some(connector_install_url(&connector.name, &connector.id)); + connector + }) .collect(); accessible.sort_by(|left, right| { right @@ -696,11 +691,11 @@ fn normalize_connector_value(value: Option<&str>) -> Option { } pub fn connector_install_url(name: &str, connector_id: &str) -> String { - let slug = connector_name_slug(name); + let slug = sanitize_name(name); format!("https://chatgpt.com/apps/{slug}/{connector_id}") } -pub fn connector_name_slug(name: &str) -> String { +pub fn sanitize_name(name: &str) -> String { let mut normalized = String::with_capacity(name.len()); for character in name.chars() { if character.is_ascii_alphanumeric() { @@ -728,10 +723,12 @@ mod tests { use crate::config::types::AppToolConfig; use crate::config::types::AppToolsConfig; use crate::config::types::AppsDefaultConfig; + use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp_connection_manager::ToolInfo; use pretty_assertions::assert_eq; use rmcp::model::JsonObject; use rmcp::model::Tool; + use std::collections::HashMap; use std::sync::Arc; fn annotations( @@ -807,12 +804,19 @@ mod tests { connector_name: Option<&str>, plugin_display_names: &[&str], ) -> ToolInfo { + let tool_namespace = connector_name + .map(sanitize_name) + .map(|connector_name| format!("mcp__{CODEX_APPS_MCP_SERVER_NAME}__{connector_name}")) + .unwrap_or_else(|| CODEX_APPS_MCP_SERVER_NAME.to_string()); + ToolInfo { server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), tool_name: tool_name.to_string(), + tool_namespace, tool: test_tool_definition(tool_name), connector_id: Some(connector_id.to_string()), connector_name: connector_name.map(ToOwned::to_owned), + connector_description: None, plugin_display_names: plugin_names(plugin_display_names), } } @@ -871,9 +875,11 @@ mod tests { ToolInfo { server_name: "sample".to_string(), tool_name: "echo".to_string(), + tool_namespace: "sample".to_string(), tool: test_tool_definition("echo"), connector_id: None, connector_name: None, + connector_description: None, plugin_display_names: plugin_names(&["ignored"]), }, ), @@ -930,6 +936,52 @@ mod tests { ); } + #[test] + fn accessible_connectors_from_mcp_tools_preserves_description() { + let mcp_tools = HashMap::from([( + "mcp__codex_apps__calendar_create_event".to_string(), + ToolInfo { + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: "calendar_create_event".to_string(), + tool_namespace: "mcp__codex_apps__calendar".to_string(), + tool: Tool { + name: "calendar_create_event".to_string().into(), + title: None, + description: Some("Create a calendar event".into()), + input_schema: Arc::new(JsonObject::default()), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + }, + connector_id: Some("calendar".to_string()), + connector_name: Some("Calendar".to_string()), + connector_description: Some("Plan events".to_string()), + plugin_display_names: Vec::new(), + }, + )]); + + assert_eq!( + accessible_connectors_from_mcp_tools(&mcp_tools), + vec![AppInfo { + id: "calendar".to_string(), + name: "Calendar".to_string(), + description: Some("Plan events".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some(connector_install_url("Calendar", "calendar")), + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }] + ); + } + #[test] fn app_tool_policy_uses_global_defaults_for_destructive_hints() { let apps_config = AppsConfigToml { diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 1bafca408..05ac09ba1 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -376,6 +376,8 @@ impl ContextManager { | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } + | ResponseItem::ToolSearchOutput { .. } | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::CustomToolCall { .. } @@ -413,6 +415,8 @@ fn is_api_message(message: &ResponseItem) -> bool { ResponseItem::Message { role, .. } => role.as_str() != "system", ResponseItem::FunctionCallOutput { .. } | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } + | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::LocalShellCall { .. } @@ -605,12 +609,14 @@ fn is_model_generated_item(item: &ResponseItem) -> bool { ResponseItem::Message { role, .. } => role == "assistant", ResponseItem::Reasoning { .. } | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::CustomToolCall { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::Compaction { .. } => true, ResponseItem::FunctionCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::GhostSnapshot { .. } | ResponseItem::Other => false, @@ -620,7 +626,9 @@ fn is_model_generated_item(item: &ResponseItem) -> bool { pub(crate) fn is_codex_generated_item(item: &ResponseItem) -> bool { matches!( item, - ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCallOutput { .. } + ResponseItem::FunctionCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } + | ResponseItem::CustomToolCallOutput { .. } ) || matches!(item, ResponseItem::Message { role, .. } if role == "developer") } diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 104fedab0..29400ba31 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -271,6 +271,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { ResponseItem::FunctionCall { id: None, name: "view_image".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), }, @@ -332,6 +333,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { ResponseItem::FunctionCall { id: None, name: "view_image".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), }, @@ -547,6 +549,7 @@ fn remove_first_item_removes_matching_output_for_function_call() { ResponseItem::FunctionCall { id: None, name: "do_it".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), }, @@ -570,6 +573,7 @@ fn remove_first_item_removes_matching_call_for_output() { ResponseItem::FunctionCall { id: None, name: "do_it".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "call-2".to_string(), }, @@ -586,6 +590,7 @@ fn remove_last_item_removes_matching_call_for_output() { ResponseItem::FunctionCall { id: None, name: "do_it".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "call-delete-last".to_string(), }, @@ -1059,6 +1064,7 @@ fn normalize_adds_missing_output_for_function_call() { let items = vec![ResponseItem::FunctionCall { id: None, name: "do_it".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), }]; @@ -1072,6 +1078,7 @@ fn normalize_adds_missing_output_for_function_call() { ResponseItem::FunctionCall { id: None, name: "do_it".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), }, @@ -1193,6 +1200,7 @@ fn normalize_mixed_inserts_and_removals() { ResponseItem::FunctionCall { id: None, name: "f1".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "c1".to_string(), }, @@ -1233,6 +1241,7 @@ fn normalize_mixed_inserts_and_removals() { ResponseItem::FunctionCall { id: None, name: "f1".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "c1".to_string(), }, @@ -1276,6 +1285,7 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() { let items = vec![ResponseItem::FunctionCall { id: None, name: "do_it".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), }]; @@ -1287,6 +1297,7 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() { ResponseItem::FunctionCall { id: None, name: "do_it".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), }, @@ -1298,6 +1309,39 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() { ); } +#[test] +fn normalize_adds_missing_output_for_tool_search_call() { + let items = vec![ResponseItem::ToolSearchCall { + id: None, + call_id: Some("search-call-x".to_string()), + status: Some("completed".to_string()), + execution: "client".to_string(), + arguments: "{}".into(), + }]; + let mut h = create_history_with_items(items); + + h.normalize_history(&default_input_modalities()); + + assert_eq!( + h.raw_items(), + vec![ + ResponseItem::ToolSearchCall { + id: None, + call_id: Some("search-call-x".to_string()), + status: Some("completed".to_string()), + execution: "client".to_string(), + arguments: "{}".into(), + }, + ResponseItem::ToolSearchOutput { + call_id: Some("search-call-x".to_string()), + status: "completed".to_string(), + execution: "client".to_string(), + tools: Vec::new(), + }, + ] + ); +} + #[cfg(debug_assertions)] #[test] #[should_panic] @@ -1357,6 +1401,59 @@ fn normalize_removes_orphan_custom_tool_call_output_panics_in_debug() { h.normalize_history(&default_input_modalities()); } +#[cfg(not(debug_assertions))] +#[test] +fn normalize_removes_orphan_client_tool_search_output() { + let items = vec![ResponseItem::ToolSearchOutput { + call_id: Some("orphan-search".to_string()), + status: "completed".to_string(), + execution: "client".to_string(), + tools: Vec::new(), + }]; + let mut h = create_history_with_items(items); + + h.normalize_history(&default_input_modalities()); + + assert_eq!(h.raw_items(), vec![]); +} + +#[cfg(debug_assertions)] +#[test] +#[should_panic] +fn normalize_removes_orphan_client_tool_search_output_panics_in_debug() { + let items = vec![ResponseItem::ToolSearchOutput { + call_id: Some("orphan-search".to_string()), + status: "completed".to_string(), + execution: "client".to_string(), + tools: Vec::new(), + }]; + let mut h = create_history_with_items(items); + h.normalize_history(&default_input_modalities()); +} + +#[test] +fn normalize_keeps_server_tool_search_output_without_matching_call() { + let items = vec![ResponseItem::ToolSearchOutput { + call_id: Some("server-search".to_string()), + status: "completed".to_string(), + execution: "server".to_string(), + tools: Vec::new(), + }]; + let mut h = create_history_with_items(items); + + h.normalize_history(&default_input_modalities()); + + assert_eq!( + h.raw_items(), + vec![ResponseItem::ToolSearchOutput { + call_id: Some("server-search".to_string()), + status: "completed".to_string(), + execution: "server".to_string(), + tools: Vec::new(), + }] + ); +} + #[cfg(debug_assertions)] #[test] #[should_panic] @@ -1365,6 +1462,7 @@ fn normalize_mixed_inserts_and_removals_panics_in_debug() { ResponseItem::FunctionCall { id: None, name: "f1".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "c1".to_string(), }, diff --git a/codex-rs/core/src/context_manager/normalize.rs b/codex-rs/core/src/context_manager/normalize.rs index a0009f18a..3f13c5d9e 100644 --- a/codex-rs/core/src/context_manager/normalize.rs +++ b/codex-rs/core/src/context_manager/normalize.rs @@ -38,6 +38,31 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { )); } } + ResponseItem::ToolSearchCall { + call_id: Some(call_id), + .. + } => { + let has_output = items.iter().any(|i| match i { + ResponseItem::ToolSearchOutput { + call_id: Some(existing), + .. + } => existing == call_id, + _ => false, + }); + + if !has_output { + info!("Tool search output is missing for call id: {call_id}"); + missing_outputs_to_insert.push(( + idx, + ResponseItem::ToolSearchOutput { + call_id: Some(call_id.clone()), + status: "completed".to_string(), + execution: "client".to_string(), + tools: Vec::new(), + }, + )); + } + } ResponseItem::CustomToolCall { call_id, .. } => { let has_output = items.iter().any(|i| match i { ResponseItem::CustomToolCallOutput { @@ -102,6 +127,17 @@ pub(crate) fn remove_orphan_outputs(items: &mut Vec) { }) .collect(); + let tool_search_call_ids: HashSet = items + .iter() + .filter_map(|i| match i { + ResponseItem::ToolSearchCall { + call_id: Some(call_id), + .. + } => Some(call_id.clone()), + _ => None, + }) + .collect(); + let local_shell_call_ids: HashSet = items .iter() .filter_map(|i| match i { @@ -141,6 +177,18 @@ pub(crate) fn remove_orphan_outputs(items: &mut Vec) { } has_match } + ResponseItem::ToolSearchOutput { execution, .. } if execution == "server" => true, + 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, _ => true, }); } @@ -168,6 +216,37 @@ pub(crate) fn remove_corresponding_for(items: &mut Vec, item: &Res items.remove(pos); } } + ResponseItem::ToolSearchCall { + call_id: Some(call_id), + .. + } => { + remove_first_matching(items, |i| { + matches!( + i, + ResponseItem::ToolSearchOutput { + call_id: Some(existing), + .. + } if existing == call_id + ) + }); + } + ResponseItem::ToolSearchOutput { + call_id: Some(call_id), + .. + } => { + remove_first_matching( + items, + |i| { + matches!( + i, + ResponseItem::ToolSearchCall { + call_id: Some(existing), + .. + } if existing == call_id + ) + }, + ); + } ResponseItem::CustomToolCall { call_id, .. } => { remove_first_matching(items, |i| { matches!( diff --git a/codex-rs/core/src/guardian_tests.rs b/codex-rs/core/src/guardian_tests.rs index 6deac9e77..ffe60ebe9 100644 --- a/codex-rs/core/src/guardian_tests.rs +++ b/codex-rs/core/src/guardian_tests.rs @@ -105,6 +105,7 @@ fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() { ResponseItem::FunctionCall { id: None, name: "read_file".to_string(), + namespace: None, arguments: "{\"path\":\"README.md\"}".to_string(), call_id: "call-1".to_string(), }, @@ -319,6 +320,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() ResponseItem::FunctionCall { id: None, name: "gh_repo_view".to_string(), + namespace: None, arguments: "{\"repo\":\"openai/codex\"}".to_string(), call_id: "call-1".to_string(), }, diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 442e7e0c6..009b85d83 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -82,6 +82,8 @@ use crate::codex::INITIAL_SUBMIT_ID; use crate::config::types::McpServerConfig; use crate::config::types::McpServerTransportConfig; use crate::connectors::is_connector_id_allowed; +use crate::connectors::sanitize_name; + /// Delimiter used to separate the server name from the tool name in a fully /// qualified tool name. /// @@ -158,10 +160,14 @@ where let mut seen_raw_names = HashSet::new(); let mut qualified_tools = HashMap::new(); for tool in tools { - let qualified_name_raw = format!( - "mcp{}{}{}{}", - MCP_TOOL_NAME_DELIMITER, tool.server_name, MCP_TOOL_NAME_DELIMITER, tool.tool_name - ); + let qualified_name_raw = if tool.server_name != CODEX_APPS_MCP_SERVER_NAME { + format!( + "mcp{}{}{}{}", + MCP_TOOL_NAME_DELIMITER, tool.server_name, MCP_TOOL_NAME_DELIMITER, tool.tool_name + ) + } else { + format!("{}{}", tool.tool_namespace, tool.tool_name) + }; if !seen_raw_names.insert(qualified_name_raw.clone()) { warn!("skipping duplicated tool {}", qualified_name_raw); continue; @@ -196,11 +202,13 @@ where pub(crate) struct ToolInfo { pub(crate) server_name: String, pub(crate) tool_name: String, + pub(crate) tool_namespace: String, pub(crate) tool: Tool, pub(crate) connector_id: Option, pub(crate) connector_name: Option, #[serde(default)] pub(crate) plugin_display_names: Vec, + pub(crate) connector_description: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -1086,7 +1094,7 @@ impl McpConnectionManager { self.list_all_tools() .await .get(tool_name) - .map(|tool| (tool.server_name.clone(), tool.tool_name.clone())) + .map(|tool| (tool.server_name.clone(), tool.tool.name.to_string())) } pub async fn notify_sandbox_state_change(&self, sandbox_state: &SandboxState) -> Result<()> { @@ -1168,31 +1176,7 @@ impl ToolFilter { fn filter_tools(tools: Vec, filter: &ToolFilter) -> Vec { tools .into_iter() - .filter(|tool| filter.allows(&tool.tool_name)) - .collect() -} - -pub(crate) fn filter_codex_apps_mcp_tools_only( - mcp_tools: &HashMap, - connectors: &[crate::connectors::AppInfo], -) -> HashMap { - let allowed: HashSet<&str> = connectors - .iter() - .map(|connector| connector.id.as_str()) - .collect(); - - mcp_tools - .iter() - .filter(|(_, tool)| { - if tool.server_name != CODEX_APPS_MCP_SERVER_NAME { - return false; - } - let Some(connector_id) = tool.connector_id.as_deref() else { - return false; - }; - allowed.contains(connector_id) - }) - .map(|(name, tool)| (name.clone(), tool.clone())) + .filter(|tool| filter.allows(&tool.tool.name)) .collect() } @@ -1206,19 +1190,6 @@ pub(crate) fn filter_non_codex_apps_mcp_tools_only( .collect() } -pub(crate) fn filter_mcp_tools_by_name( - mcp_tools: &HashMap, - selected_tools: &[String], -) -> HashMap { - let allowed: HashSet<&str> = selected_tools.iter().map(String::as_str).collect(); - - mcp_tools - .iter() - .filter(|(name, _)| allowed.contains(name.as_str())) - .map(|(name, tool)| (name.clone(), tool.clone())) - .collect() -} - fn normalize_codex_apps_tool_title( server_name: &str, connector_name: Option<&str>, @@ -1245,6 +1216,57 @@ fn normalize_codex_apps_tool_title( value.to_string() } +fn normalize_codex_apps_tool_name( + server_name: &str, + tool_name: &str, + connector_id: Option<&str>, + connector_name: Option<&str>, +) -> String { + if server_name != CODEX_APPS_MCP_SERVER_NAME { + return tool_name.to_string(); + } + + let tool_name = sanitize_name(tool_name); + + if let Some(connector_name) = connector_name + .map(str::trim) + .map(sanitize_name) + .filter(|name| !name.is_empty()) + && let Some(stripped) = tool_name.strip_prefix(&connector_name) + && !stripped.is_empty() + { + return stripped.to_string(); + } + + if let Some(connector_id) = connector_id + .map(str::trim) + .map(sanitize_name) + .filter(|name| !name.is_empty()) + && let Some(stripped) = tool_name.strip_prefix(&connector_id) + && !stripped.is_empty() + { + return stripped.to_string(); + } + + tool_name +} + +fn normalize_codex_apps_namespace(server_name: &str, connector_name: Option<&str>) -> String { + if server_name != CODEX_APPS_MCP_SERVER_NAME { + server_name.to_string() + } else if let Some(connector_name) = connector_name { + format!( + "mcp{}{}{}{}", + MCP_TOOL_NAME_DELIMITER, + server_name, + MCP_TOOL_NAME_DELIMITER, + sanitize_name(connector_name) + ) + } else { + server_name.to_string() + } +} + fn resolve_bearer_token( server_name: &str, bearer_token_env_var: Option<&str>, @@ -1563,7 +1585,16 @@ async fn list_tools_for_client_uncached( .tools .into_iter() .map(|tool| { + let tool_name = normalize_codex_apps_tool_name( + server_name, + &tool.tool.name, + tool.connector_id.as_deref(), + tool.connector_name.as_deref(), + ); + let tool_namespace = + normalize_codex_apps_namespace(server_name, tool.connector_name.as_deref()); let connector_name = tool.connector_name; + let connector_description = tool.connector_description; let mut tool_def = tool.tool; if let Some(title) = tool_def.title.as_deref() { let normalized_title = @@ -1574,11 +1605,13 @@ async fn list_tools_for_client_uncached( } ToolInfo { server_name: server_name.to_owned(), - tool_name: tool_def.name.to_string(), + tool_name, + tool_namespace, tool: tool_def, connector_id: tool.connector_id, connector_name, plugin_display_names: Vec::new(), + connector_description, } }) .collect(); @@ -1679,6 +1712,11 @@ mod tests { ToolInfo { server_name: server_name.to_string(), tool_name: tool_name.to_string(), + tool_namespace: if server_name == CODEX_APPS_MCP_SERVER_NAME { + format!("mcp__{server_name}__") + } else { + server_name.to_string() + }, tool: Tool { name: tool_name.to_string().into(), title: None, @@ -1693,6 +1731,7 @@ mod tests { connector_id: None, connector_name: None, plugin_display_names: Vec::new(), + connector_description: None, } } diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 70421ae3d..811894e92 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -674,7 +674,7 @@ async fn lookup_mcp_tool_metadata( let tool_info = tools .into_values() - .find(|tool_info| tool_info.server_name == server && tool_info.tool_name == tool_name)?; + .find(|tool_info| tool_info.server_name == server && tool_info.tool.name == tool_name)?; let connector_description = if server == CODEX_APPS_MCP_SERVER_NAME { let connectors = match connectors::list_cached_accessible_connectors_from_mcp_tools( turn_context.config.as_ref(), @@ -723,7 +723,7 @@ async fn lookup_mcp_app_usage_metadata( .await; tools.into_values().find_map(|tool_info| { - if tool_info.server_name == server && tool_info.tool_name == tool_name { + if tool_info.server_name == server && tool_info.tool.name == tool_name { Some(McpAppUsageMetadata { connector_id: tool_info.connector_id, app_name: tool_info.connector_name, diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 89068d46f..588e59ecc 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -31,7 +31,9 @@ pub(crate) fn should_persist_response_item(item: &ResponseItem) -> bool { | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::WebSearchCall { .. } @@ -49,7 +51,9 @@ pub(crate) fn should_persist_response_item_for_memories(item: &ResponseItem) -> ResponseItem::Message { role, .. } => role != "developer", ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::WebSearchCall { .. } => true, diff --git a/codex-rs/core/src/rollout/truncation.rs b/codex-rs/core/src/rollout/truncation.rs index c50eacc48..6aacc4394 100644 --- a/codex-rs/core/src/rollout/truncation.rs +++ b/codex-rs/core/src/rollout/truncation.rs @@ -120,9 +120,10 @@ mod tests { }, ResponseItem::FunctionCall { id: None, - name: "tool".to_string(), - arguments: "{}".to_string(), call_id: "c1".to_string(), + name: "tool".to_string(), + namespace: None, + arguments: "{}".to_string(), }, assistant_msg("a4"), ]; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 973501e8d..a40405d1d 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -13,6 +13,7 @@ use crate::error::Result as CodexResult; use crate::protocol::RateLimitSnapshot; use crate::protocol::TokenUsage; use crate::protocol::TokenUsageInfo; +use crate::sandboxing::merge_permission_profiles; use crate::tasks::RegularTask; use crate::truncate::TruncationPolicy; use codex_protocol::protocol::TurnContextItem; @@ -31,7 +32,6 @@ pub(crate) struct SessionState { previous_turn_settings: Option, /// Startup regular task pre-created during session initialization. pub(crate) startup_regular_task: Option>>, - pub(crate) active_mcp_tool_selection: Option>, pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, @@ -50,7 +50,6 @@ impl SessionState { mcp_dependency_prompted: HashSet::new(), previous_turn_settings: None, startup_regular_task: None, - active_mcp_tool_selection: None, active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, @@ -176,64 +175,6 @@ impl SessionState { self.startup_regular_task.take() } - pub(crate) fn merge_mcp_tool_selection(&mut self, tool_names: Vec) -> Vec { - if tool_names.is_empty() { - return self.active_mcp_tool_selection.clone().unwrap_or_default(); - } - - let mut merged = self.active_mcp_tool_selection.take().unwrap_or_default(); - let mut seen: HashSet = merged.iter().cloned().collect(); - - for tool_name in tool_names { - if seen.insert(tool_name.clone()) { - merged.push(tool_name); - } - } - - self.active_mcp_tool_selection = Some(merged.clone()); - merged - } - - pub(crate) fn set_mcp_tool_selection(&mut self, tool_names: Vec) { - if tool_names.is_empty() { - self.active_mcp_tool_selection = None; - return; - } - - let mut selected = Vec::new(); - let mut seen = HashSet::new(); - for tool_name in tool_names { - if seen.insert(tool_name.clone()) { - selected.push(tool_name); - } - } - - self.active_mcp_tool_selection = if selected.is_empty() { - None - } else { - Some(selected) - }; - } - - pub(crate) fn get_mcp_tool_selection(&self) -> Option> { - self.active_mcp_tool_selection.clone() - } - - pub(crate) fn clear_mcp_tool_selection(&mut self) { - self.active_mcp_tool_selection = None; - } - - pub(crate) fn record_granted_permissions(&mut self, permissions: PermissionProfile) { - self.granted_permissions = crate::sandboxing::merge_permission_profiles( - self.granted_permissions.as_ref(), - Some(&permissions), - ); - } - - pub(crate) fn granted_permissions(&self) -> Option { - self.granted_permissions.clone() - } - // Adds connector IDs to the active set and returns the merged selection. pub(crate) fn merge_connector_selection(&mut self, connector_ids: I) -> HashSet where @@ -265,6 +206,15 @@ impl SessionState { ) -> Option { self.pending_session_start_source.take() } + + pub(crate) fn record_granted_permissions(&mut self, permissions: PermissionProfile) { + self.granted_permissions = + merge_permission_profiles(self.granted_permissions.as_ref(), Some(&permissions)); + } + + pub(crate) fn granted_permissions(&self) -> Option { + self.granted_permissions.clone() + } } // Sometimes new snapshots don't include credits or plan information. @@ -293,109 +243,6 @@ mod tests { use crate::protocol::RateLimitWindow; use pretty_assertions::assert_eq; - #[tokio::test] - async fn merge_mcp_tool_selection_deduplicates_and_preserves_order() { - let session_configuration = make_session_configuration_for_tests().await; - let mut state = SessionState::new(session_configuration); - - let merged = state.merge_mcp_tool_selection(vec![ - "mcp__rmcp__echo".to_string(), - "mcp__rmcp__image".to_string(), - "mcp__rmcp__echo".to_string(), - ]); - assert_eq!( - merged, - vec![ - "mcp__rmcp__echo".to_string(), - "mcp__rmcp__image".to_string(), - ] - ); - - let merged = state.merge_mcp_tool_selection(vec![ - "mcp__rmcp__image".to_string(), - "mcp__rmcp__search".to_string(), - ]); - assert_eq!( - merged, - vec![ - "mcp__rmcp__echo".to_string(), - "mcp__rmcp__image".to_string(), - "mcp__rmcp__search".to_string(), - ] - ); - } - - #[tokio::test] - async fn merge_mcp_tool_selection_empty_input_is_noop() { - let session_configuration = make_session_configuration_for_tests().await; - let mut state = SessionState::new(session_configuration); - state.merge_mcp_tool_selection(vec![ - "mcp__rmcp__echo".to_string(), - "mcp__rmcp__image".to_string(), - ]); - - let merged = state.merge_mcp_tool_selection(Vec::new()); - assert_eq!( - merged, - vec![ - "mcp__rmcp__echo".to_string(), - "mcp__rmcp__image".to_string(), - ] - ); - assert_eq!( - state.get_mcp_tool_selection(), - Some(vec![ - "mcp__rmcp__echo".to_string(), - "mcp__rmcp__image".to_string(), - ]) - ); - } - - #[tokio::test] - async fn clear_mcp_tool_selection_removes_selection() { - let session_configuration = make_session_configuration_for_tests().await; - let mut state = SessionState::new(session_configuration); - state.merge_mcp_tool_selection(vec!["mcp__rmcp__echo".to_string()]); - - state.clear_mcp_tool_selection(); - - assert_eq!(state.get_mcp_tool_selection(), None); - } - - #[tokio::test] - async fn set_mcp_tool_selection_deduplicates_and_preserves_order() { - let session_configuration = make_session_configuration_for_tests().await; - let mut state = SessionState::new(session_configuration); - state.merge_mcp_tool_selection(vec!["mcp__rmcp__old".to_string()]); - - state.set_mcp_tool_selection(vec![ - "mcp__rmcp__echo".to_string(), - "mcp__rmcp__image".to_string(), - "mcp__rmcp__echo".to_string(), - "mcp__rmcp__search".to_string(), - ]); - - assert_eq!( - state.get_mcp_tool_selection(), - Some(vec![ - "mcp__rmcp__echo".to_string(), - "mcp__rmcp__image".to_string(), - "mcp__rmcp__search".to_string(), - ]) - ); - } - - #[tokio::test] - async fn set_mcp_tool_selection_empty_input_clears_selection() { - let session_configuration = make_session_configuration_for_tests().await; - let mut state = SessionState::new(session_configuration); - state.merge_mcp_tool_selection(vec!["mcp__rmcp__echo".to_string()]); - - state.set_mcp_tool_selection(Vec::new()); - - assert_eq!(state.get_mcp_tool_selection(), None); - } - #[tokio::test] // Verifies connector merging deduplicates repeated IDs. async fn merge_connector_selection_deduplicates_entries() { diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 26ec7cc6f..22351e813 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -335,7 +335,9 @@ pub(crate) async fn handle_non_tool_response_item( } Some(turn_item) } - ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCallOutput { .. } => { + ResponseItem::FunctionCallOutput { .. } + | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } => { debug!("unexpected tool output from stream"); None } @@ -381,6 +383,17 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti output, }) } + ResponseInputItem::ToolSearchOutput { + call_id, + status, + execution, + tools, + } => Some(ResponseItem::ToolSearchOutput { + call_id: Some(call_id.clone()), + status: status.clone(), + execution: execution.clone(), + tools: tools.clone(), + }), _ => None, } } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index cc6de4710..5ee47bf74 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -721,6 +721,7 @@ mod tests { id: None, call_id: "c1".to_string(), name: "tool".to_string(), + namespace: None, arguments: "{}".to_string(), }, assistant_msg("a4"), diff --git a/codex-rs/core/src/tools/code_mode.rs b/codex-rs/core/src/tools/code_mode.rs index ba8dd29e0..fd0587c71 100644 --- a/codex-rs/core/src/tools/code_mode.rs +++ b/codex-rs/core/src/tools/code_mode.rs @@ -374,7 +374,10 @@ fn enabled_tool_from_spec(spec: ToolSpec) -> Option { let (description, kind) = match spec { ToolSpec::Function(tool) => (tool.description, CodeModeToolKind::Function), ToolSpec::Freeform(tool) => (tool.description, CodeModeToolKind::Freeform), - ToolSpec::LocalShell {} | ToolSpec::ImageGeneration { .. } | ToolSpec::WebSearch { .. } => { + ToolSpec::LocalShell {} + | ToolSpec::ImageGeneration { .. } + | ToolSpec::ToolSearch { .. } + | ToolSpec::WebSearch { .. } => { return None; } }; @@ -423,25 +426,27 @@ async fn call_nested_tool( let router = build_nested_router(&exec).await; let specs = router.specs(); - let payload = if let Some((server, tool)) = exec.session.parse_mcp_tool_name(&tool_name).await { - match serialize_function_tool_arguments(&tool_name, input) { - Ok(raw_arguments) => ToolPayload::Mcp { - server, - tool, - raw_arguments, - }, - Err(error) => return JsonValue::String(error), - } - } else { - match build_nested_tool_payload(&specs, &tool_name, input) { - Ok(payload) => payload, - Err(error) => return JsonValue::String(error), - } - }; + let payload = + if let Some((server, tool)) = exec.session.parse_mcp_tool_name(&tool_name, &None).await { + match serialize_function_tool_arguments(&tool_name, input) { + Ok(raw_arguments) => ToolPayload::Mcp { + server, + tool, + raw_arguments, + }, + Err(error) => return JsonValue::String(error), + } + } else { + match build_nested_tool_payload(&specs, &tool_name, input) { + Ok(payload) => payload, + Err(error) => return JsonValue::String(error), + } + }; let call = ToolCall { tool_name: tool_name.clone(), call_id: format!("{PUBLIC_TOOL_NAME}-{}", uuid::Uuid::new_v4()), + tool_namespace: None, payload, }; let result = router diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index 041de50f5..85127059b 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -1,3 +1,4 @@ +use crate::client_common::tools::ToolSearchOutputTool; use crate::codex::Session; use crate::codex::TurnContext; use crate::tools::TELEMETRY_PREVIEW_MAX_BYTES; @@ -12,6 +13,7 @@ use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::SearchToolCallParams; use codex_protocol::models::ShellToolCallParams; use codex_protocol::models::function_call_output_content_items_to_text; use codex_utils_string::take_bytes_at_char_boundary; @@ -38,6 +40,7 @@ pub struct ToolInvocation { pub tracker: SharedTurnDiffTracker, pub call_id: String, pub tool_name: String, + pub tool_namespace: Option, pub payload: ToolPayload, } @@ -46,6 +49,9 @@ pub enum ToolPayload { Function { arguments: String, }, + ToolSearch { + arguments: SearchToolCallParams, + }, Custom { input: String, }, @@ -63,6 +69,7 @@ impl ToolPayload { pub fn log_payload(&self) -> Cow<'_, str> { match self { ToolPayload::Function { arguments } => Cow::Borrowed(arguments), + ToolPayload::ToolSearch { arguments } => Cow::Owned(arguments.query.clone()), ToolPayload::Custom { input } => Cow::Borrowed(input), ToolPayload::LocalShell { params } => Cow::Owned(params.command.join(" ")), ToolPayload::Mcp { raw_arguments, .. } => Cow::Borrowed(raw_arguments), @@ -107,6 +114,47 @@ impl ToolOutput for CallToolResult { } } +#[derive(Clone)] +pub struct ToolSearchOutput { + pub tools: Vec, +} + +impl ToolOutput for ToolSearchOutput { + fn log_preview(&self) -> String { + let 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(); + telemetry_preview(&JsonValue::Array(tools).to_string()) + } + + fn success_for_logging(&self) -> bool { + true + } + + 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(), + } + } +} + pub struct FunctionToolOutput { pub body: Vec, pub success: Option, @@ -277,6 +325,7 @@ fn response_input_to_code_mode_result(response: ResponseInputItem) -> JsonValue content_items_to_code_mode_result(&items) } }, + ResponseInputItem::ToolSearchOutput { tools, .. } => JsonValue::Array(tools), ResponseInputItem::McpToolCallOutput { output, .. } => { output.code_mode_result(&ToolPayload::Mcp { server: String::new(), @@ -379,6 +428,7 @@ mod tests { use super::*; use core_test_support::assert_regex_match; use pretty_assertions::assert_eq; + use serde_json::json; #[test] fn custom_tool_calls_should_roundtrip_as_custom_outputs() { @@ -505,6 +555,61 @@ mod tests { } } + #[test] + fn tool_search_payloads_roundtrip_as_tool_search_outputs() { + let payload = ToolPayload::ToolSearch { + arguments: SearchToolCallParams { + query: "calendar".to_string(), + limit: None, + }, + }; + let response = ToolSearchOutput { + tools: vec![ToolSearchOutputTool::Function( + crate::client_common::tools::ResponsesApiTool { + name: "create_event".to_string(), + description: String::new(), + strict: false, + defer_loading: Some(true), + parameters: crate::tools::spec::JsonSchema::Object { + properties: Default::default(), + required: None, + additional_properties: None, + }, + output_schema: None, + }, + )], + } + .to_response_item("search-1", &payload); + + match response { + ResponseInputItem::ToolSearchOutput { + call_id, + status, + execution, + tools, + } => { + assert_eq!(call_id, "search-1"); + assert_eq!(status, "completed"); + assert_eq!(execution, "client"); + assert_eq!( + tools, + vec![json!({ + "type": "function", + "name": "create_event", + "description": "", + "strict": false, + "defer_loading": true, + "parameters": { + "type": "object", + "properties": {} + } + })] + ); + } + other => panic!("expected ToolSearchOutput, got {other:?}"), + } + } + #[test] fn log_preview_uses_content_items_when_plain_text_is_missing() { let output = FunctionToolOutput::from_content( diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index b98a721e3..3cbbbd508 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -422,6 +422,7 @@ It is important to remember: "# .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["input".to_string()]), diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 38d0f74f4..1bb9c7acc 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -13,9 +13,9 @@ mod plan; mod read_file; mod request_permissions; mod request_user_input; -mod search_tool_bm25; mod shell; mod test_sync; +mod tool_search; pub(crate) mod unified_exec; mod view_image; @@ -50,12 +50,12 @@ pub use request_permissions::RequestPermissionsHandler; pub(crate) use request_permissions::request_permissions_tool_description; pub use request_user_input::RequestUserInputHandler; pub(crate) use request_user_input::request_user_input_tool_description; -pub(crate) use search_tool_bm25::DEFAULT_LIMIT as SEARCH_TOOL_BM25_DEFAULT_LIMIT; -pub(crate) use search_tool_bm25::SEARCH_TOOL_BM25_TOOL_NAME; -pub use search_tool_bm25::SearchToolBm25Handler; pub use shell::ShellCommandHandler; pub use shell::ShellHandler; pub use test_sync::TestSyncHandler; +pub(crate) use tool_search::DEFAULT_LIMIT as TOOL_SEARCH_DEFAULT_LIMIT; +pub(crate) use tool_search::TOOL_SEARCH_TOOL_NAME; +pub use tool_search::ToolSearchHandler; pub use unified_exec::UnifiedExecHandler; pub use view_image::ViewImageHandler; diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index a2d4e39b9..61ccb06fb 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -1117,6 +1117,7 @@ mod tests { tracker: Arc::new(Mutex::new(TurnDiffTracker::default())), call_id: "call-1".to_string(), tool_name: tool_name.to_string(), + tool_namespace: None, payload, } } diff --git a/codex-rs/core/src/tools/handlers/plan.rs b/codex-rs/core/src/tools/handlers/plan.rs index bd70418a6..9c9d3e959 100644 --- a/codex-rs/core/src/tools/handlers/plan.rs +++ b/codex-rs/core/src/tools/handlers/plan.rs @@ -52,6 +52,7 @@ At most one step can be in_progress at a time. "# .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["plan".to_string()]), diff --git a/codex-rs/core/src/tools/handlers/search_tool_bm25.rs b/codex-rs/core/src/tools/handlers/search_tool_bm25.rs index a038fd5ed..e69de29bb 100644 --- a/codex-rs/core/src/tools/handlers/search_tool_bm25.rs +++ b/codex-rs/core/src/tools/handlers/search_tool_bm25.rs @@ -1,349 +0,0 @@ -use async_trait::async_trait; -use bm25::Document; -use bm25::Language; -use bm25::SearchEngineBuilder; -use codex_app_server_protocol::AppInfo; -use serde::Deserialize; -use serde_json::json; -use std::collections::HashMap; -use std::collections::HashSet; - -use crate::connectors; -use crate::function_tool::FunctionCallError; -use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; -use crate::mcp_connection_manager::ToolInfo; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::handlers::parse_arguments; -use crate::tools::registry::ToolHandler; -use crate::tools::registry::ToolKind; - -pub struct SearchToolBm25Handler; - -pub(crate) const SEARCH_TOOL_BM25_TOOL_NAME: &str = "search_tool_bm25"; -pub(crate) const DEFAULT_LIMIT: usize = 8; - -fn default_limit() -> usize { - DEFAULT_LIMIT -} - -#[derive(Deserialize)] -struct SearchToolBm25Args { - query: String, - #[serde(default = "default_limit")] - limit: usize, -} - -#[derive(Clone)] -struct ToolEntry { - name: String, - server_name: String, - title: Option, - description: Option, - connector_name: Option, - input_keys: Vec, - search_text: String, -} - -impl ToolEntry { - fn new(name: String, info: ToolInfo) -> Self { - let input_keys = info - .tool - .input_schema - .get("properties") - .and_then(serde_json::Value::as_object) - .map(|map| map.keys().cloned().collect::>()) - .unwrap_or_default(); - let search_text = build_search_text(&name, &info, &input_keys); - Self { - name, - server_name: info.server_name, - title: info.tool.title, - description: info - .tool - .description - .map(|description| description.to_string()), - connector_name: info.connector_name, - input_keys, - search_text, - } - } -} - -#[async_trait] -impl ToolHandler for SearchToolBm25Handler { - type Output = FunctionToolOutput; - - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - payload, - session, - turn, - .. - } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::Fatal(format!( - "{SEARCH_TOOL_BM25_TOOL_NAME} handler received unsupported payload" - ))); - } - }; - - let args: SearchToolBm25Args = parse_arguments(&arguments)?; - let query = args.query.trim(); - if query.is_empty() { - return Err(FunctionCallError::RespondToModel( - "query must not be empty".to_string(), - )); - } - - if args.limit == 0 { - return Err(FunctionCallError::RespondToModel( - "limit must be greater than zero".to_string(), - )); - } - - let limit = args.limit; - - let mcp_tools = session - .services - .mcp_connection_manager - .read() - .await - .list_all_tools() - .await; - - let connectors = connectors::with_app_enabled_state( - connectors::accessible_connectors_from_mcp_tools(&mcp_tools), - &turn.config, - ); - let mcp_tools = filter_codex_apps_mcp_tools(mcp_tools, &connectors); - let mcp_tools = connectors::filter_codex_apps_tools_by_policy(mcp_tools, &turn.config); - - let mut entries: Vec = mcp_tools - .into_iter() - .map(|(name, info)| ToolEntry::new(name, info)) - .collect(); - entries.sort_by(|a, b| a.name.cmp(&b.name)); - - if entries.is_empty() { - let active_selected_tools = session.get_mcp_tool_selection().await.unwrap_or_default(); - let content = json!({ - "query": query, - "total_tools": 0, - "active_selected_tools": active_selected_tools, - "tools": [], - }) - .to_string(); - return Ok(FunctionToolOutput::from_text(content, Some(true))); - } - - let documents: Vec> = entries - .iter() - .enumerate() - .map(|(idx, entry)| Document::new(idx, entry.search_text.clone())) - .collect(); - let search_engine = - SearchEngineBuilder::::with_documents(Language::English, documents).build(); - let results = search_engine.search(query, limit); - - let mut selected_tools = Vec::new(); - let mut result_payloads = Vec::new(); - for result in results { - let Some(entry) = entries.get(result.document.id) else { - continue; - }; - selected_tools.push(entry.name.clone()); - result_payloads.push(json!({ - "name": entry.name.clone(), - "server": entry.server_name.clone(), - "title": entry.title.clone(), - "description": entry.description.clone(), - "connector_name": entry.connector_name.clone(), - "input_keys": entry.input_keys.clone(), - "score": result.score, - })); - } - - let active_selected_tools = session.merge_mcp_tool_selection(selected_tools).await; - - let content = json!({ - "query": query, - "total_tools": entries.len(), - "active_selected_tools": active_selected_tools, - "tools": result_payloads, - }) - .to_string(); - - Ok(FunctionToolOutput::from_text(content, Some(true))) - } -} - -fn filter_codex_apps_mcp_tools( - mut mcp_tools: HashMap, - connectors: &[AppInfo], -) -> HashMap { - let enabled_connectors: HashSet<&str> = connectors - .iter() - .filter(|connector| connector.is_enabled) - .map(|connector| connector.id.as_str()) - .collect(); - - mcp_tools.retain(|_, tool| { - if tool.server_name != CODEX_APPS_MCP_SERVER_NAME { - return false; - } - - tool.connector_id - .as_deref() - .is_some_and(|connector_id| enabled_connectors.contains(connector_id)) - }); - mcp_tools -} - -fn build_search_text(name: &str, info: &ToolInfo, input_keys: &[String]) -> String { - let mut parts = vec![ - name.to_string(), - info.tool_name.clone(), - info.server_name.clone(), - ]; - - if let Some(title) = info.tool.title.as_deref() - && !title.trim().is_empty() - { - parts.push(title.to_string()); - } - - if let Some(description) = info.tool.description.as_deref() - && !description.trim().is_empty() - { - parts.push(description.to_string()); - } - - if let Some(connector_name) = info.connector_name.as_deref() - && !connector_name.trim().is_empty() - { - parts.push(connector_name.to_string()); - } - - if !input_keys.is_empty() { - parts.extend(input_keys.iter().cloned()); - } - - parts.join(" ") -} - -#[cfg(test)] -mod tests { - use super::*; - use codex_app_server_protocol::AppInfo; - use pretty_assertions::assert_eq; - use rmcp::model::JsonObject; - use rmcp::model::Tool; - use std::sync::Arc; - - fn make_connector(id: &str, enabled: bool) -> AppInfo { - AppInfo { - id: id.to_string(), - name: id.to_string(), - description: None, - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: None, - is_accessible: true, - is_enabled: enabled, - plugin_display_names: Vec::new(), - } - } - - fn make_tool( - qualified_name: &str, - server_name: &str, - tool_name: &str, - connector_id: Option<&str>, - ) -> (String, ToolInfo) { - ( - qualified_name.to_string(), - ToolInfo { - server_name: server_name.to_string(), - tool_name: tool_name.to_string(), - tool: Tool { - name: tool_name.to_string().into(), - title: None, - description: Some(format!("Test tool: {tool_name}").into()), - input_schema: Arc::new(JsonObject::default()), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, - connector_id: connector_id.map(str::to_string), - connector_name: connector_id.map(str::to_string), - plugin_display_names: Vec::new(), - }, - ) - } - - #[test] - fn filter_codex_apps_mcp_tools_keeps_enabled_apps_only() { - let mcp_tools = HashMap::from([ - make_tool( - "mcp__codex_apps__calendar_create_event", - CODEX_APPS_MCP_SERVER_NAME, - "calendar_create_event", - Some("calendar"), - ), - make_tool( - "mcp__codex_apps__drive_search", - CODEX_APPS_MCP_SERVER_NAME, - "drive_search", - Some("drive"), - ), - make_tool("mcp__rmcp__echo", "rmcp", "echo", None), - ]); - let connectors = vec![ - make_connector("calendar", false), - make_connector("drive", true), - ]; - - let mut filtered: Vec = filter_codex_apps_mcp_tools(mcp_tools, &connectors) - .into_keys() - .collect(); - filtered.sort(); - - assert_eq!(filtered, vec!["mcp__codex_apps__drive_search".to_string()]); - } - - #[test] - fn filter_codex_apps_mcp_tools_drops_apps_without_connector_id() { - let mcp_tools = HashMap::from([ - make_tool( - "mcp__codex_apps__unknown", - CODEX_APPS_MCP_SERVER_NAME, - "unknown", - None, - ), - make_tool("mcp__rmcp__echo", "rmcp", "echo", None), - ]); - - let mut filtered: Vec = - filter_codex_apps_mcp_tools(mcp_tools, &[make_connector("calendar", true)]) - .into_keys() - .collect(); - filtered.sort(); - - assert_eq!(filtered, Vec::::new()); - } -} diff --git a/codex-rs/core/src/tools/handlers/tool_search.rs b/codex-rs/core/src/tools/handlers/tool_search.rs new file mode 100644 index 000000000..356b64ec9 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/tool_search.rs @@ -0,0 +1,390 @@ +use crate::client_common::tools::ResponsesApiNamespace; +use crate::client_common::tools::ResponsesApiNamespaceTool; +use crate::client_common::tools::ToolSearchOutputTool; +use crate::function_tool::FunctionCallError; +use crate::mcp_connection_manager::ToolInfo; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::context::ToolSearchOutput; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use crate::tools::spec::mcp_tool_to_deferred_openai_tool; +use async_trait::async_trait; +use bm25::Document; +use bm25::Language; +use bm25::SearchEngineBuilder; +use std::collections::BTreeMap; +use std::collections::HashMap; + +#[cfg(test)] +use crate::client_common::tools::ResponsesApiTool; + +pub struct ToolSearchHandler { + tools: HashMap, +} + +pub(crate) const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; +pub(crate) const DEFAULT_LIMIT: usize = 8; + +impl ToolSearchHandler { + pub fn new(tools: HashMap) -> Self { + Self { tools } + } +} + +#[async_trait] +impl ToolHandler for ToolSearchHandler { + type Output = ToolSearchOutput; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result { + 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(DEFAULT_LIMIT); + + if limit == 0 { + return Err(FunctionCallError::RespondToModel( + "limit must be greater than zero".to_string(), + )); + } + + let mut entries: Vec<(String, ToolInfo)> = self.tools.clone().into_iter().collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + + if entries.is_empty() { + return Ok(ToolSearchOutput { tools: Vec::new() }); + } + + let documents: Vec> = entries + .iter() + .enumerate() + .map(|(idx, (name, info))| Document::new(idx, build_search_text(name, info))) + .collect(); + let search_engine = + SearchEngineBuilder::::with_documents(Language::English, documents).build(); + let results = search_engine.search(query, limit); + + let matched_entries = results + .into_iter() + .filter_map(|result| entries.get(result.document.id)) + .collect::>(); + let tools = serialize_tool_search_output_tools(&matched_entries).map_err(|err| { + FunctionCallError::Fatal(format!("failed to encode tool_search output: {err}")) + })?; + + Ok(ToolSearchOutput { tools }) + } +} + +fn serialize_tool_search_output_tools( + matched_entries: &[&(String, ToolInfo)], +) -> Result, serde_json::Error> { + let grouped: BTreeMap> = + matched_entries + .iter() + .fold(BTreeMap::new(), |mut acc, (_name, tool)| { + acc.entry(tool.tool_namespace.clone()) + .or_default() + .push(tool.clone()); + + acc + }); + + let mut results = Vec::with_capacity(grouped.len()); + for (namespace, tools) in grouped { + let Some(first_tool) = tools.first() else { + continue; + }; + + let description = first_tool.connector_description.clone().or_else(|| { + first_tool + .connector_name + .as_deref() + .map(str::trim) + .filter(|connector_name| !connector_name.is_empty()) + .map(|connector_name| format!("Tools for working with {connector_name}.")) + }); + + let tools = tools + .iter() + .map(|tool| { + mcp_tool_to_deferred_openai_tool(tool.tool_name.clone(), tool.tool.clone()) + .map(ResponsesApiNamespaceTool::Function) + }) + .collect::, _>>()?; + + results.push(ToolSearchOutputTool::Namespace(ResponsesApiNamespace { + name: namespace, + description: description.unwrap_or_default(), + tools, + })); + } + + Ok(results) +} + +fn build_search_text(name: &str, info: &ToolInfo) -> String { + let mut parts = vec![ + name.to_string(), + info.tool_name.clone(), + info.server_name.clone(), + ]; + + if let Some(title) = info.tool.title.as_deref() + && !title.trim().is_empty() + { + parts.push(title.to_string()); + } + + if let Some(description) = info.tool.description.as_deref() + && !description.trim().is_empty() + { + parts.push(description.to_string()); + } + + if let Some(connector_name) = info.connector_name.as_deref() + && !connector_name.trim().is_empty() + { + parts.push(connector_name.to_string()); + } + + if let Some(connector_description) = info.connector_description.as_deref() + && !connector_description.trim().is_empty() + { + parts.push(connector_description.to_string()); + } + + parts.extend( + info.tool + .input_schema + .get("properties") + .and_then(serde_json::Value::as_object) + .map(|map| map.keys().cloned().collect::>()) + .unwrap_or_default(), + ); + + parts.join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; + use pretty_assertions::assert_eq; + use rmcp::model::JsonObject; + use rmcp::model::Tool; + use serde_json::json; + use std::sync::Arc; + + #[test] + fn serialize_tool_search_output_tools_groups_results_by_namespace() { + let entries = [ + ( + "mcp__codex_apps__calendar-create-event".to_string(), + ToolInfo { + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: "-create-event".to_string(), + tool_namespace: "mcp__codex_apps__calendar".to_string(), + tool: Tool { + name: "calendar-create-event".to_string().into(), + title: None, + description: Some("Create a calendar event.".into()), + input_schema: Arc::new(JsonObject::from_iter([( + "type".to_string(), + json!("object"), + )])), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + }, + connector_id: Some("calendar".to_string()), + connector_name: Some("Calendar".to_string()), + plugin_display_names: Vec::new(), + connector_description: Some("Plan events".to_string()), + }, + ), + ( + "mcp__codex_apps__gmail-read-email".to_string(), + ToolInfo { + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: "-read-email".to_string(), + tool_namespace: "mcp__codex_apps__gmail".to_string(), + tool: Tool { + name: "gmail-read-email".to_string().into(), + title: None, + description: Some("Read an email.".into()), + input_schema: Arc::new(JsonObject::from_iter([( + "type".to_string(), + json!("object"), + )])), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + }, + connector_id: Some("gmail".to_string()), + connector_name: Some("Gmail".to_string()), + plugin_display_names: Vec::new(), + connector_description: Some("Read mail".to_string()), + }, + ), + ( + "mcp__codex_apps__calendar-list-events".to_string(), + ToolInfo { + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: "-list-events".to_string(), + tool_namespace: "mcp__codex_apps__calendar".to_string(), + tool: Tool { + name: "calendar-list-events".to_string().into(), + title: None, + description: Some("List calendar events.".into()), + input_schema: Arc::new(JsonObject::from_iter([( + "type".to_string(), + json!("object"), + )])), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + }, + connector_id: Some("calendar".to_string()), + connector_name: Some("Calendar".to_string()), + plugin_display_names: Vec::new(), + connector_description: Some("Plan events".to_string()), + }, + ), + ]; + + let tools = serialize_tool_search_output_tools(&[&entries[0], &entries[1], &entries[2]]) + .expect("serialize tool search output"); + + assert_eq!( + tools, + vec![ + ToolSearchOutputTool::Namespace(ResponsesApiNamespace { + name: "mcp__codex_apps__calendar".to_string(), + description: "Plan events".to_string(), + tools: vec![ + ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: "-create-event".to_string(), + description: "Create a calendar event.".to_string(), + strict: false, + defer_loading: Some(true), + parameters: crate::tools::spec::JsonSchema::Object { + properties: Default::default(), + required: None, + additional_properties: None, + }, + output_schema: None, + }), + ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: "-list-events".to_string(), + description: "List calendar events.".to_string(), + strict: false, + defer_loading: Some(true), + parameters: crate::tools::spec::JsonSchema::Object { + properties: Default::default(), + required: None, + additional_properties: None, + }, + output_schema: None, + }), + ], + }), + ToolSearchOutputTool::Namespace(ResponsesApiNamespace { + name: "mcp__codex_apps__gmail".to_string(), + description: "Read mail".to_string(), + tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: "-read-email".to_string(), + description: "Read an email.".to_string(), + strict: false, + defer_loading: Some(true), + parameters: crate::tools::spec::JsonSchema::Object { + properties: Default::default(), + required: None, + additional_properties: None, + }, + output_schema: None, + })], + }) + ] + ); + } + + #[test] + fn serialize_tool_search_output_tools_falls_back_to_connector_name_description() { + let entries = [( + "mcp__codex_apps__gmail-batch-read-email".to_string(), + ToolInfo { + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: "-batch-read-email".to_string(), + tool_namespace: "mcp__codex_apps__gmail".to_string(), + tool: Tool { + name: "gmail-batch-read-email".to_string().into(), + title: None, + description: Some("Read multiple emails.".into()), + input_schema: Arc::new(JsonObject::from_iter([( + "type".to_string(), + json!("object"), + )])), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + }, + connector_id: Some("connector_gmail_456".to_string()), + connector_name: Some("Gmail".to_string()), + plugin_display_names: Vec::new(), + connector_description: None, + }, + )]; + + let tools = serialize_tool_search_output_tools(&[&entries[0]]).expect("serialize"); + + assert_eq!( + tools, + vec![ToolSearchOutputTool::Namespace(ResponsesApiNamespace { + name: "mcp__codex_apps__gmail".to_string(), + description: "Tools for working with Gmail.".to_string(), + tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: "-batch-read-email".to_string(), + description: "Read multiple emails.".to_string(), + strict: false, + defer_loading: Some(true), + parameters: crate::tools::spec::JsonSchema::Object { + properties: Default::default(), + required: None, + additional_properties: None, + }, + output_schema: None, + })], + })] + ); + } +} diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index 2fa0ab241..6fe78a728 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -637,6 +637,16 @@ impl JsReplManager { summary.result_is_error = Some(!output.success()); summary } + ResponseInputItem::ToolSearchOutput { tools, .. } => JsReplToolCallResponseSummary { + response_type: Some("tool_search_output".to_string()), + payload_kind: Some(JsReplToolCallPayloadKind::FunctionText), + payload_text_preview: Some(serde_json::Value::Array(tools.clone()).to_string()), + payload_text_length: Some( + serde_json::Value::Array(tools.clone()).to_string().len(), + ), + payload_item_count: Some(tools.len()), + ..Default::default() + }, } } @@ -1360,26 +1370,30 @@ impl JsReplManager { exec.turn.dynamic_tools.as_slice(), ); - let payload = - if let Some((server, tool)) = exec.session.parse_mcp_tool_name(&req.tool_name).await { - crate::tools::context::ToolPayload::Mcp { - server, - tool, - raw_arguments: req.arguments.clone(), - } - } else if is_freeform_tool(&router.specs(), &req.tool_name) { - crate::tools::context::ToolPayload::Custom { - input: req.arguments.clone(), - } - } else { - crate::tools::context::ToolPayload::Function { - arguments: req.arguments.clone(), - } - }; + let payload = if let Some((server, tool)) = exec + .session + .parse_mcp_tool_name(&req.tool_name, &None) + .await + { + crate::tools::context::ToolPayload::Mcp { + server, + tool, + raw_arguments: req.arguments.clone(), + } + } else if is_freeform_tool(&router.specs(), &req.tool_name) { + crate::tools::context::ToolPayload::Custom { + input: req.arguments.clone(), + } + } else { + crate::tools::context::ToolPayload::Function { + arguments: req.arguments.clone(), + } + }; let tool_name = req.tool_name.clone(); let call = crate::tools::router::ToolCall { tool_name: tool_name.clone(), + tool_namespace: None, call_id: req.id.clone(), payload, }; diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index 634d1ca71..fad7f5776 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -122,6 +122,12 @@ impl ToolCallRuntime { ..Default::default() }, }, + ToolPayload::ToolSearch { .. } => ResponseInputItem::ToolSearchOutput { + call_id: call.call_id.clone(), + status: "completed".to_string(), + execution: "client".to_string(), + tools: Vec::new(), + }, ToolPayload::Mcp { .. } => ResponseInputItem::McpToolCallOutput { call_id: call.call_id.clone(), output: codex_protocol::mcp::CallToolResult::from_error_text(Self::abort_message( diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index f78df2f1a..47c2e12b6 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -40,6 +40,7 @@ pub trait ToolHandler: Send + Sync { matches!( (self.kind(), payload), (ToolKind::Function, ToolPayload::Function { .. }) + | (ToolKind::Function, ToolPayload::ToolSearch { .. }) | (ToolKind::Mcp, ToolPayload::Mcp { .. }) ) } @@ -121,6 +122,14 @@ where } } +pub(crate) fn tool_handler_key(tool_name: &str, namespace: Option<&str>) -> String { + if let Some(namespace) = namespace { + format!("{namespace}:{tool_name}") + } else { + tool_name.to_string() + } +} + pub struct ToolRegistry { handlers: HashMap>, } @@ -130,8 +139,15 @@ impl ToolRegistry { Self { handlers } } - fn handler(&self, name: &str) -> Option> { - self.handlers.get(name).map(Arc::clone) + fn handler(&self, name: &str, namespace: Option<&str>) -> Option> { + self.handlers + .get(&tool_handler_key(name, namespace)) + .map(Arc::clone) + } + + #[cfg(test)] + pub(crate) fn has_handler(&self, name: &str, namespace: Option<&str>) -> bool { + self.handler(name, namespace).is_some() } // TODO(jif) for dynamic tools. @@ -147,6 +163,7 @@ impl ToolRegistry { invocation: ToolInvocation, ) -> Result { let tool_name = invocation.tool_name.clone(); + let tool_namespace = invocation.tool_namespace.clone(); let call_id_owned = invocation.call_id.clone(); let otel = invocation.turn.session_telemetry.clone(); let payload_for_response = invocation.payload.clone(); @@ -192,11 +209,14 @@ impl ToolRegistry { } } - let handler = match self.handler(tool_name.as_ref()) { + let handler = match self.handler(tool_name.as_ref(), tool_namespace.as_deref()) { Some(handler) => handler, None => { - let message = - unsupported_tool_call_message(&invocation.payload, tool_name.as_ref()); + let message = unsupported_tool_call_message( + &invocation.payload, + tool_name.as_ref(), + tool_namespace.as_deref(), + ); otel.tool_result_with_tags( tool_name.as_ref(), &call_id_owned, @@ -377,7 +397,12 @@ impl ToolRegistryBuilder { } } -fn unsupported_tool_call_message(payload: &ToolPayload, tool_name: &str) -> String { +fn unsupported_tool_call_message( + payload: &ToolPayload, + tool_name: &str, + namespace: Option<&str>, +) -> String { + let tool_name = tool_handler_key(tool_name, namespace); match payload { ToolPayload::Custom { .. } => format!("unsupported custom tool call: {tool_name}"), _ => format!("unsupported call: {tool_name}"), @@ -401,6 +426,13 @@ impl From<&ToolPayload> for HookToolInput { ToolPayload::Function { arguments } => HookToolInput::Function { arguments: arguments.clone(), }, + ToolPayload::ToolSearch { arguments } => HookToolInput::Function { + arguments: serde_json::json!({ + "query": arguments.query, + "limit": arguments.limit, + }) + .to_string(), + }, ToolPayload::Custom { input } => HookToolInput::Custom { input: input.clone(), }, @@ -513,3 +545,60 @@ async fn dispatch_after_tool_use_hook( None } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::context::ToolInvocation; + use async_trait::async_trait; + use pretty_assertions::assert_eq; + + struct TestHandler; + + #[async_trait] + impl ToolHandler for TestHandler { + type Output = crate::tools::context::FunctionToolOutput; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle( + &self, + _invocation: ToolInvocation, + ) -> Result { + unreachable!("test handler should not be invoked") + } + } + + #[test] + fn handler_looks_up_namespaced_aliases_explicitly() { + let plain_handler = Arc::new(TestHandler) as Arc; + let namespaced_handler = Arc::new(TestHandler) as Arc; + let namespace = "mcp__codex_apps__gmail"; + let tool_name = "gmail_get_recent_emails"; + let namespaced_name = tool_handler_key(tool_name, Some(namespace)); + let registry = ToolRegistry::new(HashMap::from([ + (tool_name.to_string(), Arc::clone(&plain_handler)), + (namespaced_name, Arc::clone(&namespaced_handler)), + ])); + + let plain = registry.handler(tool_name, None); + let namespaced = registry.handler(tool_name, Some(namespace)); + let missing_namespaced = registry.handler(tool_name, Some("mcp__codex_apps__calendar")); + + assert_eq!(plain.is_some(), true); + assert_eq!(namespaced.is_some(), true); + assert_eq!(missing_namespaced.is_none(), true); + assert!( + plain + .as_ref() + .is_some_and(|handler| Arc::ptr_eq(handler, &plain_handler)) + ); + assert!( + namespaced + .as_ref() + .is_some_and(|handler| Arc::ptr_eq(handler, &namespaced_handler)) + ); + } +} diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 7095a38ce..4482c34bb 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -8,6 +8,7 @@ use crate::tools::context::FunctionToolOutput; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::ToolSearchOutput; use crate::tools::registry::AnyToolResult; use crate::tools::registry::ConfiguredToolSpec; use crate::tools::registry::ToolRegistry; @@ -17,6 +18,7 @@ use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::LocalShellAction; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; +use codex_protocol::models::SearchToolCallParams; use codex_protocol::models::ShellToolCallParams; use rmcp::model::Tool; use std::collections::HashMap; @@ -28,6 +30,7 @@ pub use crate::tools::context::ToolCallSource; #[derive(Clone, Debug)] pub struct ToolCall { pub tool_name: String, + pub tool_namespace: Option, pub call_id: String, pub payload: ToolPayload, } @@ -72,13 +75,15 @@ impl ToolRouter { match item { ResponseItem::FunctionCall { name, + namespace, arguments, call_id, .. } => { - if let Some((server, tool)) = session.parse_mcp_tool_name(&name).await { + if let Some((server, tool)) = session.parse_mcp_tool_name(&name, &namespace).await { Ok(Some(ToolCall { tool_name: name, + tool_namespace: namespace, call_id, payload: ToolPayload::Mcp { server, @@ -89,11 +94,32 @@ impl ToolRouter { } else { Ok(Some(ToolCall { tool_name: name, + tool_namespace: namespace, call_id, payload: ToolPayload::Function { arguments }, })) } } + 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: "tool_search".to_string(), + tool_namespace: None, + call_id, + payload: ToolPayload::ToolSearch { arguments }, + })) + } + ResponseItem::ToolSearchCall { .. } => Ok(None), ResponseItem::CustomToolCall { name, input, @@ -101,6 +127,7 @@ impl ToolRouter { .. } => Ok(Some(ToolCall { tool_name: name, + tool_namespace: None, call_id, payload: ToolPayload::Custom { input }, })), @@ -127,6 +154,7 @@ impl ToolRouter { }; Ok(Some(ToolCall { tool_name: "local_shell".to_string(), + tool_namespace: None, call_id, payload: ToolPayload::LocalShell { params }, })) @@ -163,10 +191,12 @@ impl ToolRouter { ) -> Result { let ToolCall { tool_name, + tool_namespace, call_id, payload, } = call; let payload_outputs_custom = matches!(payload, ToolPayload::Custom { .. }); + let payload_outputs_tool_search = matches!(payload, ToolPayload::ToolSearch { .. }); let failure_call_id = call_id.clone(); if source == ToolCallSource::Direct @@ -180,6 +210,7 @@ impl ToolRouter { return Ok(Self::failure_result( failure_call_id, payload_outputs_custom, + payload_outputs_tool_search, err, )); } @@ -190,6 +221,7 @@ impl ToolRouter { tracker, call_id, tool_name, + tool_namespace, payload, }; @@ -199,6 +231,7 @@ impl ToolRouter { Err(err) => Ok(Self::failure_result( failure_call_id, payload_outputs_custom, + payload_outputs_tool_search, err, )), } @@ -207,10 +240,22 @@ impl ToolRouter { fn failure_result( call_id: String, payload_outputs_custom: bool, + payload_outputs_tool_search: bool, err: FunctionCallError, ) -> AnyToolResult { let message = err.to_string(); - if payload_outputs_custom { + if payload_outputs_tool_search { + AnyToolResult { + call_id, + payload: ToolPayload::ToolSearch { + arguments: SearchToolCallParams { + query: String::new(), + limit: None, + }, + }, + result: Box::new(ToolSearchOutput { tools: Vec::new() }), + } + } else if payload_outputs_custom { AnyToolResult { call_id, payload: ToolPayload::Custom { @@ -237,6 +282,7 @@ mod tests { use crate::tools::context::ToolPayload; use crate::turn_diff_tracker::TurnDiffTracker; use codex_protocol::models::ResponseInputItem; + use codex_protocol::models::ResponseItem; use super::ToolCall; use super::ToolCallSource; @@ -271,6 +317,7 @@ mod tests { let call = ToolCall { tool_name: "shell".to_string(), + tool_namespace: None, call_id: "call-1".to_string(), payload: ToolPayload::Function { arguments: "{}".to_string(), @@ -324,6 +371,7 @@ mod tests { let call = ToolCall { tool_name: "shell".to_string(), + tool_namespace: None, call_id: "call-2".to_string(), payload: ToolPayload::Function { arguments: "{}".to_string(), @@ -347,4 +395,39 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn build_tool_call_uses_namespace_for_registry_name() -> anyhow::Result<()> { + let (session, _) = make_session_and_context().await; + let session = Arc::new(session); + let tool_name = "create_event".to_string(); + + let call = ToolRouter::build_tool_call( + &session, + ResponseItem::FunctionCall { + id: None, + name: tool_name.clone(), + namespace: Some("mcp__codex_apps__calendar".to_string()), + arguments: "{}".to_string(), + call_id: "call-namespace".to_string(), + }, + ) + .await? + .expect("function_call should produce a tool call"); + + assert_eq!(call.tool_name, tool_name); + assert_eq!( + call.tool_namespace, + Some("mcp__codex_apps__calendar".to_string()) + ); + assert_eq!(call.call_id, "call-namespace"); + match call.payload { + ToolPayload::Function { arguments } => { + assert_eq!(arguments, "{}"); + } + other => panic!("expected function payload, got {other:?}"), + } + + Ok(()) + } } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 8837fea67..748333ea9 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -11,8 +11,8 @@ use crate::original_image_detail::can_request_original_image_detail; use crate::tools::code_mode::PUBLIC_TOOL_NAME; use crate::tools::code_mode_description::augment_tool_spec_for_code_mode; use crate::tools::handlers::PLAN_TOOL; -use crate::tools::handlers::SEARCH_TOOL_BM25_DEFAULT_LIMIT; -use crate::tools::handlers::SEARCH_TOOL_BM25_TOOL_NAME; +use crate::tools::handlers::TOOL_SEARCH_DEFAULT_LIMIT; +use crate::tools::handlers::TOOL_SEARCH_TOOL_NAME; use crate::tools::handlers::agent_jobs::BatchJobHandler; use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool; use crate::tools::handlers::apply_patch::create_apply_patch_json_tool; @@ -22,6 +22,7 @@ use crate::tools::handlers::multi_agents::MIN_WAIT_TIMEOUT_MS; use crate::tools::handlers::request_permissions_tool_description; use crate::tools::handlers::request_user_input_tool_description; use crate::tools::registry::ToolRegistryBuilder; +use crate::tools::registry::tool_handler_key; use codex_protocol::config_types::WebSearchConfig; use codex_protocol::config_types::WebSearchMode; use codex_protocol::dynamic_tools::DynamicToolSpec; @@ -41,7 +42,7 @@ use serde_json::json; use std::collections::BTreeMap; use std::collections::HashMap; -const SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE: &str = +const TOOL_SEARCH_DESCRIPTION_TEMPLATE: &str = include_str!("../../templates/search_tool/tool_description.md"); const WEB_SEARCH_CONTENT_TYPES: [&str; 2] = ["text", "image"]; @@ -519,6 +520,7 @@ fn create_exec_command_tool(allow_login_shell: bool, request_permission_enabled: "Runs a command in a PTY, returning output or a session ID for ongoing interaction." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["cmd".to_string()]), @@ -567,6 +569,7 @@ fn create_write_stdin_tool() -> ToolSpec { "Writes characters to an existing unified exec session and returns recent output." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["session_id".to_string()]), @@ -621,6 +624,7 @@ Examples of valid command strings: name: "shell".to_string(), description, strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["command".to_string()]), @@ -689,6 +693,7 @@ Examples of valid command strings: name: "shell_command".to_string(), description, strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["command".to_string()]), @@ -722,6 +727,7 @@ fn create_view_image_tool(can_request_original_image_detail: bool) -> ToolSpec { description: "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within tags)." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["path".to_string()]), @@ -870,6 +876,7 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec { - The key is to find opportunities to spawn multiple independent subtasks in parallel within the same round, while ensuring each subtask is well-defined, self-contained, and materially advances the main task."# ), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: None, @@ -976,6 +983,7 @@ fn create_spawn_agents_on_csv_tool() -> ToolSpec { description: "Process a CSV by spawning one worker sub-agent per row. The instruction string is a template where `{column}` placeholders are replaced with row values. Each worker must call `report_agent_job_result` with a JSON object (matching `output_schema` when provided); missing reports are treated as failures. This call blocks until all rows finish and automatically exports results to `output_csv_path` (or a default path)." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["csv_path".to_string(), "instruction".to_string()]), @@ -1022,6 +1030,7 @@ fn create_report_agent_job_result_tool() -> ToolSpec { "Worker-only tool to report a result for an agent job item. Main agents should not call this." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec![ @@ -1069,6 +1078,7 @@ fn create_send_input_tool() -> ToolSpec { description: "Send a message to an existing agent. Use interrupt=true to redirect work immediately. You should reuse the agent by send_input if you believe your assigned task is highly dependent on the context of a previous task." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["id".to_string()]), @@ -1093,6 +1103,7 @@ fn create_resume_agent_tool() -> ToolSpec { "Resume a previously closed agent by id so it can receive send_input and wait calls." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["id".to_string()]), @@ -1128,6 +1139,7 @@ fn create_wait_tool() -> ToolSpec { description: "Wait for agents to reach a final status. Completed statuses may include the agent's final message. Returns empty status when timed out. Once the agent reaches a final status, a notification message will be received containing the same completed status." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["ids".to_string()]), @@ -1214,6 +1226,7 @@ fn create_request_user_input_tool( collaboration_modes_config.default_mode_request_user_input, ), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["questions".to_string()]), @@ -1239,6 +1252,7 @@ fn create_request_permissions_tool() -> ToolSpec { name: "request_permissions".to_string(), description: request_permissions_tool_description(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["permissions".to_string()]), @@ -1261,6 +1275,7 @@ fn create_close_agent_tool() -> ToolSpec { name: "close_agent".to_string(), description: "Close an agent when it is no longer needed and return its last known status. Don't keep agents open for too long if they are not needed anymore.".to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["id".to_string()]), @@ -1329,6 +1344,7 @@ fn create_test_sync_tool() -> ToolSpec { name: "test_sync_tool".to_string(), description: "Internal synchronization helper used by Codex integration tests.".to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: None, @@ -1381,6 +1397,7 @@ fn create_grep_files_tool() -> ToolSpec { time." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["pattern".to_string()]), @@ -1390,7 +1407,7 @@ fn create_grep_files_tool() -> ToolSpec { }) } -fn create_search_tool_bm25_tool(app_tools: &HashMap) -> ToolSpec { +fn create_tool_search_tool(app_tools: &HashMap) -> ToolSpec { let properties = BTreeMap::from([ ( "query".to_string(), @@ -1402,7 +1419,7 @@ fn create_search_tool_bm25_tool(app_tools: &HashMap) -> ToolSp "limit".to_string(), JsonSchema::Number { description: Some(format!( - "Maximum number of tools to return (defaults to {SEARCH_TOOL_BM25_DEFAULT_LIMIT})." + "Maximum number of tools to return (defaults to {TOOL_SEARCH_DEFAULT_LIMIT})." )), }, ), @@ -1416,24 +1433,22 @@ fn create_search_tool_bm25_tool(app_tools: &HashMap) -> ToolSp let app_names = app_names.join(", "); let description = if app_names.is_empty() { - SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE + TOOL_SEARCH_DESCRIPTION_TEMPLATE .replace("({{app_names}})", "(None currently enabled)") .replace("{{app_names}}", "available apps") } else { - SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE.replace("{{app_names}}", app_names.as_str()) + TOOL_SEARCH_DESCRIPTION_TEMPLATE.replace("{{app_names}}", app_names.as_str()) }; - ToolSpec::Function(ResponsesApiTool { - name: SEARCH_TOOL_BM25_TOOL_NAME.to_string(), + ToolSpec::ToolSearch { + execution: "client".to_string(), description, - strict: false, parameters: JsonSchema::Object { properties, required: Some(vec!["query".to_string()]), additional_properties: Some(false.into()), }, - output_schema: None, - }) + } } fn create_read_file_tool() -> ToolSpec { @@ -1531,6 +1546,7 @@ fn create_read_file_tool() -> ToolSpec { "Reads a local file with 1-indexed line numbers, supporting slice and indentation-aware block modes." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["file_path".to_string()]), @@ -1578,6 +1594,7 @@ fn create_list_dir_tool() -> ToolSpec { "Lists entries in a local directory with 1-indexed entry numbers and simple type labels." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["dir_path".to_string()]), @@ -1653,6 +1670,7 @@ fn create_js_repl_reset_tool() -> ToolSpec { "Restarts the js_repl kernel for this run and clears persisted top-level bindings." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties: BTreeMap::new(), required: None, @@ -1718,6 +1736,7 @@ fn create_list_mcp_resources_tool() -> ToolSpec { name: "list_mcp_resources".to_string(), description: "Lists resources provided by MCP servers. Resources allow servers to share data that provides context to language models, such as files, database schemas, or application-specific information. Prefer resources over web search when possible.".to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: None, @@ -1753,6 +1772,7 @@ fn create_list_mcp_resource_templates_tool() -> ToolSpec { name: "list_mcp_resource_templates".to_string(), description: "Lists resource templates provided by MCP servers. Parameterized resource templates allow servers to share data that takes parameters and provides context to language models, such as files, database schemas, or application-specific information. Prefer resource templates over web search when possible.".to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: None, @@ -1790,6 +1810,7 @@ fn create_read_mcp_resource_tool() -> ToolSpec { "Read a specific resource from an MCP server given the server name and resource URI." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["server".to_string(), "uri".to_string()]), @@ -1839,6 +1860,59 @@ pub(crate) fn mcp_tool_to_openai_tool( fully_qualified_name: String, tool: rmcp::model::Tool, ) -> Result { + let (description, input_schema, output_schema) = mcp_tool_to_openai_tool_parts(tool)?; + + Ok(ResponsesApiTool { + name: fully_qualified_name, + description, + strict: false, + defer_loading: None, + parameters: input_schema, + output_schema, + }) +} + +pub(crate) fn mcp_tool_to_deferred_openai_tool( + name: String, + tool: rmcp::model::Tool, +) -> Result { + let (description, input_schema, _) = mcp_tool_to_openai_tool_parts(tool)?; + + Ok(ResponsesApiTool { + name, + description, + strict: false, + defer_loading: Some(true), + parameters: input_schema, + output_schema: None, + }) +} + +fn dynamic_tool_to_openai_tool( + tool: &DynamicToolSpec, +) -> Result { + let input_schema = parse_tool_input_schema(&tool.input_schema)?; + + Ok(ResponsesApiTool { + name: tool.name.clone(), + description: tool.description.clone(), + strict: false, + defer_loading: None, + parameters: input_schema, + output_schema: None, + }) +} + +/// Parse the tool input_schema or return an error for invalid schema +pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result { + let mut input_schema = input_schema.clone(); + sanitize_json_schema(&mut input_schema); + serde_json::from_value::(input_schema) +} + +fn mcp_tool_to_openai_tool_parts( + tool: rmcp::model::Tool, +) -> Result<(String, JsonSchema, Option), serde_json::Error> { let rmcp::model::Tool { description, input_schema, @@ -1873,35 +1947,9 @@ pub(crate) fn mcp_tool_to_openai_tool( let output_schema = Some(mcp_call_tool_result_output_schema( structured_content_schema, )); + let description = description.map(Into::into).unwrap_or_default(); - Ok(ResponsesApiTool { - name: fully_qualified_name, - description: description.map(Into::into).unwrap_or_default(), - strict: false, - parameters: input_schema, - output_schema, - }) -} - -fn dynamic_tool_to_openai_tool( - tool: &DynamicToolSpec, -) -> Result { - let input_schema = parse_tool_input_schema(&tool.input_schema)?; - - Ok(ResponsesApiTool { - name: tool.name.clone(), - description: tool.description.clone(), - strict: false, - parameters: input_schema, - output_schema: None, - }) -} - -/// Parse the tool input_schema or return an error for invalid schema -pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result { - let mut input_schema = input_schema.clone(); - sanitize_json_schema(&mut input_schema); - serde_json::from_value::(input_schema) + Ok((description, input_schema, output_schema)) } fn mcp_call_tool_result_output_schema(structured_content_schema: JsonValue) -> JsonValue { @@ -2056,10 +2104,10 @@ pub(crate) fn build_specs( use crate::tools::handlers::ReadFileHandler; use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::RequestUserInputHandler; - use crate::tools::handlers::SearchToolBm25Handler; use crate::tools::handlers::ShellCommandHandler; use crate::tools::handlers::ShellHandler; use crate::tools::handlers::TestSyncHandler; + use crate::tools::handlers::ToolSearchHandler; use crate::tools::handlers::UnifiedExecHandler; use crate::tools::handlers::ViewImageHandler; use std::sync::Arc; @@ -2079,7 +2127,6 @@ pub(crate) fn build_specs( let request_user_input_handler = Arc::new(RequestUserInputHandler { default_mode_request_user_input: config.default_mode_request_user_input, }); - let search_tool_handler = Arc::new(SearchToolBm25Handler); let code_mode_handler = Arc::new(CodeModeHandler); let js_repl_handler = Arc::new(JsReplHandler); let js_repl_reset_handler = Arc::new(JsReplResetHandler); @@ -2237,15 +2284,24 @@ pub(crate) fn build_specs( builder.register_handler("request_permissions", request_permissions_handler); } - if config.search_tool { - let app_tools = app_tools.unwrap_or_default(); + if config.search_tool + && let Some(app_tools) = app_tools + { + let search_tool_handler = Arc::new(ToolSearchHandler::new(app_tools.clone())); push_tool_spec( &mut builder, - create_search_tool_bm25_tool(&app_tools), + create_tool_search_tool(&app_tools), true, config.code_mode_enabled, ); - builder.register_handler(SEARCH_TOOL_BM25_TOOL_NAME, search_tool_handler); + builder.register_handler(TOOL_SEARCH_TOOL_NAME, search_tool_handler); + + for tool in app_tools.values() { + let alias_name = + tool_handler_key(tool.tool_name.as_str(), Some(tool.tool_namespace.as_str())); + + builder.register_handler(alias_name, mcp_handler.clone()); + } } if let Some(apply_patch_tool_type) = &config.apply_patch_tool_type { @@ -2668,9 +2724,73 @@ mod tests { ); } + #[test] + fn search_tool_deferred_tools_always_set_defer_loading_true() { + let tool = mcp_tool( + "lookup_order", + "Look up an order", + serde_json::json!({ + "type": "object", + "properties": { + "order_id": {"type": "string"} + }, + "required": ["order_id"], + "additionalProperties": false, + }), + ); + + let openai_tool = + mcp_tool_to_deferred_openai_tool("mcp__codex_apps__lookup_order".to_string(), tool) + .expect("convert deferred tool"); + + assert_eq!(openai_tool.defer_loading, Some(true)); + } + + #[test] + fn deferred_responses_api_tool_serializes_with_defer_loading() { + let tool = mcp_tool( + "lookup_order", + "Look up an order", + serde_json::json!({ + "type": "object", + "properties": { + "order_id": {"type": "string"} + }, + "required": ["order_id"], + "additionalProperties": false, + }), + ); + + let serialized = serde_json::to_value(ToolSpec::Function( + mcp_tool_to_deferred_openai_tool("mcp__codex_apps__lookup_order".to_string(), tool) + .expect("convert deferred tool"), + )) + .expect("serialize deferred tool"); + + assert_eq!( + serialized, + serde_json::json!({ + "type": "function", + "name": "mcp__codex_apps__lookup_order", + "description": "Look up an order", + "strict": false, + "defer_loading": true, + "parameters": { + "type": "object", + "properties": { + "order_id": {"type": "string"} + }, + "required": ["order_id"], + "additionalProperties": false, + } + }) + ); + } + fn tool_name(tool: &ToolSpec) -> &str { match tool { ToolSpec::Function(ResponsesApiTool { name, .. }) => name, + ToolSpec::ToolSearch { .. } => "tool_search", ToolSpec::LocalShell {} => "local_shell", ToolSpec::ImageGeneration { .. } => "image_generation", ToolSpec::WebSearch { .. } => "web_search", @@ -2759,6 +2879,7 @@ mod tests { fn strip_descriptions_tool(spec: &mut ToolSpec) { match spec { + ToolSpec::ToolSearch { parameters, .. } => strip_descriptions_schema(parameters), ToolSpec::Function(ResponsesApiTool { parameters, .. }) => { strip_descriptions_schema(parameters); } @@ -3863,6 +3984,7 @@ mod tests { description: "Do something cool".to_string(), strict: false, output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), + defer_loading: None, }) ); } @@ -3948,18 +4070,20 @@ mod tests { ])), Some(HashMap::from([ ( - "mcp__codex_apps__calendar_create_event".to_string(), + "mcp__codex_apps__calendar-create-event".to_string(), ToolInfo { server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(), - tool_name: "calendar_create_event".to_string(), + tool_name: "-create-event".to_string(), + tool_namespace: "mcp__codex_apps__calendar".to_string(), tool: mcp_tool( - "calendar_create_event", + "calendar-create-event", "Create calendar event", serde_json::json!({"type": "object"}), ), connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), plugin_display_names: Vec::new(), + connector_description: None, }, ), ( @@ -3967,10 +4091,12 @@ mod tests { ToolInfo { server_name: "rmcp".to_string(), tool_name: "echo".to_string(), + tool_namespace: "rmcp".to_string(), tool: mcp_tool("echo", "Echo", serde_json::json!({"type": "object"})), connector_id: None, connector_name: None, plugin_display_names: Vec::new(), + connector_description: None, }, ), ])), @@ -3978,10 +4104,11 @@ mod tests { ) .build(); - let search_tool = find_tool(&tools, SEARCH_TOOL_BM25_TOOL_NAME); - let ToolSpec::Function(ResponsesApiTool { description, .. }) = &search_tool.spec else { - panic!("expected function tool"); + let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME); + let ToolSpec::ToolSearch { description, .. } = &search_tool.spec else { + panic!("expected tool_search tool"); }; + let description = description.as_str(); assert!(description.contains("Calendar")); assert!(!description.contains("mcp__rmcp__echo")); } @@ -3996,6 +4123,7 @@ mod tests { ToolInfo { server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(), tool_name: "calendar_create_event".to_string(), + tool_namespace: "mcp__codex_apps__calendar".to_string(), tool: mcp_tool( "calendar_create_event", "Create calendar event", @@ -4003,6 +4131,7 @@ mod tests { ), connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), + connector_description: None, plugin_display_names: Vec::new(), }, )])); @@ -4017,7 +4146,7 @@ mod tests { session_source: SessionSource::Cli, }); let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build(); - assert_lacks_tool_name(&tools, SEARCH_TOOL_BM25_TOOL_NAME); + assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); let mut features = Features::with_defaults(); features.enable(Feature::Apps); @@ -4030,7 +4159,7 @@ mod tests { session_source: SessionSource::Cli, }); let (tools, _) = build_specs(&tools_config, None, app_tools, &[]).build(); - assert_contains_tool_names(&tools, &[SEARCH_TOOL_BM25_TOOL_NAME]); + assert_contains_tool_names(&tools, &[TOOL_SEARCH_TOOL_NAME]); } #[test] @@ -4050,16 +4179,80 @@ mod tests { }); let (tools, _) = build_specs(&tools_config, None, Some(HashMap::new()), &[]).build(); - let search_tool = find_tool(&tools, SEARCH_TOOL_BM25_TOOL_NAME); - let ToolSpec::Function(ResponsesApiTool { description, .. }) = &search_tool.spec else { - panic!("expected function tool"); + let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME); + let ToolSpec::ToolSearch { description, .. } = &search_tool.spec else { + panic!("expected tool_search tool"); }; assert!(description.contains("(None currently enabled)")); - assert!(description.contains("available apps.")); assert!(!description.contains("{{app_names}}")); } + #[test] + fn search_tool_registers_namespaced_app_tool_aliases() { + let config = test_config(); + let model_info = + ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::Apps); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + + let (_, registry) = build_specs( + &tools_config, + None, + Some(HashMap::from([ + ( + "mcp__codex_apps__calendar-create-event".to_string(), + ToolInfo { + server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: "-create-event".to_string(), + tool_namespace: "mcp__codex_apps__calendar".to_string(), + tool: mcp_tool( + "calendar-create-event", + "Create calendar event", + serde_json::json!({"type": "object"}), + ), + connector_id: Some("calendar".to_string()), + connector_name: Some("Calendar".to_string()), + connector_description: None, + plugin_display_names: Vec::new(), + }, + ), + ( + "mcp__codex_apps__calendar-list-events".to_string(), + ToolInfo { + server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: "-list-events".to_string(), + tool_namespace: "mcp__codex_apps__calendar".to_string(), + tool: mcp_tool( + "calendar-list-events", + "List calendar events", + serde_json::json!({"type": "object"}), + ), + connector_id: Some("calendar".to_string()), + connector_name: Some("Calendar".to_string()), + connector_description: None, + plugin_display_names: Vec::new(), + }, + ), + ])), + &[], + ) + .build(); + + let alias = tool_handler_key("-create-event", Some("mcp__codex_apps__calendar")); + + assert!(registry.has_handler(TOOL_SEARCH_TOOL_NAME, None)); + assert!(registry.has_handler(alias.as_str(), None)); + } + #[test] fn test_mcp_tool_property_missing_type_defaults_to_string() { let config = test_config(); @@ -4114,6 +4307,7 @@ mod tests { description: "Search docs".to_string(), strict: false, output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), + defer_loading: None, }) ); } @@ -4168,6 +4362,7 @@ mod tests { description: "Pagination".to_string(), strict: false, output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), + defer_loading: None, }) ); } @@ -4226,6 +4421,7 @@ mod tests { description: "Tags".to_string(), strict: false, output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), + defer_loading: None, }) ); } @@ -4282,6 +4478,7 @@ mod tests { description: "AnyOf Value".to_string(), strict: false, output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), + defer_loading: None, }) ); } @@ -4543,6 +4740,7 @@ Examples of valid command strings: description: "Do something cool".to_string(), strict: false, output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), + defer_loading: None, }) ); } @@ -4636,6 +4834,7 @@ Examples of valid command strings: name: "demo".to_string(), description: "A demo tool".to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: None, diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 6d72319bd..f197242f3 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -141,12 +141,14 @@ fn response_item_records_turn_ttft(item: &ResponseItem) -> bool { ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::CustomToolCall { .. } + | ResponseItem::ToolSearchCall { .. } | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::GhostSnapshot { .. } | ResponseItem::Compaction { .. } => true, ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } | ResponseItem::Other => false, } } @@ -237,6 +239,7 @@ mod tests { &ResponseItem::FunctionCall { id: None, name: "shell".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), } diff --git a/codex-rs/core/templates/search_tool/tool_description.md b/codex-rs/core/templates/search_tool/tool_description.md index 05667b735..07df9dc51 100644 --- a/codex-rs/core/templates/search_tool/tool_description.md +++ b/codex-rs/core/templates/search_tool/tool_description.md @@ -2,27 +2,4 @@ Searches over apps tool metadata with BM25 and exposes matching tools for the next model call. -MCP tools of the apps ({{app_names}}) are hidden until you search for them with this tool (`search_tool_bm25`). - -Follow this workflow: - -1. Call `search_tool_bm25` with: - - `query` (required): focused terms that describe the capability you need. - - `limit` (optional): maximum number of tools to return (default `8`). -2. Use the returned `tools` list to decide which Apps tools are relevant. -3. Matching tools are added to available `tools` and available for the remainder of the current session/thread. -4. Repeated searches in the same session/thread are additive: new matches are unioned into `tools`. - -Notes: -- Core tools remain available without searching. -- If you are unsure, start with `limit` between 5 and 10 to see a broader set of tools. -- `query` is matched against Apps tool metadata fields: - - `name` - - `tool_name` - - `server_name` - - `title` - - `description` - - `connector_name` - - input schema property keys (`input_keys`) -- If the needed app is already explicit in the prompt (for example `[$app-name](app://{connector_id})`) or already present in the current `tools` list, you can call that tool directly. -- Do not use `search_tool_bm25` for non-apps/local tasks (filesystem, repo search, or shell-only workflows) or anything not related to {{app_names}}. +Tools of the apps ({{app_names}}) are hidden until you search for them with this tool (`tool_search`). \ No newline at end of file diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index 1160a125a..c8ef0bd1c 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -12,6 +12,7 @@ use wiremock::matchers::path_regex; const CONNECTOR_ID: &str = "calendar"; const CONNECTOR_NAME: &str = "Calendar"; +const CONNECTOR_DESCRIPTION: &str = "Plan events and manage your calendar."; const PROTOCOL_VERSION: &str = "2025-11-25"; const SERVER_NAME: &str = "codex-apps-test"; const SERVER_VERSION: &str = "1.0.0"; @@ -31,7 +32,12 @@ impl AppsTestServer { connector_name: &str, ) -> Result { mount_oauth_metadata(server).await; - mount_streamable_http_json_rpc(server, connector_name.to_string()).await; + mount_streamable_http_json_rpc( + server, + connector_name.to_string(), + CONNECTOR_DESCRIPTION.to_string(), + ) + .await; Ok(Self { chatgpt_base_url: server.uri(), }) @@ -50,16 +56,24 @@ async fn mount_oauth_metadata(server: &MockServer) { .await; } -async fn mount_streamable_http_json_rpc(server: &MockServer, connector_name: String) { +async fn mount_streamable_http_json_rpc( + server: &MockServer, + connector_name: String, + connector_description: String, +) { Mock::given(method("POST")) .and(path_regex("^/api/codex/apps/?$")) - .respond_with(CodexAppsJsonRpcResponder { connector_name }) + .respond_with(CodexAppsJsonRpcResponder { + connector_name, + connector_description, + }) .mount(server) .await; } struct CodexAppsJsonRpcResponder { connector_name: String, + connector_description: String, } impl Respond for CodexAppsJsonRpcResponder { @@ -126,7 +140,8 @@ impl Respond for CodexAppsJsonRpcResponder { }, "_meta": { "connector_id": CONNECTOR_ID, - "connector_name": self.connector_name.clone() + "connector_name": self.connector_name.clone(), + "connector_description": self.connector_description.clone() } }, { @@ -142,7 +157,8 @@ impl Respond for CodexAppsJsonRpcResponder { }, "_meta": { "connector_id": CONNECTOR_ID, - "connector_name": self.connector_name.clone() + "connector_name": self.connector_name.clone(), + "connector_description": self.connector_description.clone() } } ], @@ -150,6 +166,33 @@ impl Respond for CodexAppsJsonRpcResponder { } })) } + "tools/call" => { + let id = body.get("id").cloned().unwrap_or(Value::Null); + let tool_name = body + .pointer("/params/name") + .and_then(Value::as_str) + .unwrap_or_default(); + let title = body + .pointer("/params/arguments/title") + .and_then(Value::as_str) + .unwrap_or_default(); + let starts_at = body + .pointer("/params/arguments/starts_at") + .and_then(Value::as_str) + .unwrap_or_default(); + + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "content": [{ + "type": "text", + "text": format!("called {tool_name} for {title} at {starts_at}") + }], + "isError": false + } + })) + } method if method.starts_with("notifications/") => ResponseTemplate::new(202), _ => { let id = body.get("id").cloned().unwrap_or(Value::Null); diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index cf7c03f4d..0971c0284 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -207,6 +207,10 @@ impl ResponsesRequest { self.call_output(call_id, "custom_tool_call_output") } + pub fn tool_search_output(&self, call_id: &str) -> Value { + self.call_output(call_id, "tool_search_output") + } + pub fn call_output(&self, call_id: &str, call_type: &str) -> Value { self.input() .iter() @@ -774,6 +778,18 @@ pub fn ev_function_call(call_id: &str, name: &str, arguments: &str) -> Value { }) } +pub fn ev_tool_search_call(call_id: &str, arguments: &serde_json::Value) -> Value { + serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "tool_search_call", + "call_id": call_id, + "execution": "client", + "arguments": arguments, + } + }) +} + pub fn ev_custom_tool_call(call_id: &str, name: &str, input: &str) -> Value { serde_json::json!({ "type": "response.output_item.done", @@ -1484,11 +1500,13 @@ pub async fn mount_response_sequence( /// Validate invariants on the request body sent to `/v1/responses`. /// /// - No `function_call_output`/`custom_tool_call_output` with missing/empty `call_id`. +/// - `tool_search_output` must have a `call_id` unless it is a server-executed legacy item. /// - Every `function_call_output` must match a prior `function_call` or /// `local_shell_call` with the same `call_id` in the same `input`. /// - Every `custom_tool_call_output` must match a prior `custom_tool_call`. -/// - Additionally, enforce symmetry: every `function_call`/`custom_tool_call` -/// in the `input` must have a matching output entry. +/// - Every `tool_search_output` must match a prior `tool_search_call`. +/// - Additionally, enforce symmetry: every `function_call`/`custom_tool_call`/ +/// `tool_search_call` in the `input` must have a matching output entry. fn validate_request_body_invariants(request: &wiremock::Request) { // Skip GET requests (e.g., /models) if request.method != "POST" || !request.url.path().ends_with("/responses") { @@ -1538,7 +1556,24 @@ fn validate_request_body_invariants(request: &wiremock::Request) { .collect() } + fn gather_tool_search_output_ids(items: &[Value]) -> HashSet { + items + .iter() + .filter(|item| item.get("type").and_then(Value::as_str) == Some("tool_search_output")) + .filter_map(|item| { + if let Some(id) = get_call_id(item) { + return Some(id.to_string()); + } + if item.get("execution").and_then(Value::as_str) == Some("server") { + return None; + } + panic!("orphan tool_search_output with empty call_id should be dropped"); + }) + .collect() + } + let function_calls = gather_ids(items, "function_call"); + let tool_search_calls = gather_ids(items, "tool_search_call"); let custom_tool_calls = gather_ids(items, "custom_tool_call"); let local_shell_calls = gather_ids(items, "local_shell_call"); let function_call_outputs = gather_output_ids( @@ -1546,6 +1581,7 @@ fn validate_request_body_invariants(request: &wiremock::Request) { "function_call_output", "orphan function_call_output with empty call_id should be dropped", ); + let tool_search_outputs = gather_tool_search_output_ids(items); let custom_tool_call_outputs = gather_output_ids( items, "custom_tool_call_output", @@ -1564,6 +1600,12 @@ fn validate_request_body_invariants(request: &wiremock::Request) { "custom_tool_call_output without matching call in input: {cid}", ); } + for cid in &tool_search_outputs { + assert!( + tool_search_calls.contains(cid), + "tool_search_output without matching call in input: {cid}", + ); + } for cid in &function_calls { assert!( @@ -1577,4 +1619,10 @@ fn validate_request_body_invariants(request: &wiremock::Request) { "Custom tool call output is missing for call id: {cid}", ); } + for cid in &tool_search_calls { + assert!( + tool_search_outputs.contains(cid), + "Tool search output is missing for call id: {cid}", + ); + } } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index cfb84be83..f946e3358 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -515,6 +515,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { item: RolloutItem::ResponseItem(ResponseItem::FunctionCall { id: None, name: "view_image".to_string(), + namespace: None, arguments: "{\"path\":\"/tmp/example.webp\"}".to_string(), call_id: function_call_id.to_string(), }), @@ -1878,6 +1879,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { prompt.input.push(ResponseItem::FunctionCall { id: Some("function-id".into()), name: "do_thing".into(), + namespace: None, arguments: "{}".into(), call_id: "function-call-id".into(), }); diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index b797ac3f2..802e4d68d 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -277,7 +277,7 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { assert!( request_tools .iter() - .any(|name| name == "mcp__codex_apps__calendar_create_event"), + .any(|name| name == "mcp__codex_apps__google-calendar-create-event"), "expected plugin app tools to become visible for this turn: {request_tools:?}" ); let echo_description = tool_description(&request_body, "mcp__sample__echo") @@ -286,9 +286,11 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { echo_description.contains("This tool is part of plugin `sample`."), "expected plugin MCP provenance in tool description: {echo_description:?}" ); - let calendar_description = - tool_description(&request_body, "mcp__codex_apps__calendar_create_event") - .expect("plugin app tool description should be present"); + let calendar_description = tool_description( + &request_body, + "mcp__codex_apps__google-calendar-create-event", + ) + .expect("plugin app tool description should be present"); assert!( calendar_description.contains("This tool is part of plugin `sample`."), "expected plugin app provenance in tool description: {calendar_description:?}" diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 13fb666ee..7b0a72b17 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -1,19 +1,13 @@ #![cfg(not(target_os = "windows"))] #![allow(clippy::unwrap_used, clippy::expect_used)] -use std::sync::Arc; -use std::time::Duration; - use anyhow::Result; use codex_core::CodexAuth; -use codex_core::CodexThread; -use codex_core::NewThread; use codex_core::config::Config; -use codex_core::config::types::McpServerConfig; -use codex_core::config::types::McpServerTransportConfig; use codex_core::features::Feature; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; @@ -21,14 +15,13 @@ use core_test_support::apps_test_server::AppsTestServer; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; +use core_test_support::responses::ev_tool_search_call; use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; -use core_test_support::stdio_server_bin; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; @@ -36,17 +29,14 @@ use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; -const SEARCH_TOOL_INSTRUCTION_SNIPPETS: [&str; 2] = [ - "MCP tools of the apps (Calendar) are hidden until you search for them with this tool", - "Matching tools are added to available `tools` and available for the remainder of the current session/thread.", +const SEARCH_TOOL_DESCRIPTION_SNIPPETS: [&str; 1] = [ + "Tools of the apps (Calendar) are hidden until you search for them with this tool (`tool_search`).", ]; -const SEARCH_TOOL_BM25_TOOL_NAME: &str = "search_tool_bm25"; -const CALENDAR_CREATE_TOOL: &str = "mcp__codex_apps__calendar_create_event"; -const CALENDAR_LIST_TOOL: &str = "mcp__codex_apps__calendar_list_events"; -const RMCP_ECHO_TOOL: &str = "mcp__rmcp__echo"; -const RMCP_IMAGE_TOOL: &str = "mcp__rmcp__image"; -const CALENDAR_CREATE_QUERY: &str = "create calendar event"; -const CALENDAR_LIST_QUERY: &str = "list calendar events"; +const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; +const CALENDAR_CREATE_TOOL: &str = "mcp__codex_apps__calendar-create-event"; +const CALENDAR_LIST_TOOL: &str = "mcp__codex_apps__calendar-list-events"; +const SEARCH_CALENDAR_NAMESPACE: &str = "mcp__codex_apps__calendar"; +const SEARCH_CALENDAR_CREATE_TOOL: &str = "-create-event"; fn tool_names(body: &Value) -> Vec { body.get("tools") @@ -65,12 +55,12 @@ fn tool_names(body: &Value) -> Vec { .unwrap_or_default() } -fn search_tool_description(body: &Value) -> Option { +fn tool_search_description(body: &Value) -> Option { body.get("tools") .and_then(Value::as_array) .and_then(|tools| { tools.iter().find_map(|tool| { - if tool.get("name").and_then(Value::as_str) == Some(SEARCH_TOOL_BM25_TOOL_NAME) { + if tool.get("type").and_then(Value::as_str) == Some(TOOL_SEARCH_TOOL_NAME) { tool.get("description") .and_then(Value::as_str) .map(str::to_string) @@ -81,69 +71,19 @@ fn search_tool_description(body: &Value) -> Option { }) } -fn search_tool_output_payload(request: &ResponsesRequest, call_id: &str) -> Value { - let (content, _success) = request - .function_call_output_content_and_success(call_id) - .unwrap_or_else(|| { - panic!("{SEARCH_TOOL_BM25_TOOL_NAME} function_call_output should be present") - }); - let content = content - .unwrap_or_else(|| panic!("{SEARCH_TOOL_BM25_TOOL_NAME} output should include content")); - serde_json::from_str(&content) - .unwrap_or_else(|_| panic!("{SEARCH_TOOL_BM25_TOOL_NAME} content should be valid JSON")) +fn tool_search_output_item(request: &ResponsesRequest, call_id: &str) -> Value { + request.tool_search_output(call_id) } -fn active_selected_tools(payload: &Value) -> Vec { - payload - .get("active_selected_tools") - .and_then(Value::as_array) - .expect("active_selected_tools should be an array") - .iter() - .map(|value| { - value - .as_str() - .expect("active_selected_tools entries should be strings") - .to_string() - }) - .collect() -} - -fn search_result_tools(payload: &Value) -> Vec<&Value> { - payload +fn tool_search_output_tools(request: &ResponsesRequest, call_id: &str) -> Vec { + tool_search_output_item(request, call_id) .get("tools") .and_then(Value::as_array) - .map(Vec::as_slice) + .cloned() .unwrap_or_default() - .iter() - .collect() } -fn rmcp_server_config(command: String) -> McpServerConfig { - McpServerConfig { - transport: McpServerTransportConfig::Stdio { - command, - args: Vec::new(), - env: None, - env_vars: Vec::new(), - cwd: None, - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: Some(Duration::from_secs(10)), - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - } -} - -fn configure_apps_with_optional_rmcp( - config: &mut Config, - apps_base_url: &str, - rmcp_server_bin: Option, -) { +fn configure_apps(config: &mut Config, apps_base_url: &str) { config .features .enable(Feature::Apps) @@ -153,40 +93,16 @@ fn configure_apps_with_optional_rmcp( .disable(Feature::AppsMcpGateway) .expect("test config should allow feature update"); config.chatgpt_base_url = apps_base_url.to_string(); - if let Some(command) = rmcp_server_bin { - let mut servers = config.mcp_servers.get().clone(); - servers.insert("rmcp".to_string(), rmcp_server_config(command)); - config - .mcp_servers - .set(servers) - .expect("test mcp servers should accept any configuration"); - } } -fn configured_builder(apps_base_url: String, rmcp_server_bin: Option) -> TestCodexBuilder { +fn configured_builder(apps_base_url: String) -> TestCodexBuilder { test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) - .with_config(move |config| { - configure_apps_with_optional_rmcp(config, apps_base_url.as_str(), rmcp_server_bin); - }) -} - -async fn submit_user_input(thread: &Arc, text: &str) -> Result<()> { - thread - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: text.to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - }) - .await?; - wait_for_event(thread, |event| matches!(event, EventMsg::TurnComplete(_))).await; - Ok(()) + .with_config(move |config| configure_apps(config, apps_base_url.as_str())) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_flag_adds_tool() -> Result<()> { +async fn search_tool_flag_adds_tool_search() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -201,7 +117,7 @@ async fn search_tool_flag_adds_tool() -> Result<()> { ) .await; - let mut builder = configured_builder(apps_server.chatgpt_base_url.clone(), None); + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone()); let test = builder.build(&server).await?; test.submit_turn_with_policies( @@ -212,17 +128,39 @@ async fn search_tool_flag_adds_tool() -> Result<()> { .await?; let body = mock.single_request().body_json(); - let tools = tool_names(&body); - assert!( - tools.iter().any(|name| name == SEARCH_TOOL_BM25_TOOL_NAME), - "tools list should include {SEARCH_TOOL_BM25_TOOL_NAME} when enabled: {tools:?}" + let tools = body + .get("tools") + .and_then(Value::as_array) + .expect("tools array should exist"); + let tool_search = tools + .iter() + .find(|tool| tool.get("type").and_then(Value::as_str) == Some(TOOL_SEARCH_TOOL_NAME)) + .cloned() + .expect("tool_search should be present"); + + assert_eq!( + tool_search, + json!({ + "type": "tool_search", + "execution": "client", + "description": tool_search["description"].as_str().expect("description should exist"), + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query for apps tools."}, + "limit": {"type": "number", "description": "Maximum number of tools to return (defaults to 8)."}, + }, + "required": ["query"], + "additionalProperties": false, + } + }) ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_flag_adds_tool_for_api_key_auth() -> Result<()> { +async fn search_tool_is_hidden_for_api_key_auth() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -239,9 +177,7 @@ async fn search_tool_flag_adds_tool_for_api_key_auth() -> Result<()> { let mut builder = test_codex() .with_auth(CodexAuth::from_api_key("Test API Key")) - .with_config(move |config| { - configure_apps_with_optional_rmcp(config, apps_server.chatgpt_base_url.as_str(), None); - }); + .with_config(move |config| configure_apps(config, apps_server.chatgpt_base_url.as_str())); let test = builder.build(&server).await?; test.submit_turn_with_policies( @@ -254,8 +190,8 @@ async fn search_tool_flag_adds_tool_for_api_key_auth() -> Result<()> { let body = mock.single_request().body_json(); let tools = tool_names(&body); assert!( - tools.iter().any(|name| name == SEARCH_TOOL_BM25_TOOL_NAME), - "tools list should include {SEARCH_TOOL_BM25_TOOL_NAME} for API key auth when Apps is enabled: {tools:?}" + !tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME), + "tools list should not include {TOOL_SEARCH_TOOL_NAME} for API key auth: {tools:?}" ); Ok(()) @@ -267,17 +203,17 @@ async fn search_tool_adds_discovery_instructions_to_tool_description() -> Result let server = start_mock_server().await; let apps_server = AppsTestServer::mount(&server).await?; - let mock = mount_sse_sequence( + let mock = mount_sse_once( &server, - vec![sse(vec![ + sse(vec![ ev_response_created("resp-1"), ev_assistant_message("msg-1", "done"), ev_completed("resp-1"), - ])], + ]), ) .await; - let mut builder = configured_builder(apps_server.chatgpt_base_url.clone(), None); + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone()); let test = builder.build(&server).await?; test.submit_turn_with_policies( @@ -288,39 +224,38 @@ async fn search_tool_adds_discovery_instructions_to_tool_description() -> Result .await?; let body = mock.single_request().body_json(); - let description = search_tool_description(&body).expect("search tool description should exist"); + let description = tool_search_description(&body).expect("tool_search description should exist"); assert!( - SEARCH_TOOL_INSTRUCTION_SNIPPETS + SEARCH_TOOL_DESCRIPTION_SNIPPETS .iter() .all(|snippet| description.contains(snippet)), - "search tool description should include search tool workflow: {description:?}" + "tool_search description should include the updated workflow: {description:?}" + ); + assert!( + !description.contains("remainder of the current session/thread"), + "tool_search description should not mention legacy client-side persistence: {description:?}" ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_hides_apps_tools_without_search_but_keeps_non_app_tools_visible() -> Result<()> -{ +async fn search_tool_hides_apps_tools_without_search() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let apps_server = AppsTestServer::mount(&server).await?; - let mock = mount_sse_sequence( + let mock = mount_sse_once( &server, - vec![sse(vec![ + sse(vec![ ev_response_created("resp-1"), ev_assistant_message("msg-1", "done"), ev_completed("resp-1"), - ])], + ]), ) .await; - let rmcp_test_server_bin = stdio_server_bin()?; - let mut builder = configured_builder( - apps_server.chatgpt_base_url.clone(), - Some(rmcp_test_server_bin), - ); + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone()); let test = builder.build(&server).await?; test.submit_turn_with_policies( @@ -332,26 +267,9 @@ async fn search_tool_hides_apps_tools_without_search_but_keeps_non_app_tools_vis let body = mock.single_request().body_json(); let tools = tool_names(&body); - assert!( - tools.iter().any(|name| name == SEARCH_TOOL_BM25_TOOL_NAME), - "tools list should include {SEARCH_TOOL_BM25_TOOL_NAME}: {tools:?}" - ); - assert!( - tools.iter().any(|name| name == RMCP_ECHO_TOOL), - "non-app MCP tools should remain visible in Apps mode: {tools:?}" - ); - assert!( - tools.iter().any(|name| name == RMCP_IMAGE_TOOL), - "non-app MCP tools should remain visible in Apps mode: {tools:?}" - ); - assert!( - !tools.iter().any(|name| name == CALENDAR_CREATE_TOOL), - "apps tools should stay hidden before search/mention: {tools:?}" - ); - assert!( - !tools.iter().any(|name| name == CALENDAR_LIST_TOOL), - "apps tools should stay hidden before search/mention: {tools:?}" - ); + assert!(tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME)); + assert!(!tools.iter().any(|name| name == CALENDAR_CREATE_TOOL)); + assert!(!tools.iter().any(|name| name == CALENDAR_LIST_TOOL)); Ok(()) } @@ -362,21 +280,17 @@ async fn explicit_app_mentions_expose_apps_tools_without_search() -> Result<()> let server = start_mock_server().await; let apps_server = AppsTestServer::mount(&server).await?; - let mock = mount_sse_sequence( + let mock = mount_sse_once( &server, - vec![sse(vec![ + sse(vec![ ev_response_created("resp-1"), ev_assistant_message("msg-1", "done"), ev_completed("resp-1"), - ])], + ]), ) .await; - let rmcp_test_server_bin = stdio_server_bin()?; - let mut builder = configured_builder( - apps_server.chatgpt_base_url.clone(), - Some(rmcp_test_server_bin), - ); + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone()); let test = builder.build(&server).await?; test.submit_turn_with_policies( @@ -388,829 +302,178 @@ async fn explicit_app_mentions_expose_apps_tools_without_search() -> Result<()> let body = mock.single_request().body_json(); let tools = tool_names(&body); - assert!( - tools.iter().any(|name| name == RMCP_ECHO_TOOL), - "non-app tools should remain visible: {tools:?}" - ); assert!( tools.iter().any(|name| name == CALENDAR_CREATE_TOOL), - "apps tools should be available after explicit app mention: {tools:?}" + "expected explicit app mention to expose create tool, got tools: {tools:?}" ); assert!( tools.iter().any(|name| name == CALENDAR_LIST_TOOL), - "apps tools should be available after explicit app mention: {tools:?}" + "expected explicit app mention to expose list tool, got tools: {tools:?}" ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_results_match_plugin_names_and_annotate_descriptions() -> Result<()> { +async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?; - let call_id = "tool-search"; - let args = json!({ - "query": "sample", - "limit": 2, - }); + let apps_server = AppsTestServer::mount(&server).await?; + let call_id = "tool-search-1"; let mock = mount_sse_sequence( &server, vec![ sse(vec![ ev_response_created("resp-1"), - ev_function_call( + ev_tool_search_call( call_id, - SEARCH_TOOL_BM25_TOOL_NAME, - &serde_json::to_string(&args)?, + &json!({ + "query": "create calendar event", + "limit": 1, + }), ), ev_completed("resp-1"), ]), sse(vec![ - ev_assistant_message("msg-1", "done"), + ev_response_created("resp-2"), + json!({ + "type": "response.output_item.done", + "item": { + "type": "function_call", + "call_id": "calendar-call-1", + "name": SEARCH_CALENDAR_CREATE_TOOL, + "namespace": SEARCH_CALENDAR_NAMESPACE, + "arguments": serde_json::to_string(&json!({ + "title": "Lunch", + "starts_at": "2026-03-10T12:00:00Z" + })).expect("serialize calendar args") + } + }), ev_completed("resp-2"), ]), + sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-3"), + ]), ], ) .await; - let codex_home = Arc::new(tempfile::TempDir::new()?); - let plugin_root = codex_home.path().join("plugins/cache/test/sample/local"); - std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir"); - std::fs::write( - plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"sample"}"#, - ) - .expect("write plugin manifest"); - std::fs::write( - plugin_root.join(".app.json"), - r#"{ - "apps": { - "calendar": { - "id": "calendar" - } - } -}"#, - ) - .expect("write plugin app config"); - std::fs::write( - codex_home.path().join("config.toml"), - "[features]\nplugins = true\n\n[plugins.\"sample@test\"]\nenabled = true\n", - ) - .expect("write config"); - - let mut builder = - configured_builder(apps_server.chatgpt_base_url.clone(), None).with_home(codex_home); + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone()); let test = builder.build(&server).await?; + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "Find the calendar create tool".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; - test.submit_turn_with_policies( - "find sample plugin tools", - AskForApproval::Never, - SandboxPolicy::DangerFullAccess, - ) - .await?; + let EventMsg::McpToolCallEnd(end) = wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::McpToolCallEnd(_)) + }) + .await + else { + unreachable!("event guard guarantees McpToolCallEnd"); + }; + assert_eq!(end.call_id, "calendar-call-1"); + assert_eq!( + end.invocation, + McpInvocation { + server: "codex_apps".to_string(), + tool: "calendar_create_event".to_string(), + arguments: Some(json!({ + "title": "Lunch", + "starts_at": "2026-03-10T12:00:00Z" + })), + } + ); + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; let requests = mock.requests(); - assert_eq!( - requests.len(), - 2, - "expected 2 requests, got {}", - requests.len() - ); + assert_eq!(requests.len(), 3); - let search_output_payload = search_tool_output_payload(&requests[1], call_id); - let result_tools = search_result_tools(&search_output_payload); - assert_eq!(result_tools.len(), 2, "expected 2 search results"); + let first_request_tools = tool_names(&requests[0].body_json()); assert!( - result_tools.iter().all(|tool| { - tool.get("description") - .and_then(Value::as_str) - .is_some_and(|description| { - description.contains("This tool is part of plugin `sample`.") - }) - }), - "expected plugin provenance in search result descriptions: {search_output_payload:?}" - ); - assert!( - result_tools + first_request_tools .iter() - .any(|tool| { tool.get("name").and_then(Value::as_str) == Some(CALENDAR_CREATE_TOOL) }), - "expected calendar create tool in search results: {search_output_payload:?}" + .any(|name| name == TOOL_SEARCH_TOOL_NAME), + "first request should advertise tool_search: {first_request_tools:?}" ); assert!( - result_tools - .iter() - .any(|tool| { tool.get("name").and_then(Value::as_str) == Some(CALENDAR_LIST_TOOL) }), - "expected calendar list tool in search results: {search_output_payload:?}" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_selection_persists_across_turns() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let apps_server = AppsTestServer::mount(&server).await?; - let call_id = "tool-search"; - let args = json!({ - "query": CALENDAR_CREATE_QUERY, - "limit": 1, - }); - let responses = vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - call_id, - SEARCH_TOOL_BM25_TOOL_NAME, - &serde_json::to_string(&args)?, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - sse(vec![ - ev_assistant_message("msg-2", "done again"), - ev_completed("resp-3"), - ]), - ]; - let mock = mount_sse_sequence(&server, responses).await; - - let rmcp_test_server_bin = stdio_server_bin()?; - let mut builder = configured_builder( - apps_server.chatgpt_base_url.clone(), - Some(rmcp_test_server_bin), - ); - let test = builder.build(&server).await?; - - test.submit_turn_with_policies( - "find calendar create tool", - AskForApproval::Never, - SandboxPolicy::DangerFullAccess, - ) - .await?; - test.submit_turn_with_policies( - "hello again", - AskForApproval::Never, - SandboxPolicy::DangerFullAccess, - ) - .await?; - - let requests = mock.requests(); - assert_eq!( - requests.len(), - 3, - "expected 3 requests, got {}", - requests.len() - ); - - let first_tools = tool_names(&requests[0].body_json()); - assert!( - first_tools.iter().any(|name| name == RMCP_ECHO_TOOL), - "non-app MCP tools should be available before search: {first_tools:?}" - ); - assert!( - !first_tools.iter().any(|name| name == CALENDAR_CREATE_TOOL), - "apps tools should not be visible before search: {first_tools:?}" - ); - - let search_output_payload = search_tool_output_payload(&requests[1], call_id); - assert!( - search_output_payload.get("selected_tools").is_none(), - "selected_tools should not be returned: {search_output_payload:?}" - ); - for tool in search_result_tools(&search_output_payload) { - assert_eq!( - tool.get("server").and_then(Value::as_str), - Some("codex_apps"), - "search results should only include codex_apps tools: {search_output_payload:?}" - ); - } - - let selected_tools = active_selected_tools(&search_output_payload); - assert!( - selected_tools - .iter() - .any(|tool| tool == CALENDAR_CREATE_TOOL), - "calendar create tool should be selected: {search_output_payload:?}" - ); - assert!( - !selected_tools - .iter() - .any(|tool_name| tool_name.starts_with("mcp__rmcp__")), - "search should not add rmcp tools to active selection: {search_output_payload:?}" - ); - - let second_tools = tool_names(&requests[1].body_json()); - assert!( - second_tools.iter().any(|name| name == RMCP_ECHO_TOOL), - "non-app MCP tools should remain visible after search: {second_tools:?}" - ); - for selected_tool in &selected_tools { - assert!( - second_tools.iter().any(|name| name == selected_tool), - "follow-up request should include selected tool {selected_tool:?}: {second_tools:?}" - ); - } - - let third_tools = tool_names(&requests[2].body_json()); - assert!( - third_tools.iter().any(|name| name == RMCP_ECHO_TOOL), - "non-app MCP tools should remain visible on later turns: {third_tools:?}" - ); - for selected_tool in &selected_tools { - assert!( - third_tools.iter().any(|name| name == selected_tool), - "subsequent turn should include selected tool {selected_tool:?}: {third_tools:?}" - ); - } - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_selection_unions_results_within_turn() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let apps_server = AppsTestServer::mount(&server).await?; - let first_call_id = "tool-search-create"; - let second_call_id = "tool-search-list"; - let first_args = json!({ - "query": CALENDAR_CREATE_QUERY, - "limit": 1, - }); - let second_args = json!({ - "query": CALENDAR_LIST_QUERY, - "limit": 1, - }); - let responses = vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - first_call_id, - SEARCH_TOOL_BM25_TOOL_NAME, - &serde_json::to_string(&first_args)?, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_function_call( - second_call_id, - SEARCH_TOOL_BM25_TOOL_NAME, - &serde_json::to_string(&second_args)?, - ), - ev_completed("resp-2"), - ]), - sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-3"), - ]), - ]; - let mock = mount_sse_sequence(&server, responses).await; - - let rmcp_test_server_bin = stdio_server_bin()?; - let mut builder = configured_builder( - apps_server.chatgpt_base_url.clone(), - Some(rmcp_test_server_bin), - ); - let test = builder.build(&server).await?; - - test.submit_turn_with_policies( - "find create and list calendar tools", - AskForApproval::Never, - SandboxPolicy::DangerFullAccess, - ) - .await?; - - let requests = mock.requests(); - assert_eq!( - requests.len(), - 3, - "expected 3 requests, got {}", - requests.len() - ); - - let first_tools = tool_names(&requests[0].body_json()); - assert!( - first_tools.iter().any(|name| name == RMCP_ECHO_TOOL), - "non-app MCP tools should be visible before search: {first_tools:?}" - ); - assert!( - !first_tools.iter().any(|name| name == CALENDAR_CREATE_TOOL), - "apps tools should be hidden before search: {first_tools:?}" - ); - assert!( - !first_tools.iter().any(|name| name == CALENDAR_LIST_TOOL), - "apps tools should be hidden before search: {first_tools:?}" - ); - - let second_search_payload = search_tool_output_payload(&requests[2], second_call_id); - assert!( - second_search_payload.get("selected_tools").is_none(), - "selected_tools should not be returned: {second_search_payload:?}" - ); - for tool in search_result_tools(&second_search_payload) { - assert_eq!( - tool.get("server").and_then(Value::as_str), - Some("codex_apps"), - "search results should only include codex_apps tools: {second_search_payload:?}" - ); - } - - let selected_tools = active_selected_tools(&second_search_payload); - assert_eq!( - selected_tools, - vec![ - CALENDAR_CREATE_TOOL.to_string(), - CALENDAR_LIST_TOOL.to_string(), - ], - "two searches in one turn should union selected apps tools" - ); - - let third_tools = tool_names(&requests[2].body_json()); - assert!( - third_tools.iter().any(|name| name == RMCP_ECHO_TOOL), - "non-app MCP tools should remain visible after repeated search: {third_tools:?}" - ); - assert!( - third_tools.iter().any(|name| name == CALENDAR_CREATE_TOOL), - "calendar create should be available after repeated search: {third_tools:?}" - ); - assert!( - third_tools.iter().any(|name| name == CALENDAR_LIST_TOOL), - "calendar list should be available after repeated search: {third_tools:?}" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_selection_restores_when_resumed() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let apps_server = AppsTestServer::mount(&server).await?; - let call_id = "tool-search"; - let args = json!({ - "query": CALENDAR_CREATE_QUERY, - "limit": 1, - }); - let responses = vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - call_id, - SEARCH_TOOL_BM25_TOOL_NAME, - &serde_json::to_string(&args)?, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - sse(vec![ - ev_response_created("resp-3"), - ev_assistant_message("msg-2", "resumed done"), - ev_completed("resp-3"), - ]), - ]; - let mock = mount_sse_sequence(&server, responses).await; - - let rmcp_test_server_bin = stdio_server_bin()?; - let mut builder = configured_builder( - apps_server.chatgpt_base_url.clone(), - Some(rmcp_test_server_bin.clone()), - ); - let test = builder.build(&server).await?; - - let home = test.home.clone(); - let rollout_path = test - .session_configured - .rollout_path - .clone() - .expect("rollout path should be available for resume"); - - test.submit_turn_with_policies( - "find calendar tools", - AskForApproval::Never, - SandboxPolicy::DangerFullAccess, - ) - .await?; - - let requests = mock.requests(); - assert_eq!( - requests.len(), - 2, - "expected 2 requests after initial turn, got {}", - requests.len() - ); - let search_output_payload = search_tool_output_payload(&requests[1], call_id); - let selected_tools = active_selected_tools(&search_output_payload); - assert!( - selected_tools + !first_request_tools .iter() .any(|name| name == CALENDAR_CREATE_TOOL), - "search should select calendar create before resume: {search_output_payload:?}" + "app tools should still be hidden before search: {first_request_tools:?}" ); - let mut resume_builder = configured_builder( - apps_server.chatgpt_base_url.clone(), - Some(rmcp_test_server_bin), - ); - let resumed = resume_builder.resume(&server, home, rollout_path).await?; - resumed - .submit_turn_with_policies( - "hello after resume", - AskForApproval::Never, - SandboxPolicy::DangerFullAccess, - ) - .await?; - - let requests = mock.requests(); + let output_item = tool_search_output_item(&requests[1], call_id); assert_eq!( - requests.len(), - 3, - "expected 3 requests after resumed turn, got {}", - requests.len() + output_item.get("status").and_then(Value::as_str), + Some("completed") ); - let resumed_tools = tool_names(&requests[2].body_json()); + assert_eq!( + output_item.get("execution").and_then(Value::as_str), + Some("client") + ); + + let tools = tool_search_output_tools(&requests[1], call_id); + assert_eq!( + tools, + vec![json!({ + "type": "namespace", + "name": SEARCH_CALENDAR_NAMESPACE, + "description": "Plan events and manage your calendar.", + "tools": [ + { + "type": "function", + "name": SEARCH_CALENDAR_CREATE_TOOL, + "description": "Create a calendar event.", + "strict": false, + "defer_loading": true, + "parameters": { + "type": "object", + "properties": { + "starts_at": {"type": "string"}, + "timezone": {"type": "string"}, + "title": {"type": "string"}, + }, + "required": ["title", "starts_at"], + "additionalProperties": false, + } + } + ] + })] + ); + + let second_request_tools = tool_names(&requests[1].body_json()); assert!( - resumed_tools.iter().any(|name| name == RMCP_ECHO_TOOL), - "non-app tools should remain visible after resume: {resumed_tools:?}" - ); - for selected_tool in &selected_tools { - assert!( - resumed_tools.iter().any(|name| name == selected_tool), - "resumed request should include restored selected tool {selected_tool:?}: {resumed_tools:?}" - ); - } - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_selection_union_restores_when_resumed_after_multiple_search_calls() --> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let apps_server = AppsTestServer::mount(&server).await?; - let first_call_id = "tool-search-create"; - let second_call_id = "tool-search-list"; - let first_args = json!({ - "query": CALENDAR_CREATE_QUERY, - "limit": 1, - }); - let second_args = json!({ - "query": CALENDAR_LIST_QUERY, - "limit": 1, - }); - let responses = vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - first_call_id, - SEARCH_TOOL_BM25_TOOL_NAME, - &serde_json::to_string(&first_args)?, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_assistant_message("msg-1", "first search done"), - ev_completed("resp-2"), - ]), - sse(vec![ - ev_response_created("resp-3"), - ev_function_call( - second_call_id, - SEARCH_TOOL_BM25_TOOL_NAME, - &serde_json::to_string(&second_args)?, - ), - ev_completed("resp-3"), - ]), - sse(vec![ - ev_response_created("resp-4"), - ev_assistant_message("msg-2", "second search done"), - ev_completed("resp-4"), - ]), - sse(vec![ - ev_response_created("resp-5"), - ev_assistant_message("msg-3", "resumed done"), - ev_completed("resp-5"), - ]), - ]; - let mock = mount_sse_sequence(&server, responses).await; - - let rmcp_test_server_bin = stdio_server_bin()?; - let mut builder = configured_builder( - apps_server.chatgpt_base_url.clone(), - Some(rmcp_test_server_bin.clone()), - ); - let test = builder.build(&server).await?; - - let home = test.home.clone(); - let rollout_path = test - .session_configured - .rollout_path - .clone() - .expect("rollout path should be available for resume"); - - test.submit_turn_with_policies( - "find create calendar tool", - AskForApproval::Never, - SandboxPolicy::DangerFullAccess, - ) - .await?; - test.submit_turn_with_policies( - "find list calendar tool", - AskForApproval::Never, - SandboxPolicy::DangerFullAccess, - ) - .await?; - - let requests = mock.requests(); - assert_eq!( - requests.len(), - 4, - "expected 4 requests before resume, got {}", - requests.len() - ); - - let first_search_payload = search_tool_output_payload(&requests[1], first_call_id); - let first_result_tools = search_result_tools(&first_search_payload); - assert_eq!( - first_result_tools.len(), - 1, - "first search should return exactly one tool: {first_search_payload:?}" - ); - assert_eq!( - first_result_tools[0].get("name").and_then(Value::as_str), - Some(CALENDAR_CREATE_TOOL), - "first search should return calendar create tool: {first_search_payload:?}" - ); - let first_selected_tools = active_selected_tools(&first_search_payload); - assert_eq!( - first_selected_tools, - vec![CALENDAR_CREATE_TOOL.to_string()], - "first search should only select create tool: {first_search_payload:?}" - ); - - let second_search_payload = search_tool_output_payload(&requests[3], second_call_id); - let second_result_tools = search_result_tools(&second_search_payload); - assert_eq!( - second_result_tools.len(), - 1, - "second search should return exactly one tool: {second_search_payload:?}" - ); - assert_eq!( - second_result_tools[0].get("name").and_then(Value::as_str), - Some(CALENDAR_LIST_TOOL), - "second search should return calendar list tool: {second_search_payload:?}" - ); - let second_selected_tools = active_selected_tools(&second_search_payload); - assert_eq!( - second_selected_tools, - vec![ - CALENDAR_CREATE_TOOL.to_string(), - CALENDAR_LIST_TOOL.to_string(), - ], - "multiple searches should persist union before resume: {second_search_payload:?}" - ); - - let mut resume_builder = configured_builder( - apps_server.chatgpt_base_url.clone(), - Some(rmcp_test_server_bin), - ); - let resumed = resume_builder.resume(&server, home, rollout_path).await?; - resumed - .submit_turn_with_policies( - "hello after resume with union", - AskForApproval::Never, - SandboxPolicy::DangerFullAccess, - ) - .await?; - - let requests = mock.requests(); - assert_eq!( - requests.len(), - 5, - "expected 5 requests after resumed turn, got {}", - requests.len() - ); - - let resumed_tools = tool_names(&requests[4].body_json()); - assert!( - resumed_tools.iter().any(|name| name == RMCP_ECHO_TOOL), - "non-app tools should remain visible after resume: {resumed_tools:?}" - ); - assert!( - resumed_tools + !second_request_tools .iter() .any(|name| name == CALENDAR_CREATE_TOOL), - "resumed turn should restore calendar create tool: {resumed_tools:?}" - ); - assert!( - resumed_tools.iter().any(|name| name == CALENDAR_LIST_TOOL), - "resumed turn should restore calendar list tool: {resumed_tools:?}" + "follow-up request should rely on tool_search_output history, not tool injection: {second_request_tools:?}" ); - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_selection_restores_when_forked_with_full_history() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let apps_server = AppsTestServer::mount(&server).await?; - let call_id = "tool-search"; - let args = json!({ - "query": CALENDAR_CREATE_QUERY, - "limit": 1, - }); - let responses = vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - call_id, - SEARCH_TOOL_BM25_TOOL_NAME, - &serde_json::to_string(&args)?, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - sse(vec![ - ev_response_created("resp-3"), - ev_assistant_message("msg-2", "forked done"), - ev_completed("resp-3"), - ]), - ]; - let mock = mount_sse_sequence(&server, responses).await; - - let rmcp_test_server_bin = stdio_server_bin()?; - let mut builder = configured_builder( - apps_server.chatgpt_base_url.clone(), - Some(rmcp_test_server_bin), - ); - let test = builder.build(&server).await?; - - test.submit_turn_with_policies( - "find calendar tools", - AskForApproval::Never, - SandboxPolicy::DangerFullAccess, - ) - .await?; - - let requests = mock.requests(); + let output_item = requests[2].function_call_output("calendar-call-1"); assert_eq!( - requests.len(), - 2, - "expected 2 requests after initial turn, got {}", - requests.len() + output_item.get("call_id").and_then(Value::as_str), + Some("calendar-call-1") ); - let search_output_payload = search_tool_output_payload(&requests[1], call_id); - let selected_tools = active_selected_tools(&search_output_payload); + + let third_request_tools = tool_names(&requests[2].body_json()); assert!( - selected_tools + !third_request_tools .iter() .any(|name| name == CALENDAR_CREATE_TOOL), - "search should select calendar create: {search_output_payload:?}" - ); - - let rollout_path = test - .codex - .rollout_path() - .expect("rollout path should exist for fork"); - let NewThread { thread: forked, .. } = test - .thread_manager - .fork_thread(usize::MAX, test.config.clone(), rollout_path, false) - .await?; - submit_user_input(&forked, "hello after fork").await?; - - let requests = mock.requests(); - assert_eq!( - requests.len(), - 3, - "expected 3 requests after forked turn, got {}", - requests.len() - ); - let forked_tools = tool_names(&requests[2].body_json()); - assert!( - forked_tools.iter().any(|name| name == RMCP_ECHO_TOOL), - "non-app tools should remain visible in forked thread: {forked_tools:?}" - ); - for selected_tool in &selected_tools { - assert!( - forked_tools.iter().any(|name| name == selected_tool), - "forked request should include restored selected tool {selected_tool:?}: {forked_tools:?}" - ); - } - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_selection_drops_when_fork_excludes_search_turn() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let apps_server = AppsTestServer::mount(&server).await?; - let call_id = "tool-search"; - let args = json!({ - "query": CALENDAR_CREATE_QUERY, - "limit": 1, - }); - let responses = vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - call_id, - SEARCH_TOOL_BM25_TOOL_NAME, - &serde_json::to_string(&args)?, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - sse(vec![ - ev_response_created("resp-3"), - ev_assistant_message("msg-2", "forked done"), - ev_completed("resp-3"), - ]), - ]; - let mock = mount_sse_sequence(&server, responses).await; - - let rmcp_test_server_bin = stdio_server_bin()?; - let mut builder = configured_builder( - apps_server.chatgpt_base_url.clone(), - Some(rmcp_test_server_bin), - ); - let test = builder.build(&server).await?; - - test.submit_turn_with_policies( - "find calendar tools", - AskForApproval::Never, - SandboxPolicy::DangerFullAccess, - ) - .await?; - - let requests = mock.requests(); - assert_eq!( - requests.len(), - 2, - "expected 2 requests after initial turn, got {}", - requests.len() - ); - let search_output_payload = search_tool_output_payload(&requests[1], call_id); - let selected_tools = active_selected_tools(&search_output_payload); - assert!( - !selected_tools.is_empty(), - "search turn should produce selected tools: {search_output_payload:?}" - ); - - let rollout_path = test - .codex - .rollout_path() - .expect("rollout path should exist for fork"); - let NewThread { thread: forked, .. } = test - .thread_manager - .fork_thread(0, test.config.clone(), rollout_path, false) - .await?; - submit_user_input(&forked, "hello after fork").await?; - - let requests = mock.requests(); - assert_eq!( - requests.len(), - 3, - "expected 3 requests after forked turn, got {}", - requests.len() - ); - let forked_tools = tool_names(&requests[2].body_json()); - assert!( - forked_tools.iter().any(|name| name == RMCP_ECHO_TOOL), - "non-app tools should remain visible in forked thread: {forked_tools:?}" - ); - assert!( - !forked_tools - .iter() - .any(|name| name.starts_with("mcp__codex_apps__")), - "forked history without search turn should not restore apps tools: {forked_tools:?}" + "post-tool follow-up should still rely on tool_search_output history, not tool injection: {third_request_tools:?}" ); Ok(()) diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 9bb0b82fd..327416093 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -898,7 +898,9 @@ impl SessionTelemetry { ResponseItem::Reasoning { .. } => "reasoning".into(), ResponseItem::LocalShellCall { .. } => "local_shell_call".into(), ResponseItem::FunctionCall { .. } => "function_call".into(), + ResponseItem::ToolSearchCall { .. } => "tool_search_call".into(), ResponseItem::FunctionCallOutput { .. } => "function_call_output".into(), + ResponseItem::ToolSearchOutput { .. } => "tool_search_output".into(), ResponseItem::CustomToolCall { .. } => "custom_tool_call".into(), ResponseItem::CustomToolCallOutput { .. } => "custom_tool_call_output".into(), ResponseItem::WebSearchCall { .. } => "web_search_call".into(), diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index fd353b12c..857ec14d4 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -240,6 +240,13 @@ pub enum ResponseInputItem { call_id: String, output: FunctionCallOutputPayload, }, + ToolSearchOutput { + call_id: String, + status: String, + execution: String, + #[ts(type = "unknown[]")] + tools: Vec, + }, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] @@ -320,12 +327,27 @@ pub enum ResponseItem { #[ts(skip)] id: Option, name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + namespace: Option, // The Responses API returns the function call arguments as a *string* that contains // JSON, not as an already‑parsed object. We keep it as a raw string here and let // Session::handle_function_call parse it into a Value. arguments: String, call_id: String, }, + ToolSearchCall { + #[serde(default, skip_serializing)] + #[ts(skip)] + id: Option, + call_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + status: Option, + execution: String, + #[ts(type = "unknown")] + arguments: serde_json::Value, + }, // NOTE: The `output` field for `function_call_output` uses a dedicated payload type with // custom serialization. On the wire it is either: // - a plain string (`content`) @@ -354,6 +376,13 @@ pub enum ResponseItem { call_id: String, output: FunctionCallOutputPayload, }, + ToolSearchOutput { + call_id: Option, + status: String, + execution: String, + #[ts(type = "unknown[]")] + tools: Vec, + }, // Emitted by the Responses API when the agent triggers a web search. // Example payload (from SSE `response.output_item.done`): // { @@ -883,6 +912,17 @@ impl From for ResponseItem { ResponseInputItem::CustomToolCallOutput { call_id, output } => { Self::CustomToolCallOutput { call_id, output } } + ResponseInputItem::ToolSearchOutput { + call_id, + status, + execution, + tools, + } => Self::ToolSearchOutput { + call_id: Some(call_id), + status, + execution, + tools, + }, } } } @@ -988,6 +1028,13 @@ impl From> for ResponseInputItem { } } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +pub struct SearchToolCallParams { + pub query: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub limit: Option, +} /// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec` /// or `shell`, the `arguments` field should deserialize to this struct. @@ -1721,6 +1768,29 @@ mod tests { assert_eq!(text, Some("line 1".to_string())); } + #[test] + fn function_call_deserializes_optional_namespace() { + let item: ResponseItem = serde_json::from_value(serde_json::json!({ + "type": "function_call", + "name": "mcp__codex_apps__gmail_get_recent_emails", + "namespace": "mcp__codex_apps__gmail", + "arguments": "{\"top_k\":5}", + "call_id": "call-1", + })) + .expect("function_call should deserialize"); + + assert_eq!( + item, + ResponseItem::FunctionCall { + id: None, + name: "mcp__codex_apps__gmail_get_recent_emails".to_string(), + namespace: Some("mcp__codex_apps__gmail".to_string()), + arguments: "{\"top_k\":5}".to_string(), + call_id: "call-1".to_string(), + } + ); + } + #[test] fn converts_sandbox_mode_into_developer_instructions() { let workspace_write: DeveloperInstructions = SandboxMode::WorkspaceWrite.into(); @@ -2193,6 +2263,169 @@ mod tests { Ok(()) } + #[test] + fn tool_search_call_roundtrips() -> Result<()> { + let parsed: ResponseItem = serde_json::from_str( + r#"{ + "type": "tool_search_call", + "call_id": "search-1", + "execution": "client", + "arguments": { + "query": "calendar create", + "limit": 1 + } + }"#, + )?; + + assert_eq!( + parsed, + ResponseItem::ToolSearchCall { + id: None, + call_id: Some("search-1".to_string()), + status: None, + execution: "client".to_string(), + arguments: serde_json::json!({ + "query": "calendar create", + "limit": 1, + }), + } + ); + + assert_eq!( + serde_json::to_value(&parsed)?, + serde_json::json!({ + "type": "tool_search_call", + "call_id": "search-1", + "execution": "client", + "arguments": { + "query": "calendar create", + "limit": 1, + } + }) + ); + + Ok(()) + } + + #[test] + fn tool_search_output_roundtrips() -> Result<()> { + let input = ResponseInputItem::ToolSearchOutput { + call_id: "search-1".to_string(), + status: "completed".to_string(), + execution: "client".to_string(), + tools: vec![serde_json::json!({ + "type": "function", + "name": "mcp__codex_apps__calendar_create_event", + "description": "Create a calendar event.", + "defer_loading": true, + "parameters": { + "type": "object", + "properties": { + "title": {"type": "string"} + }, + "required": ["title"], + "additionalProperties": false, + } + })], + }; + assert_eq!( + ResponseItem::from(input.clone()), + ResponseItem::ToolSearchOutput { + call_id: Some("search-1".to_string()), + status: "completed".to_string(), + execution: "client".to_string(), + tools: vec![serde_json::json!({ + "type": "function", + "name": "mcp__codex_apps__calendar_create_event", + "description": "Create a calendar event.", + "defer_loading": true, + "parameters": { + "type": "object", + "properties": { + "title": {"type": "string"} + }, + "required": ["title"], + "additionalProperties": false, + } + })], + } + ); + + assert_eq!( + serde_json::to_value(input)?, + serde_json::json!({ + "type": "tool_search_output", + "call_id": "search-1", + "status": "completed", + "execution": "client", + "tools": [{ + "type": "function", + "name": "mcp__codex_apps__calendar_create_event", + "description": "Create a calendar event.", + "defer_loading": true, + "parameters": { + "type": "object", + "properties": { + "title": {"type": "string"} + }, + "required": ["title"], + "additionalProperties": false, + } + }] + }) + ); + + Ok(()) + } + + #[test] + fn tool_search_server_items_allow_null_call_id() -> Result<()> { + let parsed_call: ResponseItem = serde_json::from_str( + r#"{ + "type": "tool_search_call", + "execution": "server", + "call_id": null, + "status": "completed", + "arguments": { + "paths": ["crm"] + } + }"#, + )?; + assert_eq!( + parsed_call, + ResponseItem::ToolSearchCall { + id: None, + call_id: None, + status: Some("completed".to_string()), + execution: "server".to_string(), + arguments: serde_json::json!({ + "paths": ["crm"], + }), + } + ); + + let parsed_output: ResponseItem = serde_json::from_str( + r#"{ + "type": "tool_search_output", + "execution": "server", + "call_id": null, + "status": "completed", + "tools": [] + }"#, + )?; + assert_eq!( + parsed_output, + ResponseItem::ToolSearchOutput { + call_id: None, + status: "completed".to_string(), + execution: "server".to_string(), + tools: vec![], + } + ); + + Ok(()) + } + #[test] fn mixed_remote_and_local_images_share_label_sequence() -> Result<()> { let image_url = "data:image/png;base64,abc".to_string(); diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index c3bb6be0e..f679c077b 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -451,6 +451,7 @@ pub struct ToolWithConnectorId { pub tool: Tool, pub connector_id: Option, pub connector_name: Option, + pub connector_description: Option, } pub struct ListToolsWithConnectorIdResult { @@ -616,10 +617,13 @@ impl RmcpClient { let connector_id = Self::meta_string(meta, "connector_id"); let connector_name = Self::meta_string(meta, "connector_name") .or_else(|| Self::meta_string(meta, "connector_display_name")); + let connector_description = Self::meta_string(meta, "connector_description") + .or_else(|| Self::meta_string(meta, "connectorDescription")); Ok(ToolWithConnectorId { tool, connector_id, connector_name, + connector_description, }) }) .collect::>>()?;