From d4af6053e212a982e53372a3dff5a627c60af1db Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Sun, 15 Mar 2026 21:41:55 -0700 Subject: [PATCH] [apps] Improve search tool fallback. (#14732) - [x] Bypass tool search and stuff tool specs directly into model context when either a. Tool search is not available for the model or b. There are not that many tools to search for. --- codex-rs/core/src/codex.rs | 18 ++++++- .../core/tests/common/apps_test_server.rs | 49 ++++++++++++++++++- codex-rs/core/tests/suite/search_tool.rs | 8 +-- 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 490f02553..4c852d48f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -377,6 +377,7 @@ pub(crate) const INITIAL_SUBMIT_ID: &str = ""; pub(crate) const SUBMISSION_CHANNEL_CAPACITY: usize = 512; const CYBER_VERIFY_URL: &str = "https://chatgpt.com/cyber"; const CYBER_SAFETY_URL: &str = "https://developers.openai.com/codex/concepts/cyber-safety"; +const DIRECT_APP_TOOL_EXPOSURE_THRESHOLD: usize = 100; impl Codex { /// Spawn a new [`Codex`] and initialize the session. @@ -6463,8 +6464,6 @@ pub(crate) async fn built_tools( None }; - // Keep the connector-grouped app view around for the router even though - // app tools only become prompt-visible after explicit selection/discovery. let app_tools = connectors.as_ref().map(|connectors| { filter_codex_apps_mcp_tools(&mcp_tools, connectors, &turn_context.config) }); @@ -6491,6 +6490,21 @@ pub(crate) async fn built_tools( mcp_tools = selected_mcp_tools; } + // Expose app tools directly when tool_search is disabled, or when tool_search + // is enabled but the accessible app tool set stays below the direct-exposure threshold. + let expose_app_tools_directly = !turn_context.tools_config.search_tool + || app_tools + .as_ref() + .is_some_and(|tools| tools.len() < DIRECT_APP_TOOL_EXPOSURE_THRESHOLD); + if expose_app_tools_directly && let Some(app_tools) = app_tools.as_ref() { + mcp_tools.extend(app_tools.clone()); + } + let app_tools = if expose_app_tools_directly { + None + } else { + app_tools + }; + Ok(Arc::new(ToolRouter::from_config( &turn_context.tools_config, ToolRouterParams { diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index 8ac60ffb1..450a170b2 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -18,6 +18,7 @@ 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"; +const SEARCHABLE_TOOL_COUNT: usize = 100; pub const CALENDAR_CREATE_EVENT_RESOURCE_URI: &str = "connector://calendar/tools/calendar_create_event"; const CALENDAR_LIST_EVENTS_RESOURCE_URI: &str = "connector://calendar/tools/calendar_list_events"; @@ -32,6 +33,21 @@ impl AppsTestServer { Self::mount_with_connector_name(server, CONNECTOR_NAME).await } + pub async fn mount_searchable(server: &MockServer) -> Result { + mount_oauth_metadata(server).await; + mount_connectors_directory(server).await; + mount_streamable_http_json_rpc( + server, + CONNECTOR_NAME.to_string(), + CONNECTOR_DESCRIPTION.to_string(), + /*searchable*/ true, + ) + .await; + Ok(Self { + chatgpt_base_url: server.uri(), + }) + } + pub async fn mount_with_connector_name( server: &MockServer, connector_name: &str, @@ -42,6 +58,7 @@ impl AppsTestServer { server, connector_name.to_string(), CONNECTOR_DESCRIPTION.to_string(), + /*searchable*/ false, ) .await; Ok(Self { @@ -97,12 +114,14 @@ async fn mount_streamable_http_json_rpc( server: &MockServer, connector_name: String, connector_description: String, + searchable: bool, ) { Mock::given(method("POST")) .and(path_regex("^/api/codex/apps/?$")) .respond_with(CodexAppsJsonRpcResponder { connector_name, connector_description, + searchable, }) .mount(server) .await; @@ -111,6 +130,7 @@ async fn mount_streamable_http_json_rpc( struct CodexAppsJsonRpcResponder { connector_name: String, connector_description: String, + searchable: bool, } impl Respond for CodexAppsJsonRpcResponder { @@ -157,7 +177,7 @@ impl Respond for CodexAppsJsonRpcResponder { "notifications/initialized" => ResponseTemplate::new(202), "tools/list" => { let id = body.get("id").cloned().unwrap_or(Value::Null); - ResponseTemplate::new(200).set_body_json(json!({ + let mut response = json!({ "jsonrpc": "2.0", "id": id, "result": { @@ -211,7 +231,32 @@ impl Respond for CodexAppsJsonRpcResponder { ], "nextCursor": null } - })) + }); + if self.searchable + && let Some(tools) = response + .pointer_mut("/result/tools") + .and_then(Value::as_array_mut) + { + for index in 2..SEARCHABLE_TOOL_COUNT { + tools.push(json!({ + "name": format!("calendar_timezone_option_{index}"), + "description": format!("Read timezone option {index}."), + "inputSchema": { + "type": "object", + "properties": { + "timezone": { "type": "string" } + }, + "additionalProperties": false + }, + "_meta": { + "connector_id": CONNECTOR_ID, + "connector_name": self.connector_name.clone(), + "connector_description": self.connector_description.clone() + } + })); + } + } + ResponseTemplate::new(200).set_body_json(response) } "tools/call" => { let id = body.get("id").cloned().unwrap_or(Value::Null); diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 485a216b4..118f1bd58 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -116,7 +116,7 @@ async fn search_tool_flag_adds_tool_search() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let apps_server = AppsTestServer::mount(&server).await?; + let apps_server = AppsTestServer::mount_searchable(&server).await?; let mock = mount_sse_once( &server, sse(vec![ @@ -212,7 +212,7 @@ async fn search_tool_adds_discovery_instructions_to_tool_description() -> Result skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let apps_server = AppsTestServer::mount(&server).await?; + let apps_server = AppsTestServer::mount_searchable(&server).await?; let mock = mount_sse_once( &server, sse(vec![ @@ -254,7 +254,7 @@ 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 apps_server = AppsTestServer::mount_searchable(&server).await?; let mock = mount_sse_once( &server, sse(vec![ @@ -329,7 +329,7 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let apps_server = AppsTestServer::mount(&server).await?; + let apps_server = AppsTestServer::mount_searchable(&server).await?; let call_id = "tool-search-1"; let mock = mount_sse_sequence( &server,