standalone websearch extension (#23823)

## Summary

Add the extension-backed standalone `web.run` tool so Codex can call the
standalone search endpoint through the `codex-api` search client and
return its encrypted output to Responses.

- gate the new tool behind `standalone_web_search`
- install the extension in the app-server thread registry and hide
hosted `web_search` when standalone search is enabled for OpenAI
providers so the two paths stay mutually exclusive
- build search context from persisted history using a small tail
heuristic: previous user message, assistant text between the last two
user turns capped at about 1k tokens, and current user message

## Test Plan

- `cargo test -p codex-web-search-extension`
- `cargo test -p codex-api`
- `cargo test -p codex-core
hosted_tools_follow_provider_auth_model_and_config_gates`
This commit is contained in:
sayan-oai
2026-05-26 11:12:24 -07:00
committed by GitHub
Unverified
parent aad59a0916
commit a22706dfae
26 changed files with 1238 additions and 22 deletions
+9
View File
@@ -0,0 +1,9 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "web-search",
crate_name = "codex_web_search_extension",
compile_data = [
"web_run_description.md",
],
)
+31
View File
@@ -0,0 +1,31 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-web-search-extension"
version.workspace = true
[lib]
name = "codex_web_search_extension"
path = "src/lib.rs"
doctest = false
[lints]
workspace = true
[dependencies]
async-trait = { workspace = true }
codex-api = { workspace = true }
codex-core = { workspace = true }
codex-extension-api = { workspace = true }
codex-features = { workspace = true }
codex-login = { workspace = true }
codex-model-provider = { workspace = true }
codex-model-provider-info = { workspace = true }
codex-protocol = { workspace = true }
codex-tools = { workspace = true }
http = { workspace = true }
schemars = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
+173
View File
@@ -0,0 +1,173 @@
use std::sync::Arc;
use codex_api::ApproximateLocation;
use codex_api::LocationType;
use codex_api::SearchContextSize;
use codex_api::SearchFilters;
use codex_api::SearchSettings;
use codex_core::config::Config;
use codex_extension_api::ConfigContributor;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::ThreadLifecycleContributor;
use codex_extension_api::ThreadStartInput;
use codex_extension_api::ToolContributor;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_model_provider::create_model_provider;
use codex_model_provider_info::ModelProviderInfo;
use codex_protocol::config_types::WebSearchContextSize;
use codex_protocol::config_types::WebSearchMode;
use crate::tool::WebSearchTool;
#[derive(Clone)]
struct WebSearchExtension {
auth_manager: Arc<AuthManager>,
}
#[derive(Clone)]
struct WebSearchExtensionConfig {
enabled: bool,
provider: ModelProviderInfo,
settings: SearchSettings,
}
impl From<&Config> for WebSearchExtensionConfig {
fn from(config: &Config) -> Self {
let web_search_mode = config.web_search_mode.value();
Self {
enabled: config.features.enabled(Feature::StandaloneWebSearch)
&& config.model_provider.is_openai()
&& web_search_mode != WebSearchMode::Disabled,
provider: config.model_provider.clone(),
settings: search_settings(config, web_search_mode),
}
}
}
fn search_settings(config: &Config, web_search_mode: WebSearchMode) -> SearchSettings {
let web_search_config = config.web_search_config.as_ref();
SearchSettings {
user_location: web_search_config
.and_then(|config| config.user_location.as_ref())
.map(|location| ApproximateLocation {
r#type: LocationType::Approximate,
country: location.country.clone(),
region: location.region.clone(),
city: location.city.clone(),
timezone: location.timezone.clone(),
}),
search_context_size: web_search_config
.and_then(|config| config.search_context_size)
.map(|size| match size {
WebSearchContextSize::Low => SearchContextSize::Low,
WebSearchContextSize::Medium => SearchContextSize::Medium,
WebSearchContextSize::High => SearchContextSize::High,
}),
filters: web_search_config
.and_then(|config| config.filters.as_ref())
.map(|filters| SearchFilters {
allowed_domains: filters.allowed_domains.clone(),
blocked_domains: None,
}),
external_web_access: Some(match web_search_mode {
WebSearchMode::Live => true,
WebSearchMode::Cached | WebSearchMode::Disabled => false,
}),
..Default::default()
}
}
#[async_trait::async_trait]
impl ThreadLifecycleContributor<Config> for WebSearchExtension {
async fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) {
input
.thread_store
.insert(WebSearchExtensionConfig::from(input.config));
}
}
impl ConfigContributor<Config> for WebSearchExtension {
fn on_config_changed(
&self,
_session_store: &ExtensionData,
thread_store: &ExtensionData,
_previous_config: &Config,
new_config: &Config,
) {
thread_store.insert(WebSearchExtensionConfig::from(new_config));
}
}
impl ToolContributor for WebSearchExtension {
fn tools(
&self,
session_store: &ExtensionData,
thread_store: &ExtensionData,
) -> Vec<Arc<dyn codex_extension_api::ToolExecutor<codex_extension_api::ToolCall>>> {
let Some(config) = thread_store.get::<WebSearchExtensionConfig>() else {
return Vec::new();
};
if !config.enabled {
return Vec::new();
}
vec![Arc::new(WebSearchTool {
session_id: session_store.level_id().to_string(),
provider: create_model_provider(
config.provider.clone(),
Some(self.auth_manager.clone()),
),
settings: config.settings.clone(),
})]
}
}
pub fn install(registry: &mut ExtensionRegistryBuilder<Config>, auth_manager: Arc<AuthManager>) {
let extension = Arc::new(WebSearchExtension { auth_manager });
registry.thread_lifecycle_contributor(extension.clone());
registry.config_contributor(extension.clone());
registry.tool_contributor(extension);
}
#[cfg(test)]
mod tests {
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::ToolName;
use codex_login::CodexAuth;
use codex_model_provider_info::ModelProviderInfo;
use pretty_assertions::assert_eq;
use super::AuthManager;
use super::Config;
use super::WebSearchExtensionConfig;
use super::install;
#[test]
fn installed_extension_contributes_web_run_when_enabled() {
let mut builder = ExtensionRegistryBuilder::<Config>::new();
install(
&mut builder,
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")),
);
let registry = builder.build();
let session_store = ExtensionData::new("session");
let thread_store = ExtensionData::new("11111111-1111-4111-8111-111111111111");
thread_store.insert(WebSearchExtensionConfig {
enabled: true,
provider: ModelProviderInfo::create_openai_provider(/*base_url*/ None),
settings: Default::default(),
});
let tool_names = registry
.tool_contributors()
.iter()
.flat_map(|contributor| contributor.tools(&session_store, &thread_store))
.map(|tool| tool.tool_name())
.collect::<Vec<_>>();
assert_eq!(tool_names, vec![ToolName::namespaced("web", "run")]);
}
}
+170
View File
@@ -0,0 +1,170 @@
use codex_api::SearchInput;
use codex_core::parse_turn_item;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_tools::retain_tail_from_last_n_user_messages;
use codex_tools::truncate_assistant_output_text_to_token_budget;
const ASSISTANT_CONTEXT_TOKEN_LIMIT: usize = 1_000;
const ASSISTANT_ROLE: &str = "assistant";
const USER_ROLE: &str = "user";
/// Builds the conversation tail for standalone web search.
///
/// The tail keeps the previous user text message, up to 1k tokens of assistant
/// text that followed it, and the current user text message.
pub(crate) fn recent_input(items: &[ResponseItem]) -> Option<SearchInput> {
let mut messages = Vec::new();
for item in items {
push_visible_message(&mut messages, item);
}
retain_tail_from_last_n_user_messages(&mut messages, /*user_message_count*/ 2);
truncate_assistant_output_text_to_token_budget(&mut messages, ASSISTANT_CONTEXT_TOKEN_LIMIT);
(!messages.is_empty()).then_some(SearchInput::Items(messages))
}
fn push_visible_message(messages: &mut Vec<ResponseItem>, item: &ResponseItem) {
match item {
ResponseItem::Message { role, .. } if role == ASSISTANT_ROLE => {
messages.push(item.clone());
}
ResponseItem::Message {
id,
role,
content,
phase,
} if role == USER_ROLE
&& matches!(parse_turn_item(item), Some(TurnItem::UserMessage(_))) =>
{
let content = content
.iter()
.filter(|item| matches!(item, ContentItem::InputText { .. }))
.cloned()
.collect::<Vec<_>>();
if !content.is_empty() {
messages.push(ResponseItem::Message {
id: id.clone(),
role: role.clone(),
content,
phase: phase.clone(),
});
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use codex_api::SearchInput;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use pretty_assertions::assert_eq;
use super::ASSISTANT_ROLE;
use super::USER_ROLE;
use super::recent_input;
fn message(role: &str, text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: role.to_string(),
content: vec![if role == ASSISTANT_ROLE {
ContentItem::OutputText {
text: text.to_string(),
}
} else {
ContentItem::InputText {
text: text.to_string(),
}
}],
phase: None,
}
}
#[test]
fn keeps_current_user_and_previous_visible_turn() {
let items = vec![
message("system", "system"),
message(USER_ROLE, "old user"),
message(ASSISTANT_ROLE, "old assistant"),
message(USER_ROLE, "previous user"),
ResponseItem::FunctionCall {
id: None,
name: "tool".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
message(ASSISTANT_ROLE, "previous assistant"),
message("developer", "developer"),
message(USER_ROLE, "current user"),
message(ASSISTANT_ROLE, "current commentary"),
];
assert_eq!(
recent_input(&items),
Some(SearchInput::Items(vec![
message(USER_ROLE, "previous user"),
message(ASSISTANT_ROLE, "previous assistant"),
message(USER_ROLE, "current user"),
]))
);
}
#[test]
fn keeps_only_text_from_recent_user_messages() {
let previous_user = ResponseItem::Message {
id: None,
role: USER_ROLE.to_string(),
content: vec![
ContentItem::InputText {
text: "previous user".to_string(),
},
ContentItem::InputImage {
image_url: "data:image/png;base64,image".to_string(),
detail: None,
},
],
phase: None,
};
let items = vec![
previous_user,
message(ASSISTANT_ROLE, "previous assistant"),
message(USER_ROLE, "current user"),
];
assert_eq!(
recent_input(&items),
Some(SearchInput::Items(vec![
message(USER_ROLE, "previous user"),
message(ASSISTANT_ROLE, "previous assistant"),
message(USER_ROLE, "current user"),
]))
);
}
#[test]
fn ignores_contextual_user_messages_when_selecting_recent_turns() {
let items = vec![
message(USER_ROLE, "previous user"),
message(ASSISTANT_ROLE, "previous assistant"),
message(
USER_ROLE,
"<environment_context>\n<cwd>/tmp</cwd>\n</environment_context>",
),
message(USER_ROLE, "current user"),
];
assert_eq!(
recent_input(&items),
Some(SearchInput::Items(vec![
message(USER_ROLE, "previous user"),
message(ASSISTANT_ROLE, "previous assistant"),
message(USER_ROLE, "current user"),
]))
);
}
}
+7
View File
@@ -0,0 +1,7 @@
mod extension;
mod history;
mod output;
mod schema;
mod tool;
pub use extension::install;
+72
View File
@@ -0,0 +1,72 @@
use codex_extension_api::ToolOutput;
use codex_extension_api::ToolPayload;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
pub(crate) struct EncryptedSearchOutput {
encrypted_output: String,
}
impl EncryptedSearchOutput {
pub(crate) fn new(encrypted_output: String) -> Self {
Self { encrypted_output }
}
}
impl ToolOutput for EncryptedSearchOutput {
fn log_preview(&self) -> String {
"[encrypted standalone web search output]".to_string()
}
fn success_for_logging(&self) -> bool {
true
}
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
// TODO: Make standalone search honor memories.disable_on_external_context,
// as hosted web search does.
ResponseInputItem::FunctionCallOutput {
call_id: call_id.to_string(),
output: FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::EncryptedContent {
encrypted_content: self.encrypted_output.clone(),
},
]),
}
}
}
#[cfg(test)]
mod tests {
use codex_extension_api::ToolPayload;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use pretty_assertions::assert_eq;
use super::EncryptedSearchOutput;
use super::ToolOutput;
#[test]
fn emits_encrypted_function_call_output() {
let output = EncryptedSearchOutput::new("encrypted-search-output".to_string());
assert_eq!(
output.to_response_item(
"call-1",
&ToolPayload::Function {
arguments: "{}".to_string(),
},
),
ResponseInputItem::FunctionCallOutput {
call_id: "call-1".to_string(),
output: FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::EncryptedContent {
encrypted_content: "encrypted-search-output".to_string(),
},
]),
}
);
}
}
+36
View File
@@ -0,0 +1,36 @@
use codex_api::SearchCommands;
use schemars::r#gen::SchemaSettings;
use serde_json::Map;
use serde_json::Value;
pub(crate) fn commands_schema() -> Value {
let schema = SchemaSettings::draft2019_09()
.with(|settings| {
settings.inline_subschemas = true;
settings.option_add_null_type = false;
})
.into_generator()
.into_root_schema_for::<SearchCommands>();
let schema = match serde_json::to_value(schema) {
Ok(schema) => schema,
Err(err) => panic!("search commands schema should serialize: {err}"),
};
let Value::Object(mut schema) = schema else {
unreachable!("search commands schema must be an object");
};
let mut tool_schema = Map::new();
for key in [
"properties",
"required",
"type",
"additionalProperties",
"$defs",
"definitions",
] {
if let Some(value) = schema.remove(key) {
tool_schema.insert(key.to_string(), value);
}
}
Value::Object(tool_schema)
}
+113
View File
@@ -0,0 +1,113 @@
use codex_api::ReqwestTransport;
use codex_api::SearchClient;
use codex_api::SearchCommands;
use codex_api::SearchRequest;
use codex_api::SearchSettings;
use codex_extension_api::FunctionCallError;
use codex_extension_api::ResponsesApiTool;
use codex_extension_api::ToolCall;
use codex_extension_api::ToolExecutor;
use codex_extension_api::ToolName;
use codex_extension_api::ToolOutput;
use codex_extension_api::ToolSpec;
use codex_extension_api::parse_tool_input_schema;
use codex_login::default_client::build_reqwest_client;
use codex_model_provider::SharedModelProvider;
use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ToolExposure;
use codex_tools::default_namespace_description;
use http::HeaderMap;
use crate::history::recent_input;
use crate::output::EncryptedSearchOutput;
use crate::schema::commands_schema;
const WEB_NAMESPACE: &str = "web";
const RUN_TOOL_NAME: &str = "run";
const WEB_RUN_DESCRIPTION: &str = include_str!("../web_run_description.md");
pub(crate) struct WebSearchTool {
pub(crate) session_id: String,
pub(crate) provider: SharedModelProvider,
pub(crate) settings: SearchSettings,
}
#[async_trait::async_trait]
impl ToolExecutor<ToolCall> for WebSearchTool {
fn tool_name(&self) -> ToolName {
ToolName::namespaced(WEB_NAMESPACE, RUN_TOOL_NAME)
}
fn spec(&self) -> ToolSpec {
let parameters = match parse_tool_input_schema(&commands_schema()) {
Ok(parameters) => parameters,
Err(err) => panic!("search command schema should parse: {err}"),
};
ToolSpec::Namespace(ResponsesApiNamespace {
name: WEB_NAMESPACE.to_string(),
description: default_namespace_description(WEB_NAMESPACE),
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
name: RUN_TOOL_NAME.to_string(),
description: WEB_RUN_DESCRIPTION.to_string(),
strict: false,
parameters,
output_schema: None,
defer_loading: None,
})],
})
}
fn exposure(&self) -> ToolExposure {
ToolExposure::DirectModelOnly
}
async fn handle(&self, call: ToolCall) -> Result<Box<dyn ToolOutput>, FunctionCallError> {
let commands = parse_commands(&call)?;
let provider = self
.provider
.api_provider()
.await
.map_err(|err| FunctionCallError::Fatal(err.to_string()))?;
let auth = self
.provider
.api_auth()
.await
.map_err(|err| FunctionCallError::Fatal(err.to_string()))?;
let client = SearchClient::new(
ReqwestTransport::new(build_reqwest_client()),
provider,
auth,
);
let request = SearchRequest {
id: self.session_id.clone(),
model: None,
reasoning: None,
input: recent_input(call.conversation_history.items()),
commands: Some(commands),
settings: Some(self.settings.clone()),
max_output_tokens: Some(
u64::try_from(call.truncation_policy.token_budget()).unwrap_or(u64::MAX),
),
};
let response = client
.search(&request, HeaderMap::new())
.await
.map_err(|err| FunctionCallError::Fatal(err.to_string()))?;
Ok(Box::new(EncryptedSearchOutput::new(
response.encrypted_output,
)))
}
}
fn parse_commands(call: &ToolCall) -> Result<SearchCommands, FunctionCallError> {
let arguments = call.function_arguments()?;
if arguments.trim().is_empty() {
return Ok(SearchCommands::default());
}
serde_json::from_str(arguments)
.map_err(|err| FunctionCallError::RespondToModel(err.to_string()))
}
@@ -0,0 +1,80 @@
Tool for accessing the internet.
---
## Examples of different commands available in this tool
Examples of different commands available in this tool:
* `search_query`: {"search_query": [{"q": "What is the capital of France?"}, {"q": "What is the capital of belgium?"}]}. Searches the internet for a given query (and optionally with a domain or recency filter)
* `image_query`: {"image_query":[{"q": "waterfalls"}]}.
* `open`: {"open": [{"ref_id": "turn0search0"}, {"ref_id": "https://www.openai.com", "lineno": 120}]}
* `click`: {"click": [{"ref_id": "turn0fetch3", "id": 17}]}
* `find`: {"find": [{"ref_id": "turn0fetch3", "pattern": "Annie Case"}]}
* `screenshot`: {"screenshot": [{"ref_id": "turn1view0", "pageno": 0}, {"ref_id": "turn1view0", "pageno": 3}]}
* `finance`: {"finance":[{"ticker":"AMD","type":"equity","market":"USA"}]}, {"finance":[{"ticker":"BTC","type":"crypto","market":""}]}
* `weather`: {"weather":[{"location":"San Francisco, CA"}]}
* `sports`: {"sports":[{"fn":"standings","league":"nfl"}, {"fn":"schedule","league":"nba","team":"GSW","date_from":"2025-02-24"}]}
* `time`: {"time":[{"utc_offset":"+03:00"}]}
---
## Usage hints
To use this tool efficiently:
* Use multiple commands and queries in one call to get more results faster; e.g. {"search_query": [{"q": "bitcoin news"}], "finance":[{"ticker":"BTC","type":"crypto","market":""}], "find": [{"ref_id": "turn0search0", "pattern": "Annie Case"}, {"ref_id": "turn0search1", "pattern": "John Smith"}]}
* Use "response_length" to control the number of results returned by this tool, omit it if you intend to pass "short" in
* Only write required parameters; do not write empty lists or nulls where they could be omitted.
* `search_query` must have length at most 4 in each call. If it has length > 3, response_length must be medium or long
* If you find yourself in a situation where you accidentally call the `web.run` tool, it's best just to send an empty query: {"search_query": [{"q": ""}]}.
---
## Decision boundary
If the user makes an explicit request to search the internet, find latest information, look up, etc (or to not do so), you must obey their request.
When you make an assumption, always consider whether it is temporally stable; i.e. whether there's even a small (>10%) chance it has changed. If it is unstable, you must verify with browsing the internet for verification.
<situations_where_you_must_browse_the_internet>
Below is a list of scenarios where browsing the internet MUST be used. PAY CLOSE ATTENTION: you MUST browse the internet in these cases. If you're unsure or on the fence, you MUST bias towards browsing the internet.
- The information could have changed recently: for example news; prices; laws; schedules; product specs; sports scores; economic indicators; political/public/company figures (e.g. the question relates to 'the president of country A' or 'the CEO of company B', which might change over time); rules; regulations; standards; software libraries that could be updated; exchange rates; recommendations (i.e., recommendations about various topics or things might be informed by what currently exists / is popular / is safe / is unsafe / is in the zeitgeist / etc.); and many many many more categories -- again, if you're on the fence, you MUST browse the internet!
- For news queries, prioritize more recent events, ensuring you compare publish dates and the date that the event happened.
- The user is seeking recommendations that could lead them to spend substantial time or money -- researching products, restaurants, travel plans, etc.
- The user wants (or would benefit from) direct quotes, links, or precise source attribution.
- A specific page, paper, dataset, PDF, or site is referenced and you haven't been given its contents.
- You're unsure about a fact, the topic is niche or emerging, or you suspect there's at least a 10% chance you will incorrectly recall it
- High-stakes accuracy matters (medical, legal, financial guidance). For these you generally should search by default because this information is highly temporally unstable
- The user explicitly says to search, browse, verify, or look it up.
</situations_where_you_must_browse_the_internet>
---
## Special cases
If these conflict with any other instructions, these should take precedence.
<special_cases>
- When the user asks for information about how to use OpenAI products, (ChatGPT, the OpenAI API, etc.), you should check the code in local env and only browse as fallback, when you browse restrict your sources to official OpenAI websites using the domains filter, unless otherwise requested.
- When using search to answer technical questions, you must only rely on primary sources (research papers, official documentation, etc.)
- Clearly indicate when you are making an inference from sources.
</special_cases>
---
## Word limits
Responses may not excessively quote or draw on a specific source. There are several limits here:
- **Limit on verbatim quotes:**
- You may not quote more than 25 words verbatim from any single non-lyrical source, unless the source is reddit.
- For song lyrics, verbatim quotes must be limited to at most 10 words.
- Long quotes from reddit are allowed, as long as you indicate that those are direct quotes via a markdown blockquote starting with ">", copy verbatim, and link the source.
- **Word limits:**
- Each webpage source in the sources has a word limit label formatted like "[wordlim N]", in which N is the maximum number of words in the whole response that are attributed to that source. If omitted, the word limit is 200 words.
- Non-contiguous words derived from a given source must be counted to the word limit.
- The summarization limit N is a maximum for each source.
- When using multiple sources, their summarization limits add together. However, each article used must be relevant to the response.
- **Copyright compliance:**
- You must avoid providing full articles, long verbatim passages, or extensive direct quotes due to copyright concerns.
- If the user asked for a verbatim quote, the response should provide a short compliant excerpt and then answer with paraphrases and summaries.
- Again, this limit does not apply to reddit content, as long as it's appropriately indicated that those are direct quotes and you link to the source.
---
Make sure to provide links to the sources you used in your response.