diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 15257f57e5..2610bee95c 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -238,15 +238,12 @@ fn spec_for_model_request( } } -pub(crate) fn hosted_model_tool_specs(turn_context: &TurnContext) -> Vec { +fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec { + let turn_context = context.turn_context; let mut specs = Vec::new(); let provider_capabilities = turn_context.provider.capabilities(); - let standalone_web_search = turn_context - .config - .features - .enabled(Feature::StandaloneWebSearch) - && turn_context.provider.info().is_openai(); - let web_search_mode = (!standalone_web_search && provider_capabilities.web_search) + let web_search_mode = (!standalone_web_run_available(context.extension_tool_executors) + && provider_capabilities.web_search) .then_some(turn_context.config.web_search_mode.value()); let web_search_config = if provider_capabilities.web_search { turn_context.config.web_search_config.as_ref() @@ -508,11 +505,20 @@ fn add_tool_sources(context: &CoreToolPlanContext<'_>, planned_tools: &mut Plann add_mcp_runtime_tools(context, planned_tools); add_dynamic_tools(context, planned_tools); add_extension_tools(context, planned_tools); - for spec in hosted_model_tool_specs(context.turn_context) { + for spec in hosted_model_tool_specs(context) { planned_tools.add_hosted_spec(spec); } } +fn standalone_web_run_available( + extension_tools: &[Arc>], +) -> bool { + let web_run = ToolName::namespaced("web", "run"); + extension_tools + .iter() + .any(|executor| executor.tool_name() == web_run) +} + fn add_shell_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { let turn_context = context.turn_context; let features = turn_context.features.get(); diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index f8a7e6aed3..3e0abeba14 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -20,8 +20,11 @@ use codex_tools::DiscoverablePluginInfo; use codex_tools::DiscoverableTool; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ResponsesApiTool; +use codex_tools::ToolCall as ExtensionToolCall; +use codex_tools::ToolExecutor; use codex_tools::ToolExposure; use codex_tools::ToolName; +use codex_tools::ToolOutput; use codex_tools::ToolSpec; use pretty_assertions::assert_eq; use serde_json::json; @@ -37,6 +40,7 @@ struct ToolPlanInputs { mcp_tools: Option>, deferred_mcp_tools: Option>, discoverable_tools: Option>, + extension_tool_executors: Vec>>, dynamic_tools: Vec, } @@ -176,7 +180,7 @@ async fn probe_with( mcp_tools: inputs.mcp_tools, deferred_mcp_tools: inputs.deferred_mcp_tools, discoverable_tools: inputs.discoverable_tools, - extension_tool_executors: Vec::new(), + extension_tool_executors: inputs.extension_tool_executors, dynamic_tools: inputs.dynamic_tools.as_slice(), }, ); @@ -253,6 +257,22 @@ fn use_bedrock_provider(turn: &mut TurnContext) { turn.provider = create_model_provider(provider_info, turn.auth_manager.clone()); } +struct WebRunExtensionTool; + +#[async_trait::async_trait] +impl ToolExecutor for WebRunExtensionTool { + fn tool_name(&self) -> ToolName { + ToolName::namespaced("web", "run") + } + + async fn handle( + &self, + _call: ExtensionToolCall, + ) -> Result, codex_tools::FunctionCallError> { + Ok(Box::new(codex_tools::JsonToolOutput::new(json!({})))) + } +} + fn duplicate_primary_environment(turn: &mut TurnContext) { let mut second_environment = turn.environments.turn_environments[0].clone(); second_environment.environment_id = "secondary".to_string(); @@ -948,11 +968,24 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() { } ); - let standalone_web_search = probe(|turn| { + let standalone_web_search_without_web_run = probe(|turn| { set_feature(turn, Feature::StandaloneWebSearch, /*enabled*/ true); set_web_search_mode(turn, WebSearchMode::Live); }) .await; + standalone_web_search_without_web_run.assert_visible_contains(&["web_search"]); + + let standalone_web_search = probe_with( + |turn| { + set_feature(turn, Feature::StandaloneWebSearch, /*enabled*/ true); + set_web_search_mode(turn, WebSearchMode::Live); + }, + ToolPlanInputs { + extension_tool_executors: vec![Arc::new(WebRunExtensionTool)], + ..Default::default() + }, + ) + .await; standalone_web_search.assert_visible_lacks(&["web_search"]); let unsupported_provider = probe(|turn| { diff --git a/codex-rs/ext/web-search/src/extension.rs b/codex-rs/ext/web-search/src/extension.rs index afe05f167c..77744d54db 100644 --- a/codex-rs/ext/web-search/src/extension.rs +++ b/codex-rs/ext/web-search/src/extension.rs @@ -36,8 +36,8 @@ struct WebSearchExtensionConfig { settings: SearchSettings, } -impl WebSearchExtensionConfig { - fn from_config(config: &Config) -> Self { +impl From<&Config> for WebSearchExtensionConfig { + fn from(config: &Config) -> Self { let web_search_mode = config.web_search_mode.value(); Self { enabled: config.features.enabled(Feature::StandaloneWebSearch) @@ -87,7 +87,7 @@ impl ThreadLifecycleContributor for WebSearchExtension { async fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { input .thread_store - .insert(WebSearchExtensionConfig::from_config(input.config)); + .insert(WebSearchExtensionConfig::from(input.config)); } } @@ -99,7 +99,7 @@ impl ConfigContributor for WebSearchExtension { _previous_config: &Config, new_config: &Config, ) { - thread_store.insert(WebSearchExtensionConfig::from_config(new_config)); + thread_store.insert(WebSearchExtensionConfig::from(new_config)); } } diff --git a/codex-rs/ext/web-search/src/history.rs b/codex-rs/ext/web-search/src/history.rs index ef33a8c7b2..41269c9982 100644 --- a/codex-rs/ext/web-search/src/history.rs +++ b/codex-rs/ext/web-search/src/history.rs @@ -11,8 +11,8 @@ const ASSISTANT_CONTEXT_TOKEN_LIMIT: usize = 1_000; /// Builds the persisted conversation tail for standalone web search. /// -/// The tail keeps the previous user message, up to 1k tokens of assistant text -/// that followed it, and the current user message. +/// The tail keeps the previous user text message, up to 1k tokens of assistant +/// text that followed it, and the current user text message. pub(crate) fn recent_input(items: &[RolloutItem]) -> Option { let messages = recent_messages(items); (!messages.is_empty()).then_some(SearchInput::Items(messages)) @@ -46,11 +46,29 @@ fn recent_messages(items: &[RolloutItem]) -> Vec { } fn push_visible_message(messages: &mut Vec, item: &ResponseItem) { - if matches!( - item, - ResponseItem::Message { role, .. } if role == "user" || role == "assistant" - ) { - messages.push(item.clone()); + match item { + ResponseItem::Message { role, .. } if role == "assistant" => messages.push(item.clone()), + ResponseItem::Message { + id, + role, + content, + phase, + } if role == "user" => { + let content = content + .iter() + .filter(|item| matches!(item, ContentItem::InputText { .. })) + .cloned() + .collect::>(); + if !content.is_empty() { + messages.push(ResponseItem::Message { + id: id.clone(), + role: role.clone(), + content, + phase: phase.clone(), + }); + } + } + _ => {} } } @@ -119,7 +137,6 @@ fn cap_assistant_text(messages: &mut Vec) { mod tests { use codex_api::SearchInput; use codex_protocol::models::ContentItem; - use codex_protocol::models::ImageDetail; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::RolloutItem; @@ -206,7 +223,7 @@ mod tests { } #[test] - fn preserves_image_content_from_recent_user_messages() { + fn keeps_only_text_from_recent_user_messages() { let previous_user = ResponseItem::Message { id: None, role: "user".to_string(), @@ -216,7 +233,7 @@ mod tests { }, ContentItem::InputImage { image_url: "data:image/png;base64,image".to_string(), - detail: Some(ImageDetail::High), + detail: None, }, ], phase: None, @@ -230,7 +247,7 @@ mod tests { assert_eq!( recent_input(&items), Some(SearchInput::Items(vec![ - previous_user, + message("user", "previous user"), message("assistant", "previous assistant"), message("user", "current user"), ]))