Files
codex/prs/bolinfest/PR-2371.md
2025-09-02 15:17:45 -07:00

32 KiB
Raw Blame History

PR #2371: Add web search tool

Description

Adds web_search tool, enabling the model to use Responses API web_search tool.

  • Disabled by default, enabled by --search flag
  • When --search is passed, exposes web_search_request function tool to the model, which triggers user approval. When approved, the model can use the web_search tool for the remainder of the turn image

Currently only works for API key login

Full Diff

diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs
index 55b200911a..641574074e 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 6206b4264a..ecf1ae7125 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<S>(
             }
         };
 
-        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<S>(
             | "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<S>(
                 }
             }
             "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 c320d8d0d7..e2c191f4a5 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<String>,
+    },
 }
 
 #[derive(Debug, Serialize)]
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
index a7f8e7c30d..e175f55094 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 fbf0387a01..98a8fde135 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<AuthMode>,
+
+    /// Nested tools section for feature toggles
+    pub tools: Option<ToolsToml>,
 }
 
 #[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
@@ -487,6 +492,13 @@ pub struct ProjectConfig {
     pub trust_level: Option<String>,
 }
 
+#[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<bool>,
+}
+
 impl ConfigToml {
     /// Derive the effective sandbox policy from the configuration.
     fn derive_sandbox_policy(&self, sandbox_mode_override: Option<SandboxMode>) -> SandboxPolicy {
@@ -576,6 +588,7 @@ pub struct ConfigOverrides {
     pub include_apply_patch_tool: Option<bool>,
     pub disable_response_storage: Option<bool>,
     pub show_raw_agent_reasoning: Option<bool>,
+    pub tools_web_search_request: Option<bool>,
 }
 
 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 272c901dc2..516a984453 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<ApplyPatchToolType>,
+    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::<Vec<_>>();
@@ -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 0f7e14ea40..cfdba98461 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 d403cb7960..3de95291d1 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<PathBuf>) -> 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 0bbf6ff849..97a3602d70 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 5993c10faf..69f07ff223 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 c6d65bc89d..8480e29c53 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 7e7708b23c..71c1538197 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 235280be9f..5f48b0e4ed 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 91ee9cfdc7..8eb6d6b896 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<PathBuf>,
 
+    /// Enable web search (off by default). When enabled, the native Responses `web_search` tool is available to the model (no percall 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 3e51ffd036..0b2af7a100 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<Line<'static>> =
+        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 d586c202ac..e5dc5f2abe 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) => {

Review Comments

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()),
+                    );
+                }
+            }
+        }

Why is this logic added here instead of inside create_tools_json_for_responses_api()?

@@ -580,8 +595,24 @@ async fn process_sse<S>(
             | "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"

Why is this here instead of being its own case on line 596? This whole thing starts with match event.kind.as_str()?

codex-rs/core/src/config.rs

@@ -480,13 +482,23 @@ pub struct ConfigToml {
 
     /// If set to `true`, the API key will be signed with the `originator` header.
     pub preferred_auth_method: Option<AuthMode>,
+
+    /// Nested tools section for feature toggles
+    pub tools: Option<ToolsToml>,
 }
 
 #[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
 pub struct ProjectConfig {
     pub trust_level: Option<String>,
 }
 
+#[derive(Deserialize, Debug, Clone, Default)]
+pub struct ToolsToml {
+    // Renamed from `web_search_request`; keep alias for backwards compatibility.

Backwards compatibility? Isn't this new code?

@@ -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();

Why are all these clone() calls being added?

codex-rs/core/src/openai_tools.rs

@@ -576,11 +586,12 @@ mod tests {
             SandboxPolicy::ReadOnly,
             true,
             false,
+            true,

ToolsConfig::new() really needs to take a struct: this is no longer comprehensible...

@@ -631,8 +644,8 @@ mod tests {
                                     "number_property": { "type": "number" },
                                 },
                                 "required": [
-                                    "string_property",
-                                    "number_property"
+                                    "string_property".to_string(),

Why did this change?

codex-rs/protocol/src/protocol.rs

@@ -437,6 +437,8 @@ pub enum EventMsg {
 
     McpToolCallEnd(McpToolCallEndEvent),
 
+    WebSearchBegin(WebSearchBeginEvent),

There's a begin but no end?

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();

Why did this change?