diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 0bebb007cb8..642057cbf7f 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -11989,6 +11989,16 @@ } ] }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ToolsV2" + }, + { + "type": "null" + } + ] + }, "web_search": { "anyOf": [ { @@ -15729,9 +15739,13 @@ ] }, "web_search": { - "type": [ - "boolean", - "null" + "anyOf": [ + { + "$ref": "#/definitions/v2/WebSearchToolConfig" + }, + { + "type": "null" + } ] } }, @@ -16330,6 +16344,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", @@ -16338,6 +16390,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": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index da67d650c40..8b8b1be8150 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -8646,6 +8646,16 @@ } ] }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/ToolsV2" + }, + { + "type": "null" + } + ] + }, "web_search": { "anyOf": [ { @@ -13761,9 +13771,13 @@ ] }, "web_search": { - "type": [ - "boolean", - "null" + "anyOf": [ + { + "$ref": "#/definitions/WebSearchToolConfig" + }, + { + "type": "null" + } ] } }, @@ -14586,6 +14600,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", @@ -14594,6 +14646,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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index dd0a86fe910..90828da0bae 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/typescript/WebSearchContextSize.ts b/codex-rs/app-server-protocol/schema/typescript/WebSearchContextSize.ts new file mode 100644 index 00000000000..d6feedde849 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/WebSearchContextSize.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/WebSearchLocation.ts b/codex-rs/app-server-protocol/schema/typescript/WebSearchLocation.ts new file mode 100644 index 00000000000..12319983d7d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/WebSearchLocation.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/WebSearchToolConfig.ts b/codex-rs/app-server-protocol/schema/typescript/WebSearchToolConfig.ts new file mode 100644 index 00000000000..c14067cef44 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/WebSearchToolConfig.ts @@ -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 | null, location: WebSearchLocation | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 67b98c39467..af0db3367e8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts index 81d20993cbf..f2c72b3ae65 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts @@ -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 | { [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 | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ToolsV2.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ToolsV2.ts index 0b1bee51460..784991f017d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ToolsV2.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ToolsV2.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index c65c41d1a72..a34ecd5b21c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -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, + pub web_search: Option, pub view_image: Option, } @@ -401,6 +401,7 @@ pub struct ProfileV2 { pub model_reasoning_summary: Option, pub model_verbosity: Option, pub web_search: Option, + pub tools: Option, pub chatgpt_base_url: Option, #[serde(default, flatten)] pub additional: HashMap, diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index cd74710876b..1b73589a6bb 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -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()?; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 1f94469530e..62201e2be2c 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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": { @@ -1439,8 +1439,12 @@ "type": "boolean" }, "web_search": { - "default": null, - "type": "boolean" + "allOf": [ + { + "$ref": "#/definitions/WebSearchToolConfig" + } + ], + "default": null } }, "type": "object" @@ -1548,6 +1552,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", @@ -1556,6 +1586,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", diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index b2bd4d0b3aa..1fb3b7b47c8 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -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, + #[serde( + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_web_search_filters" + )] + filters: Option, + #[serde( + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_web_search_user_location" + )] + user_location: Option, + #[serde(skip_serializing_if = "Option::is_none")] + search_context_size: Option, #[serde(skip_serializing_if = "Option::is_none")] search_content_types: Option>, }, @@ -195,6 +212,66 @@ pub(crate) mod tools { } } + fn serialize_web_search_filters( + filters: &Option, + serializer: S, + ) -> Result + where + S: Serializer, + { + match filters { + Some(filters) => { + #[derive(Serialize)] + struct SerializableWebSearchFilters<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + allowed_domains: Option<&'a Vec>, + } + + SerializableWebSearchFilters { + allowed_domains: filters.allowed_domains.as_ref(), + } + .serialize(serializer) + } + None => serializer.serialize_none(), + } + } + + fn serialize_web_search_user_location( + user_location: &Option, + serializer: S, + ) -> Result + 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, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index bd32dfe0296..44d365233d6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -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()); diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 8fa95d6a29c..1e135c50dde 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -867,34 +867,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()?; @@ -2712,6 +2684,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(), @@ -2841,6 +2814,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(), @@ -2968,6 +2942,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(), @@ -3081,6 +3056,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(), diff --git a/codex-rs/core/src/config/managed_features.rs b/codex-rs/core/src/config/managed_features.rs index 13510b0d853..4e45dedf91a 100644 --- a/codex-rs/core/src/config/managed_features.rs +++ b/codex-rs/core/src/config/managed_features.rs @@ -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 diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index e489720bab9..e808632fa3e 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -67,7 +67,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; @@ -465,6 +467,9 @@ pub struct Config { /// Explicit or feature-derived web search mode. pub web_search_mode: Constrained, + /// Additional parameters for the web search tool when it is enabled. + pub web_search_config: Option, + /// If set to `true`, used only the experimental unified exec tool. pub use_experimental_unified_exec_tool: bool, @@ -1354,8 +1359,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, + #[serde(default)] + pub web_search: Option, /// Enable the `view_image` tool that lets the agent attach local images. #[serde(default)] @@ -1417,7 +1422,7 @@ pub struct AgentRoleToml { impl From 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, } } @@ -1637,6 +1642,27 @@ fn resolve_web_search_mode( None } +fn resolve_web_search_config( + config_toml: &ConfigToml, + config_profile: &ConfigProfile, +) -> Option { + 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, sandbox_policy: &SandboxPolicy, @@ -1836,6 +1862,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. @@ -2224,6 +2251,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, diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index 6d4cd230901..ce454ff0a85 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -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, pub experimental_use_unified_exec_tool: Option, pub experimental_use_freeform_apply_patch: Option, - pub tools_web_search: Option, pub tools_view_image: Option, + pub tools: Option, pub web_search: Option, pub analytics: Option, #[serde(default)] diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 7defc571ffb..29e6de281e5 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -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 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" } diff --git a/codex-rs/core/src/features/legacy.rs b/codex-rs/core/src/features/legacy.rs index 45b3dfd5dca..b7aa30482a1 100644 --- a/codex-rs/core/src/features/legacy.rs +++ b/codex-rs/core/src/features/legacy.rs @@ -62,7 +62,6 @@ pub struct LegacyFeatureToggles { pub include_apply_patch_tool: Option, pub experimental_use_freeform_apply_patch: Option, pub experimental_use_unified_exec_tool: Option, - pub tools_web_search: Option, } 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", - ); } } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index f0c952e0787..9c6cb95a648 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -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, pub web_search_mode: Option, + pub web_search_config: Option, pub web_search_tool_type: WebSearchToolType, pub image_gen_tool: bool, pub agent_roles: BTreeMap, @@ -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) -> 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() diff --git a/codex-rs/core/tests/suite/web_search.rs b/codex-rs/core/tests/suite/web_search.rs index 6df0338e920..c90ca91235a 100644 --- a/codex-rs/core/tests/suite/web_search.rs +++ b/codex-rs/core/tests/suite/web_search.rs @@ -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", + }, + }) + ); +} diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index b467d34857d..4261bb8d1d8 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -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, + pub region: Option, + pub city: Option, + pub timezone: Option, +} + +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, + pub allowed_domains: Option>, + pub location: Option, +} + +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>, +} + +#[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, + pub region: Option, + pub city: Option, + pub timezone: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, JsonSchema, TS)] +#[schemars(deny_unknown_fields)] +pub struct WebSearchConfig { + pub filters: Option, + pub user_location: Option, + pub search_context_size: Option, +} + +impl From 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 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)); + } }