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
+14 -4
View File
@@ -238,11 +238,12 @@ fn spec_for_model_request(
}
}
pub(crate) fn hosted_model_tool_specs(turn_context: &TurnContext) -> Vec<ToolSpec> {
fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec<ToolSpec> {
let turn_context = context.turn_context;
let mut specs = Vec::new();
let provider_capabilities = turn_context.provider.capabilities();
let web_search_mode = provider_capabilities
.web_search
let web_search_mode = (!standalone_web_run_available(context.extension_tool_executors)
&& provider_capabilities.web_search)
.then_some(turn_context.config.web_search_mode.value());
let web_search_config = if provider_capabilities.web_search {
turn_context.config.web_search_config.as_ref()
@@ -504,11 +505,20 @@ fn add_tool_sources(context: &CoreToolPlanContext<'_>, planned_tools: &mut Plann
add_mcp_runtime_tools(context, planned_tools);
add_dynamic_tools(context, planned_tools);
add_extension_tools(context, planned_tools);
for spec in hosted_model_tool_specs(context.turn_context) {
for spec in hosted_model_tool_specs(context) {
planned_tools.add_hosted_spec(spec);
}
}
fn standalone_web_run_available(
extension_tools: &[Arc<dyn ToolExecutor<ExtensionToolCall>>],
) -> bool {
let web_run = ToolName::namespaced("web", "run");
extension_tools
.iter()
.any(|executor| executor.tool_name() == web_run)
}
fn add_shell_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) {
let turn_context = context.turn_context;
let features = turn_context.features.get();
+56 -1
View File
@@ -20,8 +20,11 @@ use codex_tools::DiscoverablePluginInfo;
use codex_tools::DiscoverableTool;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolCall as ExtensionToolCall;
use codex_tools::ToolExecutor;
use codex_tools::ToolExposure;
use codex_tools::ToolName;
use codex_tools::ToolOutput;
use codex_tools::ToolSpec;
use pretty_assertions::assert_eq;
use serde_json::json;
@@ -37,6 +40,7 @@ struct ToolPlanInputs {
mcp_tools: Option<Vec<ToolInfo>>,
deferred_mcp_tools: Option<Vec<ToolInfo>>,
discoverable_tools: Option<Vec<DiscoverableTool>>,
extension_tool_executors: Vec<Arc<dyn ToolExecutor<ExtensionToolCall>>>,
dynamic_tools: Vec<DynamicToolSpec>,
}
@@ -176,7 +180,7 @@ async fn probe_with(
mcp_tools: inputs.mcp_tools,
deferred_mcp_tools: inputs.deferred_mcp_tools,
discoverable_tools: inputs.discoverable_tools,
extension_tool_executors: Vec::new(),
extension_tool_executors: inputs.extension_tool_executors,
dynamic_tools: inputs.dynamic_tools.as_slice(),
},
);
@@ -253,6 +257,37 @@ fn use_bedrock_provider(turn: &mut TurnContext) {
turn.provider = create_model_provider(provider_info, turn.auth_manager.clone());
}
struct WebRunExtensionTool;
#[async_trait::async_trait]
impl ToolExecutor<ExtensionToolCall> for WebRunExtensionTool {
fn tool_name(&self) -> ToolName {
ToolName::namespaced("web", "run")
}
fn spec(&self) -> ToolSpec {
ToolSpec::Namespace(codex_tools::ResponsesApiNamespace {
name: "web".to_string(),
description: "Test web namespace.".to_string(),
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
name: "run".to_string(),
description: "Test standalone web search tool.".to_string(),
strict: false,
defer_loading: None,
parameters: codex_tools::JsonSchema::default(),
output_schema: None,
})],
})
}
async fn handle(
&self,
_call: ExtensionToolCall,
) -> Result<Box<dyn ToolOutput>, codex_tools::FunctionCallError> {
Ok(Box::new(codex_tools::JsonToolOutput::new(json!({}))))
}
}
fn duplicate_primary_environment(turn: &mut TurnContext) {
let mut second_environment = turn.environments.turn_environments[0].clone();
second_environment.environment_id = "secondary".to_string();
@@ -947,6 +982,26 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() {
}
);
let standalone_web_search_without_web_run = probe(|turn| {
set_feature(turn, Feature::StandaloneWebSearch, /*enabled*/ true);
set_web_search_mode(turn, WebSearchMode::Live);
})
.await;
standalone_web_search_without_web_run.assert_visible_contains(&["web_search"]);
let standalone_web_search = probe_with(
|turn| {
set_feature(turn, Feature::StandaloneWebSearch, /*enabled*/ true);
set_web_search_mode(turn, WebSearchMode::Live);
},
ToolPlanInputs {
extension_tool_executors: vec![Arc::new(WebRunExtensionTool)],
..Default::default()
},
)
.await;
standalone_web_search.assert_visible_lacks(&["web_search"]);
let unsupported_provider = probe(|turn| {
set_web_search_mode(turn, WebSearchMode::Live);
use_bedrock_provider(turn);