diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index 55b20091..64157407 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -623,6 +623,12 @@ where Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryPartAdded))) => { continue; } + Poll::Ready(Some(Ok(ResponseEvent::WebSearchCallBegin { .. }))) => { + return Poll::Ready(Some(Ok(ResponseEvent::WebSearchCallBegin { + call_id: String::new(), + query: None, + }))); + } } } } diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 6206b426..ecf1ae71 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -149,7 +149,21 @@ impl ModelClient { let store = prompt.store && auth_mode != Some(AuthMode::ChatGPT); let full_instructions = prompt.get_full_instructions(&self.config.model_family); - let tools_json = create_tools_json_for_responses_api(&prompt.tools)?; + let mut tools_json = create_tools_json_for_responses_api(&prompt.tools)?; + // ChatGPT backend expects the preview name for web search. + if auth_mode == Some(AuthMode::ChatGPT) { + for tool in &mut tools_json { + if let Some(map) = tool.as_object_mut() + && map.get("type").and_then(|v| v.as_str()) == Some("web_search") + { + map.insert( + "type".to_string(), + serde_json::Value::String("web_search_preview".to_string()), + ); + } + } + } + let reasoning = create_reasoning_param_for_request( &self.config.model_family, self.effort, @@ -466,7 +480,8 @@ async fn process_sse( } }; - trace!("SSE event: {}", sse.data); + let raw = sse.data.clone(); + trace!("SSE event: {}", raw); let event: SseEvent = match serde_json::from_str(&sse.data) { Ok(event) => event, @@ -580,8 +595,24 @@ async fn process_sse( | "response.in_progress" | "response.output_item.added" | "response.output_text.done" => { - // Currently, we ignore this event, but we handle it - // separately to skip the logging message in the `other` case. + if event.kind == "response.output_item.added" + && let Some(item) = event.item.as_ref() + { + // Detect web_search_call begin and forward a synthetic event upstream. + if let Some(ty) = item.get("type").and_then(|v| v.as_str()) + && ty == "web_search_call" + { + let call_id = item + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let ev = ResponseEvent::WebSearchCallBegin { call_id, query: None }; + if tx_event.send(Ok(ev)).await.is_err() { + return; + } + } + } } "response.reasoning_summary_part.added" => { // Boundary between reasoning summary sections (e.g., titles). @@ -591,7 +622,7 @@ async fn process_sse( } } "response.reasoning_summary_text.done" => {} - other => debug!(other, "sse event"), + _ => {} } } } diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index c320d8d0..e2c191f4 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -93,6 +93,10 @@ pub enum ResponseEvent { ReasoningSummaryDelta(String), ReasoningContentDelta(String), ReasoningSummaryPartAdded, + WebSearchCallBegin { + call_id: String, + query: Option, + }, } #[derive(Debug, Serialize)] diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a7f8e7c3..e175f550 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -96,6 +96,7 @@ use crate::protocol::StreamErrorEvent; use crate::protocol::Submission; use crate::protocol::TaskCompleteEvent; use crate::protocol::TurnDiffEvent; +use crate::protocol::WebSearchBeginEvent; use crate::rollout::RolloutRecorder; use crate::safety::SafetyCheck; use crate::safety::assess_command_safety; @@ -511,6 +512,7 @@ impl Session { sandbox_policy.clone(), config.include_plan_tool, config.include_apply_patch_tool, + config.tools_web_search_request, config.use_experimental_streamable_shell_tool, ), user_instructions, @@ -1096,6 +1098,7 @@ async fn submission_loop( new_sandbox_policy.clone(), config.include_plan_tool, config.include_apply_patch_tool, + config.tools_web_search_request, config.use_experimental_streamable_shell_tool, ); @@ -1175,6 +1178,7 @@ async fn submission_loop( sandbox_policy.clone(), config.include_plan_tool, config.include_apply_patch_tool, + config.tools_web_search_request, config.use_experimental_streamable_shell_tool, ), user_instructions: turn_context.user_instructions.clone(), @@ -1687,6 +1691,7 @@ async fn try_run_turn( let mut stream = turn_context.client.clone().stream(&prompt).await?; let mut output = Vec::new(); + loop { // Poll the next item from the model stream. We must inspect *both* Ok and Err // cases so that transient stream failures (e.g., dropped SSE connection before @@ -1723,6 +1728,16 @@ async fn try_run_turn( .await?; output.push(ProcessedResponseItem { item, response }); } + ResponseEvent::WebSearchCallBegin { call_id, query } => { + let q = query.unwrap_or_else(|| "Searching Web...".to_string()); + let _ = sess + .tx_event + .send(Event { + id: sub_id.to_string(), + msg: EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id, query: q }), + }) + .await; + } ResponseEvent::Completed { response_id: _, token_usage, diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index fbf0387a..98a8fde1 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -169,6 +169,8 @@ pub struct Config { /// model family's default preference. pub include_apply_patch_tool: bool, + pub tools_web_search_request: bool, + /// The value for the `originator` header included with Responses API requests. pub responses_originator_header: String, @@ -480,6 +482,9 @@ pub struct ConfigToml { /// If set to `true`, the API key will be signed with the `originator` header. pub preferred_auth_method: Option, + + /// Nested tools section for feature toggles + pub tools: Option, } #[derive(Deserialize, Debug, Clone, PartialEq, Eq)] @@ -487,6 +492,13 @@ pub struct ProjectConfig { pub trust_level: Option, } +#[derive(Deserialize, Debug, Clone, Default)] +pub struct ToolsToml { + // Renamed from `web_search_request`; keep alias for backwards compatibility. + #[serde(default, alias = "web_search_request")] + pub web_search: Option, +} + impl ConfigToml { /// Derive the effective sandbox policy from the configuration. fn derive_sandbox_policy(&self, sandbox_mode_override: Option) -> SandboxPolicy { @@ -576,6 +588,7 @@ pub struct ConfigOverrides { pub include_apply_patch_tool: Option, pub disable_response_storage: Option, pub show_raw_agent_reasoning: Option, + pub tools_web_search_request: Option, } impl Config { @@ -602,6 +615,7 @@ impl Config { include_apply_patch_tool, disable_response_storage, show_raw_agent_reasoning, + tools_web_search_request: override_tools_web_search_request, } = overrides; let config_profile = match config_profile_key.as_ref().or(cfg.profile.as_ref()) { @@ -640,7 +654,7 @@ impl Config { })? .clone(); - let shell_environment_policy = cfg.shell_environment_policy.into(); + let shell_environment_policy = cfg.shell_environment_policy.clone().into(); let resolved_cwd = { use std::env; @@ -661,7 +675,11 @@ impl Config { } }; - let history = cfg.history.unwrap_or_default(); + let history = cfg.history.clone().unwrap_or_default(); + + let tools_web_search_request = override_tools_web_search_request + .or(cfg.tools.as_ref().and_then(|t| t.web_search)) + .unwrap_or(false); let model = model .or(config_profile.model) @@ -735,7 +753,7 @@ impl Config { codex_home, history, file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode), - tui: cfg.tui.unwrap_or_default(), + tui: cfg.tui.clone().unwrap_or_default(), codex_linux_sandbox_exe, hide_agent_reasoning: cfg.hide_agent_reasoning.unwrap_or(false), @@ -754,12 +772,13 @@ impl Config { model_verbosity: config_profile.model_verbosity.or(cfg.model_verbosity), chatgpt_base_url: config_profile .chatgpt_base_url - .or(cfg.chatgpt_base_url) + .or(cfg.chatgpt_base_url.clone()) .unwrap_or("https://chatgpt.com/backend-api/".to_string()), experimental_resume, include_plan_tool: include_plan_tool.unwrap_or(false), include_apply_patch_tool: include_apply_patch_tool.unwrap_or(false), + tools_web_search_request, responses_originator_header, preferred_auth_method: cfg.preferred_auth_method.unwrap_or(AuthMode::ChatGPT), use_experimental_streamable_shell_tool: cfg @@ -1129,6 +1148,7 @@ disable_response_storage = true base_instructions: None, include_plan_tool: false, include_apply_patch_tool: false, + tools_web_search_request: false, responses_originator_header: "codex_cli_rs".to_string(), preferred_auth_method: AuthMode::ChatGPT, use_experimental_streamable_shell_tool: false, @@ -1184,6 +1204,7 @@ disable_response_storage = true base_instructions: None, include_plan_tool: false, include_apply_patch_tool: false, + tools_web_search_request: false, responses_originator_header: "codex_cli_rs".to_string(), preferred_auth_method: AuthMode::ChatGPT, use_experimental_streamable_shell_tool: false, @@ -1254,6 +1275,7 @@ disable_response_storage = true base_instructions: None, include_plan_tool: false, include_apply_patch_tool: false, + tools_web_search_request: false, responses_originator_header: "codex_cli_rs".to_string(), preferred_auth_method: AuthMode::ChatGPT, use_experimental_streamable_shell_tool: false, diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs index 272c901d..516a9844 100644 --- a/codex-rs/core/src/openai_tools.rs +++ b/codex-rs/core/src/openai_tools.rs @@ -47,6 +47,8 @@ pub(crate) enum OpenAiTool { Function(ResponsesApiTool), #[serde(rename = "local_shell")] LocalShell {}, + #[serde(rename = "web_search")] + WebSearch {}, #[serde(rename = "custom")] Freeform(FreeformTool), } @@ -64,6 +66,7 @@ pub struct ToolsConfig { pub shell_type: ConfigShellToolType, pub plan_tool: bool, pub apply_patch_tool_type: Option, + pub web_search_request: bool, } impl ToolsConfig { @@ -73,6 +76,7 @@ impl ToolsConfig { sandbox_policy: SandboxPolicy, include_plan_tool: bool, include_apply_patch_tool: bool, + include_web_search_request: bool, use_streamable_shell_tool: bool, ) -> Self { let mut shell_type = if use_streamable_shell_tool { @@ -104,6 +108,7 @@ impl ToolsConfig { shell_type, plan_tool: include_plan_tool, apply_patch_tool_type, + web_search_request: include_web_search_request, } } } @@ -521,6 +526,10 @@ pub(crate) fn get_openai_tools( } } + if config.web_search_request { + tools.push(OpenAiTool::WebSearch {}); + } + if let Some(mcp_tools) = mcp_tools { for (name, tool) in mcp_tools { match mcp_tool_to_openai_tool(name.clone(), tool.clone()) { @@ -549,6 +558,7 @@ mod tests { .map(|tool| match tool { OpenAiTool::Function(ResponsesApiTool { name, .. }) => name, OpenAiTool::LocalShell {} => "local_shell", + OpenAiTool::WebSearch {} => "web_search", OpenAiTool::Freeform(FreeformTool { name, .. }) => name, }) .collect::>(); @@ -576,11 +586,12 @@ mod tests { SandboxPolicy::ReadOnly, true, false, + true, /*use_experimental_streamable_shell_tool*/ false, ); let tools = get_openai_tools(&config, Some(HashMap::new())); - assert_eq_tool_names(&tools, &["local_shell", "update_plan"]); + assert_eq_tool_names(&tools, &["local_shell", "update_plan", "web_search"]); } #[test] @@ -592,11 +603,12 @@ mod tests { SandboxPolicy::ReadOnly, true, false, + true, /*use_experimental_streamable_shell_tool*/ false, ); let tools = get_openai_tools(&config, Some(HashMap::new())); - assert_eq_tool_names(&tools, &["shell", "update_plan"]); + assert_eq_tool_names(&tools, &["shell", "update_plan", "web_search"]); } #[test] @@ -608,6 +620,7 @@ mod tests { SandboxPolicy::ReadOnly, false, false, + true, /*use_experimental_streamable_shell_tool*/ false, ); let tools = get_openai_tools( @@ -631,8 +644,8 @@ mod tests { "number_property": { "type": "number" }, }, "required": [ - "string_property", - "number_property" + "string_property".to_string(), + "number_property".to_string() ], "additionalProperties": Some(false), }, @@ -648,10 +661,13 @@ mod tests { )])), ); - assert_eq_tool_names(&tools, &["shell", "test_server/do_something_cool"]); + assert_eq_tool_names( + &tools, + &["shell", "web_search", "test_server/do_something_cool"], + ); assert_eq!( - tools[1], + tools[2], OpenAiTool::Function(ResponsesApiTool { name: "test_server/do_something_cool".to_string(), parameters: JsonSchema::Object { @@ -703,6 +719,7 @@ mod tests { SandboxPolicy::ReadOnly, false, false, + true, /*use_experimental_streamable_shell_tool*/ false, ); @@ -729,10 +746,10 @@ mod tests { )])), ); - assert_eq_tool_names(&tools, &["shell", "dash/search"]); + assert_eq_tool_names(&tools, &["shell", "web_search", "dash/search"]); assert_eq!( - tools[1], + tools[2], OpenAiTool::Function(ResponsesApiTool { name: "dash/search".to_string(), parameters: JsonSchema::Object { @@ -760,6 +777,7 @@ mod tests { SandboxPolicy::ReadOnly, false, false, + true, /*use_experimental_streamable_shell_tool*/ false, ); @@ -784,9 +802,9 @@ mod tests { )])), ); - assert_eq_tool_names(&tools, &["shell", "dash/paginate"]); + assert_eq_tool_names(&tools, &["shell", "web_search", "dash/paginate"]); assert_eq!( - tools[1], + tools[2], OpenAiTool::Function(ResponsesApiTool { name: "dash/paginate".to_string(), parameters: JsonSchema::Object { @@ -812,6 +830,7 @@ mod tests { SandboxPolicy::ReadOnly, false, false, + true, /*use_experimental_streamable_shell_tool*/ false, ); @@ -836,9 +855,9 @@ mod tests { )])), ); - assert_eq_tool_names(&tools, &["shell", "dash/tags"]); + assert_eq_tool_names(&tools, &["shell", "web_search", "dash/tags"]); assert_eq!( - tools[1], + tools[2], OpenAiTool::Function(ResponsesApiTool { name: "dash/tags".to_string(), parameters: JsonSchema::Object { @@ -867,6 +886,7 @@ mod tests { SandboxPolicy::ReadOnly, false, false, + true, /*use_experimental_streamable_shell_tool*/ false, ); @@ -891,9 +911,9 @@ mod tests { )])), ); - assert_eq_tool_names(&tools, &["shell", "dash/value"]); + assert_eq_tool_names(&tools, &["shell", "web_search", "dash/value"]); assert_eq!( - tools[1], + tools[2], OpenAiTool::Function(ResponsesApiTool { name: "dash/value".to_string(), parameters: JsonSchema::Object { diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 0f7e14ea..cfdba984 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -24,6 +24,7 @@ use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TurnAbortReason; use codex_core::protocol::TurnDiffEvent; +use codex_core::protocol::WebSearchBeginEvent; use owo_colors::OwoColorize; use owo_colors::Style; use shlex::try_join; @@ -361,6 +362,9 @@ impl EventProcessor for EventProcessorWithHumanOutput { } } } + EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id: _, query }) => { + ts_println!(self, "🌐 {query}"); + } EventMsg::PatchApplyBegin(PatchApplyBeginEvent { call_id, auto_approved, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index d403cb79..3de95291 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -150,6 +150,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any include_apply_patch_tool: None, disable_response_storage: oss.then_some(true), show_raw_agent_reasoning: oss.then_some(true), + tools_web_search_request: None, }; // Parse `-c` overrides. let cli_kv_overrides = match config_overrides.parse_overrides() { diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs index 0bbf6ff8..97a3602d 100644 --- a/codex-rs/mcp-server/src/codex_message_processor.rs +++ b/codex-rs/mcp-server/src/codex_message_processor.rs @@ -738,6 +738,7 @@ fn derive_config_from_params( include_apply_patch_tool, disable_response_storage: None, show_raw_agent_reasoning: None, + tools_web_search_request: None, }; let cli_overrides = cli_overrides diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 5993c10f..69f07ff2 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -163,6 +163,7 @@ impl CodexToolCallParam { include_apply_patch_tool: None, disable_response_storage: None, show_raw_agent_reasoning: None, + tools_web_search_request: None, }; let cli_overrides = cli_overrides diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index c6d65bc8..8480e29c 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -272,6 +272,7 @@ async fn run_codex_tool_session_inner( | EventMsg::PatchApplyBegin(_) | EventMsg::PatchApplyEnd(_) | EventMsg::TurnDiff(_) + | EventMsg::WebSearchBegin(_) | EventMsg::GetHistoryEntryResponse(_) | EventMsg::PlanUpdate(_) | EventMsg::TurnAborted(_) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7e7708b2..71c15381 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -437,6 +437,8 @@ pub enum EventMsg { McpToolCallEnd(McpToolCallEndEvent), + WebSearchBegin(WebSearchBeginEvent), + /// Notification that the server is about to execute a command. ExecCommandBegin(ExecCommandBeginEvent), @@ -658,6 +660,12 @@ impl McpToolCallEndEvent { } } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct WebSearchBeginEvent { + pub call_id: String, + pub query: String, +} + /// Response payload for `Op::GetHistory` containing the current session's /// in-memory transcript. #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 235280be..5f48b0e4 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -28,6 +28,7 @@ use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TokenUsage; use codex_core::protocol::TurnAbortReason; use codex_core::protocol::TurnDiffEvent; +use codex_core::protocol::WebSearchBeginEvent; use codex_protocol::parse_command::ParsedCommand; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -308,6 +309,11 @@ impl ChatWidget { self.defer_or_handle(|q| q.push_mcp_end(ev), |s| s.handle_mcp_end_now(ev2)); } + fn on_web_search_begin(&mut self, ev: WebSearchBeginEvent) { + self.flush_answer_stream_with_separator(); + self.add_to_history(history_cell::new_web_search_call(ev.query)); + } + fn on_get_history_entry_response( &mut self, event: codex_core::protocol::GetHistoryEntryResponseEvent, @@ -839,6 +845,7 @@ impl ChatWidget { EventMsg::ExecCommandEnd(ev) => self.on_exec_command_end(ev), EventMsg::McpToolCallBegin(ev) => self.on_mcp_tool_call_begin(ev), EventMsg::McpToolCallEnd(ev) => self.on_mcp_tool_call_end(ev), + EventMsg::WebSearchBegin(ev) => self.on_web_search_begin(ev), EventMsg::GetHistoryEntryResponse(ev) => self.on_get_history_entry_response(ev), EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev), EventMsg::ShutdownComplete => self.on_shutdown_complete(), diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index 91ee9cfd..8eb6d6b8 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -54,6 +54,10 @@ pub struct Cli { #[clap(long = "cd", short = 'C', value_name = "DIR")] pub cwd: Option, + /// Enable web search (off by default). When enabled, the native Responses `web_search` tool is available to the model (no per‑call approval). + #[arg(long = "search", default_value_t = false)] + pub web_search: bool, + #[clap(skip)] pub config_overrides: CliConfigOverrides, } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 3e51ffd0..0b2af7a1 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -445,6 +445,12 @@ pub(crate) fn new_active_mcp_tool_call(invocation: McpInvocation) -> PlainHistor PlainHistoryCell { lines } } +pub(crate) fn new_web_search_call(query: String) -> PlainHistoryCell { + let lines: Vec> = + vec![Line::from(""), Line::from(vec!["🌐 ".into(), query.into()])]; + PlainHistoryCell { lines } +} + /// If the first content is an image, return a new cell with the image. /// TODO(rgwood-dd): Handle images properly even if they're not the first result. fn try_new_completed_mcp_tool_call_with_image_output( diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index d586c202..e5dc5f2a 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -128,10 +128,11 @@ pub async fn run_main( include_apply_patch_tool: None, disable_response_storage: cli.oss.then_some(true), show_raw_agent_reasoning: cli.oss.then_some(true), + tools_web_search_request: cli.web_search.then_some(true), }; - - // Parse `-c` overrides from the CLI. - let cli_kv_overrides = match cli.config_overrides.parse_overrides() { + let raw_overrides = cli.config_overrides.raw_overrides.clone(); + let overrides_cli = codex_common::CliConfigOverrides { raw_overrides }; + let cli_kv_overrides = match overrides_cli.parse_overrides() { Ok(v) => v, #[allow(clippy::print_stderr)] Err(e) => {