Add indexed web search mode (#28489)

## Summary

- Add `web_search = "indexed"` alongside `disabled`, `cached`, and
`live`.
- Use that same resolved mode for both hosted and standalone web search.
- For hosted search, send `index_gated_web_access: true` with external
web access enabled only when `indexed` is selected.
- For standalone search, preserve the existing boolean wire values for
existing modes (`cached` maps to `false` and `live` to `true`) and send
`"indexed"` only for `indexed`; `disabled` keeps the tool unavailable.
- Carry the mode through managed configuration requirements and
generated schemas.

## Why

Indexed search provides a middle ground between cached-only search and
unrestricted live page fetching. Search queries can remain live while
direct page fetches are limited to URLs admitted by the server.

The existing `web_search` setting remains the single source of truth, so
hosted and standalone executors cannot drift into different access
modes. Without an explicit `indexed` selection, the existing
model-visible tool and request shapes are unchanged.

```toml
web_search = "indexed"

[features]
standalone_web_search = true
```

## Validation

- `just fmt`
- `just test -p codex-api` (`126 passed`)
- `just test -p codex-web-search-extension` (`7 passed`)
- `just test -p codex-core
code_mode_can_call_indexed_standalone_web_search` (`1 passed`)
- Focused configuration, hosted request, standalone request, and
managed-requirement coverage is included in the PR; remaining suites run
in CI.

The full workspace test suite was not run locally.
This commit is contained in:
Winston Howes
2026-06-19 05:35:57 -07:00
committed by GitHub
Unverified
parent 7951397e4a
commit 3a2712ea14
24 changed files with 204 additions and 23 deletions
@@ -20355,6 +20355,7 @@
"enum": [
"disabled",
"cached",
"indexed",
"live"
],
"type": "string"
@@ -18134,6 +18134,7 @@
"enum": [
"disabled",
"cached",
"indexed",
"live"
],
"type": "string"
@@ -809,6 +809,7 @@
"enum": [
"disabled",
"cached",
"indexed",
"live"
],
"type": "string"
@@ -503,6 +503,7 @@
"enum": [
"disabled",
"cached",
"indexed",
"live"
],
"type": "string"
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type WebSearchMode = "disabled" | "cached" | "live";
export type WebSearchMode = "disabled" | "cached" | "indexed" | "live";
+2 -1
View File
@@ -55,6 +55,7 @@ mod tests {
use crate::provider::RetryConfig;
use crate::search::AllowedCaller;
use crate::search::ApproximateLocation;
use crate::search::ExternalWebAccess;
use crate::search::LocationType;
use crate::search::OpenOperation;
use crate::search::SearchCommands;
@@ -193,7 +194,7 @@ mod tests {
caption: Some(true),
}),
allowed_callers: Some(vec![AllowedCaller::Direct]),
external_web_access: Some(true),
external_web_access: Some(ExternalWebAccess::Boolean(true)),
}),
max_output_tokens: Some(2500),
},
+2
View File
@@ -81,6 +81,8 @@ pub use crate::requests::Compression;
pub use crate::search::AllowedCaller;
pub use crate::search::ApproximateLocation;
pub use crate::search::ClickOperation;
pub use crate::search::ExternalWebAccess;
pub use crate::search::ExternalWebAccessMode;
pub use crate::search::FinanceAssetType;
pub use crate::search::FinanceOperation;
pub use crate::search::FindOperation;
+16 -1
View File
@@ -211,6 +211,21 @@ pub enum SearchResponseLength {
Long,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ExternalWebAccessMode {
Offline,
Indexed,
Online,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(untagged)]
pub enum ExternalWebAccess {
Boolean(bool),
Mode(ExternalWebAccessMode),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct SearchSettings {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -224,7 +239,7 @@ pub struct SearchSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub allowed_callers: Option<Vec<AllowedCaller>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub external_web_access: Option<bool>,
pub external_web_access: Option<ExternalWebAccess>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+35 -1
View File
@@ -665,10 +665,11 @@ fn is_glob_metacharacter(ch: char) -> bool {
}
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[serde(rename_all = "lowercase")]
#[serde(rename_all = "snake_case")]
pub enum WebSearchModeRequirement {
Disabled,
Cached,
Indexed,
Live,
}
@@ -677,6 +678,7 @@ impl From<WebSearchMode> for WebSearchModeRequirement {
match mode {
WebSearchMode::Disabled => WebSearchModeRequirement::Disabled,
WebSearchMode::Cached => WebSearchModeRequirement::Cached,
WebSearchMode::Indexed => WebSearchModeRequirement::Indexed,
WebSearchMode::Live => WebSearchModeRequirement::Live,
}
}
@@ -687,6 +689,7 @@ impl From<WebSearchModeRequirement> for WebSearchMode {
match mode {
WebSearchModeRequirement::Disabled => WebSearchMode::Disabled,
WebSearchModeRequirement::Cached => WebSearchMode::Cached,
WebSearchModeRequirement::Indexed => WebSearchMode::Indexed,
WebSearchModeRequirement::Live => WebSearchMode::Live,
}
}
@@ -697,6 +700,7 @@ impl fmt::Display for WebSearchModeRequirement {
match self {
WebSearchModeRequirement::Disabled => write!(f, "disabled"),
WebSearchModeRequirement::Cached => write!(f, "cached"),
WebSearchModeRequirement::Indexed => write!(f, "indexed"),
WebSearchModeRequirement::Live => write!(f, "live"),
}
}
@@ -1345,6 +1349,8 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
let initial_value = if accepted.contains(&WebSearchModeRequirement::Cached) {
WebSearchMode::Cached
} else if accepted.contains(&WebSearchModeRequirement::Indexed) {
WebSearchMode::Indexed
} else if accepted.contains(&WebSearchModeRequirement::Live) {
WebSearchMode::Live
} else {
@@ -2927,6 +2933,34 @@ allowed_approvals_reviewers = ["user"]
Ok(())
}
#[test]
fn allowed_web_search_modes_supports_indexed() -> Result<()> {
let config: ConfigRequirementsToml = from_str(
r#"
allowed_web_search_modes = ["indexed"]
"#,
)?;
let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
assert_eq!(requirements.web_search_mode.value(), WebSearchMode::Indexed);
for mode in [WebSearchMode::Disabled, WebSearchMode::Indexed] {
assert!(requirements.web_search_mode.can_set(&mode).is_ok());
}
for mode in [WebSearchMode::Cached, WebSearchMode::Live] {
assert_eq!(
requirements.web_search_mode.can_set(&mode),
Err(ConstraintError::InvalidValue {
field_name: "web_search_mode",
candidate: format!("{mode:?}"),
allowed: "[Disabled, Indexed]".into(),
requirement_source: RequirementSource::Unknown,
})
);
}
Ok(())
}
#[test]
fn allowed_web_search_modes_allows_disabled() -> Result<()> {
let toml_str = r#"
+1 -1
View File
@@ -409,7 +409,7 @@ pub struct ConfigToml {
pub experimental_thread_store: Option<ThreadStoreToml>,
pub projects: Option<HashMap<String, ProjectConfig>>,
/// Controls the web search tool mode: disabled, cached, or live.
/// Controls the web search tool mode: disabled, cached, indexed, or live.
pub web_search: Option<WebSearchMode>,
/// Nested tools section for feature toggles
+2 -1
View File
@@ -4419,6 +4419,7 @@
"enum": [
"disabled",
"cached",
"indexed",
"live"
],
"type": "string"
@@ -5380,7 +5381,7 @@
"$ref": "#/definitions/WebSearchMode"
}
],
"description": "Controls the web search tool mode: disabled, cached, or live."
"description": "Controls the web search tool mode: disabled, cached, indexed, or live."
},
"windows": {
"allOf": [
+33
View File
@@ -5186,6 +5186,14 @@ fn web_search_mode_disabled_overrides_legacy_request() {
);
}
#[test]
fn web_search_mode_for_turn_preserves_indexed_for_disabled_permissions() {
let web_search_mode = Constrained::allow_any(WebSearchMode::Indexed);
let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled);
assert_eq!(mode, WebSearchMode::Indexed);
}
#[test]
fn web_search_mode_for_turn_uses_preference_for_read_only() {
let web_search_mode = Constrained::allow_any(WebSearchMode::Cached);
@@ -5232,6 +5240,31 @@ fn web_search_mode_for_turn_falls_back_when_live_is_disallowed() -> anyhow::Resu
Ok(())
}
#[test]
fn web_search_mode_for_turn_does_not_implicitly_select_indexed() -> anyhow::Result<()> {
let allowed = [
WebSearchMode::Disabled,
WebSearchMode::Cached,
WebSearchMode::Indexed,
];
let web_search_mode = Constrained::new(WebSearchMode::Cached, move |candidate| {
if allowed.contains(candidate) {
Ok(())
} else {
Err(ConstraintError::InvalidValue {
field_name: "web_search_mode",
candidate: format!("{candidate:?}"),
allowed: format!("{allowed:?}"),
requirement_source: RequirementSource::Unknown,
})
}
})?;
let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled);
assert_eq!(mode, WebSearchMode::Cached);
Ok(())
}
#[tokio::test]
async fn project_profiles_are_ignored() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
+1 -1
View File
@@ -2667,7 +2667,7 @@ pub(crate) fn resolve_web_search_mode_for_turn(
let preferred = web_search_mode.value();
if matches!(permission_profile, PermissionProfile::Disabled)
&& preferred != WebSearchMode::Disabled
&& !matches!(preferred, WebSearchMode::Disabled | WebSearchMode::Indexed)
{
for mode in [
WebSearchMode::Live,
+7 -5
View File
@@ -18,11 +18,12 @@ pub fn create_image_generation_tool(output_format: &str) -> ToolSpec {
}
pub fn create_web_search_tool(options: WebSearchToolOptions<'_>) -> Option<ToolSpec> {
let external_web_access = match options.web_search_mode {
Some(WebSearchMode::Cached) => Some(false),
Some(WebSearchMode::Live) => Some(true),
Some(WebSearchMode::Disabled) | None => None,
}?;
let (external_web_access, index_gated_web_access) = match options.web_search_mode {
Some(WebSearchMode::Cached) => (false, None),
Some(WebSearchMode::Indexed) => (true, Some(true)),
Some(WebSearchMode::Live) => (true, None),
Some(WebSearchMode::Disabled) | None => return None,
};
let search_content_types = match options.web_search_tool_type {
WebSearchToolType::Text => None,
@@ -36,6 +37,7 @@ pub fn create_web_search_tool(options: WebSearchToolOptions<'_>) -> Option<ToolS
Some(ToolSpec::WebSearch {
external_web_access: Some(external_web_access),
index_gated_web_access,
filters: options
.web_search_config
.and_then(|config| config.filters.clone().map(Into::into)),
@@ -39,6 +39,7 @@ fn web_search_tool_preserves_configured_options() {
}),
Some(ToolSpec::WebSearch {
external_web_access: Some(true),
index_gated_web_access: None,
filters: Some(ResponsesApiWebSearchFilters {
allowed_domains: Some(vec!["example.com".to_string()]),
}),
@@ -1441,6 +1441,7 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() {
live_web_search.visible_spec("web_search"),
&ToolSpec::WebSearch {
external_web_access: Some(true),
index_gated_web_access: None,
filters: None,
user_location: None,
search_context_size: None,
+16 -3
View File
@@ -221,6 +221,19 @@ async fn run_code_mode_turn_with_model_and_config(
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_can_call_standalone_web_search() -> Result<()> {
assert_code_mode_standalone_web_search(WebSearchMode::Live, serde_json::json!(true)).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_can_call_indexed_standalone_web_search() -> Result<()> {
assert_code_mode_standalone_web_search(WebSearchMode::Indexed, serde_json::json!("indexed"))
.await
}
async fn assert_code_mode_standalone_web_search(
web_search_mode: WebSearchMode,
expected_external_web_access: Value,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
@@ -268,7 +281,7 @@ text(result);
.with_auth(auth)
.with_extensions(Arc::new(extension_builder.build()))
.with_model("test-gpt-5.1-codex")
.with_config(|config| {
.with_config(move |config| {
config
.features
.enable(Feature::CodeMode)
@@ -279,7 +292,7 @@ text(result);
.expect("standalone web search should be enabled");
config
.web_search_mode
.set(WebSearchMode::Live)
.set(web_search_mode)
.expect("web search mode should be accepted");
});
let test = builder.build(&server).await?;
@@ -310,7 +323,7 @@ text(result);
search_body["settings"],
serde_json::json!({
"allowed_callers": ["direct"],
"external_web_access": true,
"external_web_access": expected_external_web_access,
})
);
assert_eq!(
@@ -92,7 +92,7 @@ async fn emits_deprecation_notice_for_web_search_feature_flag_values() -> anyhow
assert_eq!(
details.as_deref(),
Some(
"Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml if you want to override it."
"Set `web_search` to `\"live\"`, `\"indexed\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml if you want to override it."
),
);
}
+39
View File
@@ -274,3 +274,42 @@ location = { country = "US", city = "New York", timezone = "America/New_York" }
})
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn indexed_web_search_mode_sets_index_gate() {
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 = "indexed""#)
.expect("write config.toml");
let mut builder = test_codex().with_model("gpt-5.3-codex").with_home(home);
let test = builder
.build(&server)
.await
.expect("create test Codex conversation");
test.submit_turn_with_permission_profile(
"hello indexed web search",
PermissionProfile::Disabled,
)
.await
.expect("submit turn");
let body = resp_mock.single_request().body_json();
let tool = find_web_search_tool(&body);
assert_eq!(
(
tool.get("external_web_access").and_then(Value::as_bool),
tool.get("index_gated_web_access").and_then(Value::as_bool),
),
(Some(true), Some(true))
);
}
+34 -4
View File
@@ -2,6 +2,8 @@ use std::sync::Arc;
use codex_api::AllowedCaller;
use codex_api::ApproximateLocation;
use codex_api::ExternalWebAccess;
use codex_api::ExternalWebAccessMode;
use codex_api::LocationType;
use codex_api::SearchContextSize;
use codex_api::SearchFilters;
@@ -73,14 +75,19 @@ fn search_settings(config: &Config, web_search_mode: WebSearchMode) -> SearchSet
blocked_domains: None,
}),
allowed_callers: Some(vec![AllowedCaller::Direct]),
external_web_access: Some(match web_search_mode {
WebSearchMode::Live => true,
WebSearchMode::Cached | WebSearchMode::Disabled => false,
}),
external_web_access: Some(external_web_access_for_mode(web_search_mode)),
..Default::default()
}
}
fn external_web_access_for_mode(web_search_mode: WebSearchMode) -> ExternalWebAccess {
match web_search_mode {
WebSearchMode::Disabled | WebSearchMode::Cached => ExternalWebAccess::Boolean(false),
WebSearchMode::Indexed => ExternalWebAccess::Mode(ExternalWebAccessMode::Indexed),
WebSearchMode::Live => ExternalWebAccess::Boolean(true),
}
}
impl ThreadLifecycleContributor<Config> for WebSearchExtension {
fn on_thread_start<'a>(
&'a self,
@@ -149,9 +156,32 @@ mod tests {
use super::AuthManager;
use super::Config;
use super::WebSearchExtensionConfig;
use super::external_web_access_for_mode;
use super::install;
use crate::tool::RUN_TOOL_NAME;
use crate::tool::WEB_NAMESPACE;
use codex_api::ExternalWebAccess;
use codex_api::ExternalWebAccessMode;
use codex_protocol::config_types::WebSearchMode;
#[test]
fn external_web_access_preserves_legacy_values_until_indexed() {
assert_eq!(
[
WebSearchMode::Disabled,
WebSearchMode::Cached,
WebSearchMode::Indexed,
WebSearchMode::Live,
]
.map(external_web_access_for_mode),
[
ExternalWebAccess::Boolean(false),
ExternalWebAccess::Boolean(false),
ExternalWebAccess::Mode(ExternalWebAccessMode::Indexed),
ExternalWebAccess::Boolean(true),
]
);
}
#[test]
fn installed_extension_contributes_web_run_when_enabled() {
+1 -1
View File
@@ -599,7 +599,7 @@ fn legacy_usage_notice(alias: &str, feature: Feature) -> (String, Option<String>
}
fn web_search_details() -> &'static str {
"Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml if you want to override it."
"Set `web_search` to `\"live\"`, `\"indexed\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml if you want to override it."
}
/// Keys accepted in `[features]` tables.
+3 -2
View File
@@ -313,12 +313,13 @@ pub enum MultiAgentMode {
#[derive(
Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, Default,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum WebSearchMode {
Disabled,
#[default]
Cached,
Indexed,
Live,
}
+2
View File
@@ -38,6 +38,8 @@ pub enum ToolSpec {
#[serde(skip_serializing_if = "Option::is_none")]
external_web_access: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
index_gated_web_access: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
filters: Option<ResponsesApiWebSearchFilters>,
#[serde(skip_serializing_if = "Option::is_none")]
user_location: Option<ResponsesApiWebSearchUserLocation>,
+2
View File
@@ -67,6 +67,7 @@ fn tool_spec_name_covers_all_variants() {
assert_eq!(
ToolSpec::WebSearch {
external_web_access: Some(true),
index_gated_web_access: None,
filters: None,
user_location: None,
search_context_size: None,
@@ -199,6 +200,7 @@ fn web_search_tool_spec_serializes_expected_wire_shape() {
assert_eq!(
serde_json::to_value(ToolSpec::WebSearch {
external_web_access: Some(true),
index_gated_web_access: None,
filters: Some(ResponsesApiWebSearchFilters {
allowed_domains: Some(vec!["example.com".to_string()]),
}),