Add web search tool (#2371)

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
<img width="1033" height="294" alt="image"
src="https://github.com/user-attachments/assets/62ac6563-b946-465c-ba5d-9325af28b28f"
/>

---------

Co-authored-by: easong-openai <easong@openai.com>
This commit is contained in:
Reuben Narad
2025-08-23 22:58:56 -07:00
committed by GitHub
parent 957d44918d
commit 363636f5eb
16 changed files with 158 additions and 26 deletions

View File

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

View File

@@ -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"),
_ => {}
}
}
}

View File

@@ -93,6 +93,10 @@ pub enum ResponseEvent {
ReasoningSummaryDelta(String),
ReasoningContentDelta(String),
ReasoningSummaryPartAdded,
WebSearchCallBegin {
call_id: String,
query: Option<String>,
},
}
#[derive(Debug, Serialize)]

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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,

View File

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

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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)]

View File

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

View File

@@ -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,
}

View File

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

View File

@@ -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) => {