Allow full web search tool config (#13675)

Previously, we could only configure whether web search was on/off.

This PR enables sending along a web search config, which includes all
the stuff responsesapi supports: filters, location, etc.
This commit is contained in:
Rohan Mehta
2026-03-06 19:50:50 -05:00
committed by GitHub
Unverified
parent 8b81284975
commit 61098c7f51
23 changed files with 860 additions and 79 deletions
@@ -12108,6 +12108,16 @@
}
]
},
"tools": {
"anyOf": [
{
"$ref": "#/definitions/v2/ToolsV2"
},
{
"type": "null"
}
]
},
"web_search": {
"anyOf": [
{
@@ -15848,9 +15858,13 @@
]
},
"web_search": {
"type": [
"boolean",
"null"
"anyOf": [
{
"$ref": "#/definitions/v2/WebSearchToolConfig"
},
{
"type": "null"
}
]
}
},
@@ -16449,6 +16463,44 @@
}
]
},
"WebSearchContextSize": {
"enum": [
"low",
"medium",
"high"
],
"type": "string"
},
"WebSearchLocation": {
"additionalProperties": false,
"properties": {
"city": {
"type": [
"string",
"null"
]
},
"country": {
"type": [
"string",
"null"
]
},
"region": {
"type": [
"string",
"null"
]
},
"timezone": {
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"WebSearchMode": {
"enum": [
"disabled",
@@ -16457,6 +16509,41 @@
],
"type": "string"
},
"WebSearchToolConfig": {
"additionalProperties": false,
"properties": {
"allowed_domains": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"context_size": {
"anyOf": [
{
"$ref": "#/definitions/v2/WebSearchContextSize"
},
{
"type": "null"
}
]
},
"location": {
"anyOf": [
{
"$ref": "#/definitions/v2/WebSearchLocation"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"WindowsSandboxSetupCompletedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -8765,6 +8765,16 @@
}
]
},
"tools": {
"anyOf": [
{
"$ref": "#/definitions/ToolsV2"
},
{
"type": "null"
}
]
},
"web_search": {
"anyOf": [
{
@@ -13880,9 +13890,13 @@
]
},
"web_search": {
"type": [
"boolean",
"null"
"anyOf": [
{
"$ref": "#/definitions/WebSearchToolConfig"
},
{
"type": "null"
}
]
}
},
@@ -14705,6 +14719,44 @@
}
]
},
"WebSearchContextSize": {
"enum": [
"low",
"medium",
"high"
],
"type": "string"
},
"WebSearchLocation": {
"additionalProperties": false,
"properties": {
"city": {
"type": [
"string",
"null"
]
},
"country": {
"type": [
"string",
"null"
]
},
"region": {
"type": [
"string",
"null"
]
},
"timezone": {
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"WebSearchMode": {
"enum": [
"disabled",
@@ -14713,6 +14765,41 @@
],
"type": "string"
},
"WebSearchToolConfig": {
"additionalProperties": false,
"properties": {
"allowed_domains": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"context_size": {
"anyOf": [
{
"$ref": "#/definitions/WebSearchContextSize"
},
{
"type": "null"
}
]
},
"location": {
"anyOf": [
{
"$ref": "#/definitions/WebSearchLocation"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"WindowsSandboxSetupCompletedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -628,6 +628,16 @@
}
]
},
"tools": {
"anyOf": [
{
"$ref": "#/definitions/ToolsV2"
},
{
"type": "null"
}
]
},
"web_search": {
"anyOf": [
{
@@ -721,9 +731,13 @@
]
},
"web_search": {
"type": [
"boolean",
"null"
"anyOf": [
{
"$ref": "#/definitions/WebSearchToolConfig"
},
{
"type": "null"
}
]
}
},
@@ -738,6 +752,44 @@
],
"type": "string"
},
"WebSearchContextSize": {
"enum": [
"low",
"medium",
"high"
],
"type": "string"
},
"WebSearchLocation": {
"additionalProperties": false,
"properties": {
"city": {
"type": [
"string",
"null"
]
},
"country": {
"type": [
"string",
"null"
]
},
"region": {
"type": [
"string",
"null"
]
},
"timezone": {
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"WebSearchMode": {
"enum": [
"disabled",
@@ -745,6 +797,41 @@
"live"
],
"type": "string"
},
"WebSearchToolConfig": {
"additionalProperties": false,
"properties": {
"allowed_domains": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"context_size": {
"anyOf": [
{
"$ref": "#/definitions/WebSearchContextSize"
},
{
"type": "null"
}
]
},
"location": {
"anyOf": [
{
"$ref": "#/definitions/WebSearchLocation"
},
{
"type": "null"
}
]
}
},
"type": "object"
}
},
"properties": {
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type WebSearchContextSize = "low" | "medium" | "high";
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type WebSearchLocation = { country: string | null, region: string | null, city: string | null, timezone: string | null, };
@@ -0,0 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { WebSearchContextSize } from "./WebSearchContextSize";
import type { WebSearchLocation } from "./WebSearchLocation";
export type WebSearchToolConfig = { context_size: WebSearchContextSize | null, allowed_domains: Array<string> | null, location: WebSearchLocation | null, };
@@ -211,7 +211,10 @@ export type { ViewImageToolCallEvent } from "./ViewImageToolCallEvent";
export type { WarningEvent } from "./WarningEvent";
export type { WebSearchAction } from "./WebSearchAction";
export type { WebSearchBeginEvent } from "./WebSearchBeginEvent";
export type { WebSearchContextSize } from "./WebSearchContextSize";
export type { WebSearchEndEvent } from "./WebSearchEndEvent";
export type { WebSearchItem } from "./WebSearchItem";
export type { WebSearchLocation } from "./WebSearchLocation";
export type { WebSearchMode } from "./WebSearchMode";
export type { WebSearchToolConfig } from "./WebSearchToolConfig";
export * as v2 from "./v2";
@@ -8,5 +8,6 @@ import type { Verbosity } from "../Verbosity";
import type { WebSearchMode } from "../WebSearchMode";
import type { JsonValue } from "../serde_json/JsonValue";
import type { AskForApproval } from "./AskForApproval";
import type { ToolsV2 } from "./ToolsV2";
export type ProfileV2 = { model: string | null, model_provider: string | null, approval_policy: AskForApproval | null, service_tier: ServiceTier | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, chatgpt_base_url: string | null, } & ({ [key in string]?: number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null });
export type ProfileV2 = { model: string | null, model_provider: string | null, approval_policy: AskForApproval | null, service_tier: ServiceTier | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, chatgpt_base_url: string | null, } & ({ [key in string]?: number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null });
@@ -1,5 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { WebSearchToolConfig } from "../WebSearchToolConfig";
export type ToolsV2 = { web_search: boolean | null, view_image: boolean | null, };
export type ToolsV2 = { web_search: WebSearchToolConfig | null, view_image: boolean | null, };
@@ -22,6 +22,7 @@ use codex_protocol::config_types::SandboxMode as CoreSandboxMode;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::Verbosity;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::config_types::WebSearchToolConfig;
use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent;
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::mcp::Resource as McpResource;
@@ -375,8 +376,7 @@ pub struct SandboxWorkspaceWrite {
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
pub struct ToolsV2 {
#[serde(alias = "web_search_request")]
pub web_search: Option<bool>,
pub web_search: Option<WebSearchToolConfig>,
pub view_image: Option<bool>,
}
@@ -401,6 +401,7 @@ pub struct ProfileV2 {
pub model_reasoning_summary: Option<ReasoningSummary>,
pub model_verbosity: Option<Verbosity>,
pub web_search: Option<WebSearchMode>,
pub tools: Option<ToolsV2>,
pub chatgpt_base_url: Option<String>,
#[serde(default, flatten)]
pub additional: HashMap<String, JsonValue>,
@@ -23,6 +23,9 @@ use codex_app_server_protocol::ToolsV2;
use codex_app_server_protocol::WriteStatus;
use codex_core::config::set_project_trust_level;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::WebSearchContextSize;
use codex_protocol::config_types::WebSearchLocation;
use codex_protocol::config_types::WebSearchToolConfig;
use codex_protocol::openai_models::ReasoningEffort;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
@@ -93,8 +96,11 @@ async fn config_read_includes_tools() -> Result<()> {
r#"
model = "gpt-user"
[tools.web_search]
context_size = "low"
allowed_domains = ["example.com"]
[tools]
web_search = true
view_image = false
"#,
)?;
@@ -125,12 +131,28 @@ view_image = false
assert_eq!(
tools,
ToolsV2 {
web_search: Some(true),
web_search: Some(WebSearchToolConfig {
context_size: Some(WebSearchContextSize::Low),
allowed_domains: Some(vec!["example.com".to_string()]),
location: None,
}),
view_image: Some(false),
}
);
assert_eq!(
origins.get("tools.web_search").expect("origin").name,
origins
.get("tools.web_search.context_size")
.expect("origin")
.name,
ConfigLayerSource::User {
file: user_file.clone(),
}
);
assert_eq!(
origins
.get("tools.web_search.allowed_domains.0")
.expect("origin")
.name,
ConfigLayerSource::User {
file: user_file.clone(),
}
@@ -148,6 +170,54 @@ view_image = false
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_includes_nested_web_search_tool_config() -> Result<()> {
let codex_home = TempDir::new()?;
write_config(
&codex_home,
r#"
web_search = "live"
[tools.web_search]
context_size = "high"
allowed_domains = ["example.com"]
location = { country = "US", city = "New York", timezone = "America/New_York" }
"#,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ConfigReadResponse { config, .. } = to_response(resp)?;
assert_eq!(
config.tools.expect("tools present").web_search,
Some(WebSearchToolConfig {
context_size: Some(WebSearchContextSize::High),
allowed_domains: Some(vec!["example.com".to_string()]),
location: Some(WebSearchLocation {
country: Some("US".to_string()),
region: None,
city: Some("New York".to_string()),
timezone: Some("America/New_York".to_string()),
}),
}),
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_includes_apps() -> Result<()> {
let codex_home = TempDir::new()?;
+53 -5
View File
@@ -530,10 +530,10 @@
"service_tier": {
"$ref": "#/definitions/ServiceTier"
},
"tools_view_image": {
"type": "boolean"
"tools": {
"$ref": "#/definitions/ToolsToml"
},
"tools_web_search": {
"tools_view_image": {
"type": "boolean"
},
"web_search": {
@@ -1464,8 +1464,12 @@
"type": "boolean"
},
"web_search": {
"default": null,
"type": "boolean"
"allOf": [
{
"$ref": "#/definitions/WebSearchToolConfig"
}
],
"default": null
}
},
"type": "object"
@@ -1573,6 +1577,32 @@
],
"type": "string"
},
"WebSearchContextSize": {
"enum": [
"low",
"medium",
"high"
],
"type": "string"
},
"WebSearchLocation": {
"additionalProperties": false,
"properties": {
"city": {
"type": "string"
},
"country": {
"type": "string"
},
"region": {
"type": "string"
},
"timezone": {
"type": "string"
}
},
"type": "object"
},
"WebSearchMode": {
"enum": [
"disabled",
@@ -1581,6 +1611,24 @@
],
"type": "string"
},
"WebSearchToolConfig": {
"additionalProperties": false,
"properties": {
"allowed_domains": {
"items": {
"type": "string"
},
"type": "array"
},
"context_size": {
"$ref": "#/definitions/WebSearchContextSize"
},
"location": {
"$ref": "#/definitions/WebSearchLocation"
}
},
"type": "object"
},
"WindowsSandboxModeToml": {
"enum": [
"elevated",
+77
View File
@@ -154,8 +154,13 @@ fn strip_total_output_header(output: &str) -> Option<(&str, u32)> {
pub(crate) mod tools {
use crate::tools::spec::JsonSchema;
use codex_protocol::config_types::WebSearchContextSize;
use codex_protocol::config_types::WebSearchFilters;
use codex_protocol::config_types::WebSearchUserLocation;
use codex_protocol::config_types::WebSearchUserLocationType;
use serde::Deserialize;
use serde::Serialize;
use serde::Serializer;
/// When serialized as JSON, this produces a valid "Tool" in the OpenAI
/// Responses API.
@@ -176,6 +181,18 @@ pub(crate) mod tools {
WebSearch {
#[serde(skip_serializing_if = "Option::is_none")]
external_web_access: Option<bool>,
#[serde(
skip_serializing_if = "Option::is_none",
serialize_with = "serialize_web_search_filters"
)]
filters: Option<WebSearchFilters>,
#[serde(
skip_serializing_if = "Option::is_none",
serialize_with = "serialize_web_search_user_location"
)]
user_location: Option<WebSearchUserLocation>,
#[serde(skip_serializing_if = "Option::is_none")]
search_context_size: Option<WebSearchContextSize>,
#[serde(skip_serializing_if = "Option::is_none")]
search_content_types: Option<Vec<String>>,
},
@@ -195,6 +212,66 @@ pub(crate) mod tools {
}
}
fn serialize_web_search_filters<S>(
filters: &Option<WebSearchFilters>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match filters {
Some(filters) => {
#[derive(Serialize)]
struct SerializableWebSearchFilters<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
allowed_domains: Option<&'a Vec<String>>,
}
SerializableWebSearchFilters {
allowed_domains: filters.allowed_domains.as_ref(),
}
.serialize(serializer)
}
None => serializer.serialize_none(),
}
}
fn serialize_web_search_user_location<S>(
user_location: &Option<WebSearchUserLocation>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match user_location {
Some(user_location) => {
#[derive(Serialize)]
struct SerializableWebSearchUserLocation<'a> {
#[serde(rename = "type")]
r#type: WebSearchUserLocationType,
#[serde(skip_serializing_if = "Option::is_none")]
country: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
region: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
city: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
timezone: Option<&'a String>,
}
SerializableWebSearchUserLocation {
r#type: user_location.r#type,
country: user_location.country.as_ref(),
region: user_location.region.as_ref(),
city: user_location.city.as_ref(),
timezone: user_location.timezone.as_ref(),
}
.serialize(serializer)
}
None => serializer.serialize_none(),
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FreeformTool {
pub(crate) name: String,
+3
View File
@@ -744,6 +744,7 @@ impl TurnContext {
web_search_mode: self.tools_config.web_search_mode,
session_source: self.session_source.clone(),
})
.with_web_search_config(self.tools_config.web_search_config.clone())
.with_allow_login_shell(self.tools_config.allow_login_shell)
.with_agent_roles(config.agent_roles.clone());
@@ -1119,6 +1120,7 @@ impl Session {
web_search_mode: Some(per_turn_config.web_search_mode.value()),
session_source: session_source.clone(),
})
.with_web_search_config(per_turn_config.web_search_config.clone())
.with_allow_login_shell(per_turn_config.permissions.allow_login_shell)
.with_agent_roles(per_turn_config.agent_roles.clone());
@@ -4911,6 +4913,7 @@ async fn spawn_review_thread(
web_search_mode: Some(review_web_search_mode),
session_source: parent_turn_context.session_source.clone(),
})
.with_web_search_config(None)
.with_allow_login_shell(config.permissions.allow_login_shell)
.with_agent_roles(config.agent_roles.clone());
+4 -28
View File
@@ -1250,34 +1250,6 @@ fn web_search_mode_for_turn_falls_back_when_live_is_disallowed() -> anyhow::Resu
Ok(())
}
#[test]
fn profile_legacy_toggles_override_base() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let mut profiles = HashMap::new();
profiles.insert(
"work".to_string(),
ConfigProfile {
tools_web_search: Some(false),
..Default::default()
},
);
let cfg = ConfigToml {
profiles,
profile: Some("work".to_string()),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.path().to_path_buf(),
)?;
assert!(!config.features.enabled(Feature::WebSearchRequest));
Ok(())
}
#[tokio::test]
async fn project_profile_overrides_user_profile() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
@@ -3100,6 +3072,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
forced_login_method: None,
include_apply_patch_tool: false,
web_search_mode: Constrained::allow_any(WebSearchMode::Cached),
web_search_config: None,
use_experimental_unified_exec_tool: !cfg!(windows),
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
@@ -3234,6 +3207,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
forced_login_method: None,
include_apply_patch_tool: false,
web_search_mode: Constrained::allow_any(WebSearchMode::Cached),
web_search_config: None,
use_experimental_unified_exec_tool: !cfg!(windows),
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
@@ -3366,6 +3340,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
forced_login_method: None,
include_apply_patch_tool: false,
web_search_mode: Constrained::allow_any(WebSearchMode::Cached),
web_search_config: None,
use_experimental_unified_exec_tool: !cfg!(windows),
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
@@ -3484,6 +3459,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
forced_login_method: None,
include_apply_patch_tool: false,
web_search_mode: Constrained::allow_any(WebSearchMode::Cached),
web_search_config: None,
use_experimental_unified_exec_tool: !cfg!(windows),
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
@@ -218,14 +218,6 @@ fn explicit_feature_settings_in_config(cfg: &ConfigToml) -> Vec<(String, Feature
enabled,
));
}
if let Some(enabled) = cfg.tools.as_ref().and_then(|tools| tools.web_search) {
explicit_settings.push((
"tools.web_search".to_string(),
Feature::WebSearchRequest,
enabled,
));
}
for (profile_name, profile) in &cfg.profiles {
if let Some(features) = profile.features.as_ref() {
for (key, enabled) in &features.entries {
@@ -259,13 +251,6 @@ fn explicit_feature_settings_in_config(cfg: &ConfigToml) -> Vec<(String, Feature
enabled,
));
}
if let Some(enabled) = profile.tools_web_search {
explicit_settings.push((
format!("profiles.{profile_name}.tools_web_search"),
Feature::WebSearchRequest,
enabled,
));
}
}
explicit_settings
+31 -3
View File
@@ -68,7 +68,9 @@ use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::Verbosity;
use codex_protocol::config_types::WebSearchConfig;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::config_types::WebSearchToolConfig;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::openai_models::ModelsResponse;
@@ -482,6 +484,9 @@ pub struct Config {
/// Explicit or feature-derived web search mode.
pub web_search_mode: Constrained<WebSearchMode>,
/// Additional parameters for the web search tool when it is enabled.
pub web_search_config: Option<WebSearchConfig>,
/// If set to `true`, used only the experimental unified exec tool.
pub use_experimental_unified_exec_tool: bool,
@@ -1379,8 +1384,8 @@ pub struct RealtimeAudioToml {
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ToolsToml {
#[serde(default, alias = "web_search_request")]
pub web_search: Option<bool>,
#[serde(default)]
pub web_search: Option<WebSearchToolConfig>,
/// Enable the `view_image` tool that lets the agent attach local images.
#[serde(default)]
@@ -1442,7 +1447,7 @@ pub struct AgentRoleToml {
impl From<ToolsToml> for Tools {
fn from(tools_toml: ToolsToml) -> Self {
Self {
web_search: tools_toml.web_search,
web_search: tools_toml.web_search.is_some().then_some(true),
view_image: tools_toml.view_image,
}
}
@@ -1734,6 +1739,27 @@ fn resolve_web_search_mode(
None
}
fn resolve_web_search_config(
config_toml: &ConfigToml,
config_profile: &ConfigProfile,
) -> Option<WebSearchConfig> {
let base = config_toml
.tools
.as_ref()
.and_then(|tools| tools.web_search.as_ref());
let profile = config_profile
.tools
.as_ref()
.and_then(|tools| tools.web_search.as_ref());
match (base, profile) {
(None, None) => None,
(Some(base), None) => Some(base.clone().into()),
(None, Some(profile)) => Some(profile.clone().into()),
(Some(base), Some(profile)) => Some(base.merge(profile).into()),
}
}
pub(crate) fn resolve_web_search_mode_for_turn(
web_search_mode: &Constrained<WebSearchMode>,
sandbox_policy: &SandboxPolicy,
@@ -2013,6 +2039,7 @@ impl Config {
}
let web_search_mode = resolve_web_search_mode(&cfg, &config_profile, &features)
.unwrap_or(WebSearchMode::Cached);
let web_search_config = resolve_web_search_config(&cfg, &config_profile);
let mut model_providers = built_in_model_providers();
// Merge user-defined providers into the built-in list.
@@ -2418,6 +2445,7 @@ impl Config {
forced_login_method,
include_apply_patch_tool: include_apply_patch_tool_flag,
web_search_mode: constrained_web_search_mode.value,
web_search_config,
use_experimental_unified_exec_tool,
background_terminal_max_timeout,
ghost_snapshot,
+2 -1
View File
@@ -3,6 +3,7 @@ use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use crate::config::ToolsToml;
use crate::config::types::Personality;
use crate::config::types::WindowsToml;
use crate::protocol::AskForApproval;
@@ -47,8 +48,8 @@ pub struct ConfigProfile {
pub include_apply_patch_tool: Option<bool>,
pub experimental_use_unified_exec_tool: Option<bool>,
pub experimental_use_freeform_apply_patch: Option<bool>,
pub tools_web_search: Option<bool>,
pub tools_view_image: Option<bool>,
pub tools: Option<ToolsToml>,
pub web_search: Option<WebSearchMode>,
pub analytics: Option<crate::config::types::AnalyticsConfigToml>,
#[serde(default)]
+8 -4
View File
@@ -213,10 +213,17 @@ impl FeatureOverrides {
fn apply(self, features: &mut Features) {
LegacyFeatureToggles {
include_apply_patch_tool: self.include_apply_patch_tool,
tools_web_search: self.web_search_request,
..Default::default()
}
.apply(features);
if let Some(enabled) = self.web_search_request {
if enabled {
features.enable(Feature::WebSearchRequest);
} else {
features.disable(Feature::WebSearchRequest);
}
features.record_legacy_usage("web_search_request", Feature::WebSearchRequest);
}
}
}
@@ -342,7 +349,6 @@ impl Features {
let base_legacy = LegacyFeatureToggles {
experimental_use_freeform_apply_patch: cfg.experimental_use_freeform_apply_patch,
experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool,
tools_web_search: cfg.tools.as_ref().and_then(|t| t.web_search),
..Default::default()
};
base_legacy.apply(&mut features);
@@ -357,7 +363,6 @@ impl Features {
.experimental_use_freeform_apply_patch,
experimental_use_unified_exec_tool: config_profile.experimental_use_unified_exec_tool,
tools_web_search: config_profile.tools_web_search,
};
profile_legacy.apply(&mut features);
if let Some(profile_features) = config_profile.features.as_ref() {
@@ -388,7 +393,6 @@ fn legacy_usage_notice(alias: &str, feature: Feature) -> (String, Option<String>
Feature::WebSearchRequest | Feature::WebSearchCached => {
let label = match alias {
"web_search" => "[features].web_search",
"tools.web_search" => "[tools].web_search",
"features.web_search_request" | "web_search_request" => {
"[features].web_search_request"
}
-7
View File
@@ -62,7 +62,6 @@ pub struct LegacyFeatureToggles {
pub include_apply_patch_tool: Option<bool>,
pub experimental_use_freeform_apply_patch: Option<bool>,
pub experimental_use_unified_exec_tool: Option<bool>,
pub tools_web_search: Option<bool>,
}
impl LegacyFeatureToggles {
@@ -85,12 +84,6 @@ impl LegacyFeatureToggles {
self.experimental_use_unified_exec_tool,
"experimental_use_unified_exec_tool",
);
set_if_some(
features,
Feature::WebSearchRequest,
self.tools_web_search,
"tools.web_search",
);
}
}
+74
View File
@@ -18,6 +18,7 @@ use crate::tools::handlers::multi_agents::MAX_WAIT_TIMEOUT_MS;
use crate::tools::handlers::multi_agents::MIN_WAIT_TIMEOUT_MS;
use crate::tools::handlers::request_user_input_tool_description;
use crate::tools::registry::ToolRegistryBuilder;
use codex_protocol::config_types::WebSearchConfig;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::models::VIEW_IMAGE_TOOL_NAME;
@@ -58,6 +59,7 @@ pub(crate) struct ToolsConfig {
pub allow_login_shell: bool,
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
pub web_search_mode: Option<WebSearchMode>,
pub web_search_config: Option<WebSearchConfig>,
pub web_search_tool_type: WebSearchToolType,
pub image_gen_tool: bool,
pub agent_roles: BTreeMap<String, AgentRoleConfig>,
@@ -158,6 +160,7 @@ impl ToolsConfig {
allow_login_shell: true,
apply_patch_tool_type,
web_search_mode: *web_search_mode,
web_search_config: None,
web_search_tool_type: model_info.web_search_tool_type,
image_gen_tool: include_image_gen_tool,
agent_roles: BTreeMap::new(),
@@ -184,6 +187,11 @@ impl ToolsConfig {
self.allow_login_shell = allow_login_shell;
self
}
pub fn with_web_search_config(mut self, web_search_config: Option<WebSearchConfig>) -> Self {
self.web_search_config = web_search_config;
self
}
}
fn supports_image_generation(model_info: &ModelInfo) -> bool {
@@ -1979,6 +1987,18 @@ pub(crate) fn build_specs(
builder.push_spec(ToolSpec::WebSearch {
external_web_access: Some(external_web_access),
filters: config
.web_search_config
.as_ref()
.and_then(|cfg| cfg.filters.clone()),
user_location: config
.web_search_config
.as_ref()
.and_then(|cfg| cfg.user_location.clone()),
search_context_size: config
.web_search_config
.as_ref()
.and_then(|cfg| cfg.search_context_size),
search_content_types,
});
}
@@ -2266,6 +2286,9 @@ mod tests {
create_apply_patch_freeform_tool(),
ToolSpec::WebSearch {
external_web_access: Some(true),
filters: None,
user_location: None,
search_context_size: None,
search_content_types: None,
},
create_view_image_tool(),
@@ -2592,6 +2615,9 @@ mod tests {
tool.spec,
ToolSpec::WebSearch {
external_web_access: Some(false),
filters: None,
user_location: None,
search_context_size: None,
search_content_types: None,
}
);
@@ -2617,6 +2643,51 @@ mod tests {
tool.spec,
ToolSpec::WebSearch {
external_web_access: Some(true),
filters: None,
user_location: None,
search_context_size: None,
search_content_types: None,
}
);
}
#[test]
fn web_search_config_is_forwarded_to_tool_spec() {
let config = test_config();
let model_info =
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
let features = Features::with_defaults();
let web_search_config = WebSearchConfig {
filters: Some(codex_protocol::config_types::WebSearchFilters {
allowed_domains: Some(vec!["example.com".to_string()]),
}),
user_location: Some(codex_protocol::config_types::WebSearchUserLocation {
r#type: codex_protocol::config_types::WebSearchUserLocationType::Approximate,
country: Some("US".to_string()),
region: Some("California".to_string()),
city: Some("San Francisco".to_string()),
timezone: Some("America/Los_Angeles".to_string()),
}),
search_context_size: Some(codex_protocol::config_types::WebSearchContextSize::High),
};
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Live),
session_source: SessionSource::Cli,
})
.with_web_search_config(Some(web_search_config.clone()));
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
let tool = find_tool(&tools, "web_search");
assert_eq!(
tool.spec,
ToolSpec::WebSearch {
external_web_access: Some(true),
filters: web_search_config.filters,
user_location: web_search_config.user_location,
search_context_size: web_search_config.search_context_size,
search_content_types: None,
}
);
@@ -2643,6 +2714,9 @@ mod tests {
tool.spec,
ToolSpec::WebSearch {
external_web_access: Some(true),
filters: None,
user_location: None,
search_context_size: None,
search_content_types: Some(
WEB_SEARCH_CONTENT_TYPES
.into_iter()
+60
View File
@@ -9,6 +9,8 @@ use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::sync::Arc;
#[allow(clippy::expect_used)]
fn find_web_search_tool(body: &Value) -> &Value {
@@ -223,3 +225,61 @@ async fn web_search_mode_updates_between_turns_with_sandbox_policy() {
"danger-full-access policy should default web_search to live"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn web_search_tool_config_from_config_toml_is_forwarded_to_request() {
skip_if_no_network!();
let server = start_mock_server().await;
let sse = responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_completed("resp-1"),
]);
let resp_mock = responses::mount_sse_once(&server, sse).await;
let home = Arc::new(tempfile::TempDir::new().expect("create codex home"));
std::fs::write(
home.path().join("config.toml"),
r#"web_search = "live"
[tools.web_search]
context_size = "high"
allowed_domains = ["example.com"]
location = { country = "US", city = "New York", timezone = "America/New_York" }
"#,
)
.expect("write config.toml");
let mut builder = test_codex().with_model("gpt-5-codex").with_home(home);
let test = builder
.build(&server)
.await
.expect("create test Codex conversation");
test.submit_turn_with_policy(
"hello configured web search",
SandboxPolicy::DangerFullAccess,
)
.await
.expect("submit turn");
let body = resp_mock.single_request().body_json();
let tool = find_web_search_tool(&body);
assert_eq!(
tool,
&json!({
"type": "web_search",
"external_web_access": true,
"search_context_size": "high",
"filters": {
"allowed_domains": ["example.com"],
},
"user_location": {
"type": "approximate",
"country": "US",
"city": "New York",
"timezone": "America/New_York",
},
})
);
}
+178
View File
@@ -113,6 +113,122 @@ pub enum WebSearchMode {
Live,
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum WebSearchContextSize {
Low,
Medium,
High,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
#[schemars(deny_unknown_fields)]
pub struct WebSearchLocation {
pub country: Option<String>,
pub region: Option<String>,
pub city: Option<String>,
pub timezone: Option<String>,
}
impl WebSearchLocation {
pub fn merge(&self, other: &Self) -> Self {
Self {
country: other.country.clone().or_else(|| self.country.clone()),
region: other.region.clone().or_else(|| self.region.clone()),
city: other.city.clone().or_else(|| self.city.clone()),
timezone: other.timezone.clone().or_else(|| self.timezone.clone()),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
#[schemars(deny_unknown_fields)]
pub struct WebSearchToolConfig {
pub context_size: Option<WebSearchContextSize>,
pub allowed_domains: Option<Vec<String>>,
pub location: Option<WebSearchLocation>,
}
impl WebSearchToolConfig {
pub fn merge(&self, other: &Self) -> Self {
Self {
context_size: other.context_size.or(self.context_size),
allowed_domains: other
.allowed_domains
.clone()
.or_else(|| self.allowed_domains.clone()),
location: match (&self.location, &other.location) {
(Some(location), Some(other_location)) => Some(location.merge(other_location)),
(Some(location), None) => Some(location.clone()),
(None, Some(other_location)) => Some(other_location.clone()),
(None, None) => None,
},
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
#[schemars(deny_unknown_fields)]
pub struct WebSearchFilters {
pub allowed_domains: Option<Vec<String>>,
}
#[derive(
Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Display, JsonSchema, TS,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum WebSearchUserLocationType {
#[default]
Approximate,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
#[schemars(deny_unknown_fields)]
pub struct WebSearchUserLocation {
#[serde(default)]
pub r#type: WebSearchUserLocationType,
pub country: Option<String>,
pub region: Option<String>,
pub city: Option<String>,
pub timezone: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
#[schemars(deny_unknown_fields)]
pub struct WebSearchConfig {
pub filters: Option<WebSearchFilters>,
pub user_location: Option<WebSearchUserLocation>,
pub search_context_size: Option<WebSearchContextSize>,
}
impl From<WebSearchLocation> for WebSearchUserLocation {
fn from(location: WebSearchLocation) -> Self {
Self {
r#type: WebSearchUserLocationType::Approximate,
country: location.country,
region: location.region,
city: location.city,
timezone: location.timezone,
}
}
}
impl From<WebSearchToolConfig> for WebSearchConfig {
fn from(config: WebSearchToolConfig) -> Self {
Self {
filters: config
.allowed_domains
.map(|allowed_domains| WebSearchFilters {
allowed_domains: Some(allowed_domains),
}),
user_location: config.location.map(Into::into),
search_context_size: config.context_size,
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
@@ -366,4 +482,66 @@ mod tests {
assert!(!ModeKind::PairProgramming.is_tui_visible());
assert!(!ModeKind::Execute.is_tui_visible());
}
#[test]
fn web_search_location_merge_prefers_overlay_values() {
let base = WebSearchLocation {
country: Some("US".to_string()),
region: Some("CA".to_string()),
city: None,
timezone: Some("America/Los_Angeles".to_string()),
};
let overlay = WebSearchLocation {
country: None,
region: Some("WA".to_string()),
city: Some("Seattle".to_string()),
timezone: None,
};
let expected = WebSearchLocation {
country: Some("US".to_string()),
region: Some("WA".to_string()),
city: Some("Seattle".to_string()),
timezone: Some("America/Los_Angeles".to_string()),
};
assert_eq!(expected, base.merge(&overlay));
}
#[test]
fn web_search_tool_config_merge_prefers_overlay_values() {
let base = WebSearchToolConfig {
context_size: Some(WebSearchContextSize::Low),
allowed_domains: Some(vec!["openai.com".to_string()]),
location: Some(WebSearchLocation {
country: Some("US".to_string()),
region: Some("CA".to_string()),
city: None,
timezone: Some("America/Los_Angeles".to_string()),
}),
};
let overlay = WebSearchToolConfig {
context_size: Some(WebSearchContextSize::High),
allowed_domains: None,
location: Some(WebSearchLocation {
country: None,
region: Some("WA".to_string()),
city: Some("Seattle".to_string()),
timezone: None,
}),
};
let expected = WebSearchToolConfig {
context_size: Some(WebSearchContextSize::High),
allowed_domains: Some(vec!["openai.com".to_string()]),
location: Some(WebSearchLocation {
country: Some("US".to_string()),
region: Some("WA".to_string()),
city: Some("Seattle".to_string()),
timezone: Some("America/Los_Angeles".to_string()),
}),
};
assert_eq!(expected, base.merge(&overlay));
}
}