Refine standalone web search gating

This commit is contained in:
Sayan Sisodiya
2026-05-21 16:15:30 -07:00
parent f51225081b
commit 81483023e1
4 changed files with 81 additions and 25 deletions

View File

@@ -238,15 +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 standalone_web_search = turn_context
.config
.features
.enabled(Feature::StandaloneWebSearch)
&& turn_context.provider.info().is_openai();
let web_search_mode = (!standalone_web_search && 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()
@@ -508,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();

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,22 @@ 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")
}
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();
@@ -948,11 +968,24 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() {
}
);
let standalone_web_search = probe(|turn| {
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| {

View File

@@ -36,8 +36,8 @@ struct WebSearchExtensionConfig {
settings: SearchSettings,
}
impl WebSearchExtensionConfig {
fn from_config(config: &Config) -> Self {
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)
@@ -87,7 +87,7 @@ impl ThreadLifecycleContributor<Config> for WebSearchExtension {
async fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) {
input
.thread_store
.insert(WebSearchExtensionConfig::from_config(input.config));
.insert(WebSearchExtensionConfig::from(input.config));
}
}
@@ -99,7 +99,7 @@ impl ConfigContributor<Config> for WebSearchExtension {
_previous_config: &Config,
new_config: &Config,
) {
thread_store.insert(WebSearchExtensionConfig::from_config(new_config));
thread_store.insert(WebSearchExtensionConfig::from(new_config));
}
}

View File

@@ -11,8 +11,8 @@ const ASSISTANT_CONTEXT_TOKEN_LIMIT: usize = 1_000;
/// Builds the persisted conversation tail for standalone web search.
///
/// The tail keeps the previous user message, up to 1k tokens of assistant text
/// that followed it, and the current user message.
/// 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: &[RolloutItem]) -> Option<SearchInput> {
let messages = recent_messages(items);
(!messages.is_empty()).then_some(SearchInput::Items(messages))
@@ -46,11 +46,29 @@ fn recent_messages(items: &[RolloutItem]) -> Vec<ResponseItem> {
}
fn push_visible_message(messages: &mut Vec<ResponseItem>, item: &ResponseItem) {
if matches!(
item,
ResponseItem::Message { role, .. } if role == "user" || role == "assistant"
) {
messages.push(item.clone());
match item {
ResponseItem::Message { role, .. } if role == "assistant" => messages.push(item.clone()),
ResponseItem::Message {
id,
role,
content,
phase,
} if role == "user" => {
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(),
});
}
}
_ => {}
}
}
@@ -119,7 +137,6 @@ fn cap_assistant_text(messages: &mut Vec<ResponseItem>) {
mod tests {
use codex_api::SearchInput;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ImageDetail;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::CompactedItem;
use codex_protocol::protocol::RolloutItem;
@@ -206,7 +223,7 @@ mod tests {
}
#[test]
fn preserves_image_content_from_recent_user_messages() {
fn keeps_only_text_from_recent_user_messages() {
let previous_user = ResponseItem::Message {
id: None,
role: "user".to_string(),
@@ -216,7 +233,7 @@ mod tests {
},
ContentItem::InputImage {
image_url: "data:image/png;base64,image".to_string(),
detail: Some(ImageDetail::High),
detail: None,
},
],
phase: None,
@@ -230,7 +247,7 @@ mod tests {
assert_eq!(
recent_input(&items),
Some(SearchInput::Items(vec![
previous_user,
message("user", "previous user"),
message("assistant", "previous assistant"),
message("user", "current user"),
]))