Resolve model-selected search providers with policy-aware fallback (#59036)

## Why

Search discovery must account for the active model's supported tools while honoring host search restrictions. Automatic fallback must also preserve the provider that owns existing browsing references.

## What changed

- Resolve search from model metadata, client configuration, and managed requirements, exposing the selected provider's tools.
- Discover hosted search with the model's supported tools and carry the discovered invocation context into calls.
- Allow native fallback for eligible discovery failures only when search policy permits it and hosted search has not already been used. Do not replay failed hosted invocations through native search.
- Keep native fallback references on native search across later turns, discovery recovery, and resume. Release unused fallback offers and coordinate background calls to prevent conflicting provider use.

## Testing

Add app-server and core scenario coverage for provider selection, discovery failures, search restrictions, reference continuity, resume, and concurrent background search. Include a snapshot covering discovery and call history across a model switch.

GitOrigin-RevId: 0f22605722daaa424c4fa34660654cde5a6db049
This commit is contained in:
yitaoh-oai
2026-11-03 02:08:56 +00:00
committed by copyberry
parent 3c074bb44a
commit e839b828fb
15 changed files with 1749 additions and 11 deletions
+1
View File
@@ -111,6 +111,7 @@ pub(crate) mod mcp;
mod mcp_skill_dependencies;
mod mcp_tool_approval_templates;
mod mcp_tool_exposure;
mod model_search;
mod network_policy_decision;
pub use mcp::McpManager;
pub use mcp::McpServersDisabled;
+70
View File
@@ -488,6 +488,39 @@ async fn handle_approved_mcp_tool_call(
let mut tool_input = arguments_value
.clone()
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new()));
let is_search = prepared_call.is_host_owned_apps()
&& codex_mcp::is_model_search_tool(prepared_call.tool_info());
let search = if is_search {
step_context
.extension_data
.get::<crate::model_search::ModelSearch>()
} else {
None
};
// Code Mode can retain this step past a later turn's first native call.
// A call rejected here never reaches the peer or acquires MCP attribution.
if matches!(
search.as_deref(),
Some(crate::model_search::ModelSearch::Hosted { .. })
) && !crate::model_search::SearchOwner::admit(
&sess.services.thread_extension_data,
codex_protocol::openai_models::WebSearchProvider::SearchMcp,
) {
let result = notify_mcp_tool_call_skip(
sess,
turn_context,
call_id,
invocation,
item_metadata,
"This thread is using native search".to_string(),
/*already_started*/ true,
)
.await;
return HandledMcpToolCall {
result: CallToolResult::from_result(result),
tool_input,
};
}
let mut elicitation_type = None;
let mut request_error_code = None;
let result = async {
@@ -569,6 +602,32 @@ async fn handle_approved_mcp_tool_call(
serde_json::json!(turn_context.cyber_access_program),
);
}
let request_meta = if let Some(crate::model_search::ModelSearch::Hosted {
binding,
context,
}) = search.as_deref()
{
let mut meta = request_meta.unwrap_or_else(|| serde_json::json!({}));
let turn_meta = meta
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("invalid MCP metadata"))?
.entry(crate::X_CODEX_TURN_METADATA_HEADER)
.or_insert_with(|| serde_json::json!({}));
crate::model_search::with_search_context(turn_meta, context)?;
let pin = binding
.search_invocation_context(&prepared_call)
.ok_or_else(|| {
anyhow::anyhow!("Search MCP connection changed after discovery")
})?;
let apps = meta
.get_mut(MCP_TOOL_CODEX_APPS_META_KEY)
.and_then(serde_json::Value::as_object_mut)
.ok_or_else(|| anyhow::anyhow!("missing hosted Apps call metadata"))?;
apps.insert("invocation_context".to_string(), pin.clone());
Some(meta)
} else {
request_meta
};
let request_meta = with_mcp_tool_call_ids_meta(
request_meta,
&sess.thread_id.to_string(),
@@ -581,6 +640,17 @@ async fn handle_approved_mcp_tool_call(
request_meta,
)
.await?;
if is_search
&& let Some(usage) = step_context
.turn
.extension_data
.get::<crate::model_search::SearchTurnUsage>()
{
usage.record(
&step_context.turn,
codex_protocol::openai_models::WebSearchProvider::SearchMcp,
);
}
let mcp_call_trace = sess
.services
.rollout_thread_trace
+1 -1
View File
@@ -247,7 +247,7 @@ fn filter_non_codex_apps_mcp_tools_only(
})
}
fn filter_codex_apps_mcp_tools<'a>(
pub(crate) fn filter_codex_apps_mcp_tools<'a>(
mcp_tools: &'a [McpToolInfo],
config: &'a Config,
) -> impl Iterator<Item = &'a McpToolInfo> + 'a {
+456
View File
@@ -0,0 +1,456 @@
//! Resolve one search provider for the sampling step, preserving the host's search policy.
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::Weak;
use std::sync::atomic::AtomicU8;
use std::sync::atomic::Ordering;
use codex_extension_api::ExtensionData;
use codex_history::InitialHistory;
use codex_history::ResponseItemEnvelope;
use codex_history::RolloutItem;
use codex_mcp::McpBinding;
use codex_mcp::SearchDiscovery;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::mcp::McpAttributionStatus;
use codex_protocol::openai_models::WebSearchProvider;
use codex_tools::ToolSpec;
use serde_json::Map;
use serde_json::Value;
use serde_json::json;
use crate::session::step_settings::ResolvedStepSettings;
use crate::session::turn_context::TurnContext;
use crate::state::SessionServices;
use crate::turn_metadata::ExecutionMetadata;
#[derive(Clone)]
pub(crate) enum ModelSearch {
Hosted {
binding: Arc<McpBinding>,
context: Map<String, Value>,
},
ClientMcp,
Native {
mode: WebSearchMode,
fallback: bool,
},
Disabled,
}
#[derive(Default)]
pub(crate) struct SearchTurnUsage(AtomicU8);
impl SearchTurnUsage {
const MCP: u8 = 1;
const NATIVE: u8 = 2;
pub(crate) fn record(&self, turn: &TurnContext, provider: WebSearchProvider) {
let (flag, label) = match provider {
WebSearchProvider::SearchMcp => (Self::MCP, "search_mcp"),
WebSearchProvider::Standalone => (Self::NATIVE, "native"),
};
let previous = self.0.fetch_or(flag, Ordering::Relaxed);
if previous & flag == 0 {
turn.session_telemetry.counter(
"codex.model_search.turn",
/*inc*/ 1,
&[("provider", label)],
);
if previous != 0 {
turn.session_telemetry.counter(
"codex.model_search.turn",
/*inc*/ 1,
&[("provider", "mixed")],
);
}
}
}
}
struct CachedSearch {
base: Arc<McpBinding>,
request: Map<String, Value>,
discovery: SearchDiscovery,
}
/// Concurrent calls on the same provider share a reservation. An actual search
/// keeps it for the thread. An unused offer releases it when sampling finishes.
pub(crate) struct SearchOwner(WebSearchProvider);
impl SearchOwner {
fn reserve(store: &ExtensionData, provider: WebSearchProvider) -> Option<Arc<Self>> {
let slot = store.get_or_init(Mutex::<Weak<Self>>::default);
let mut slot = slot
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(owner) = slot.upgrade() {
return (owner.0 == provider).then_some(owner);
}
let owner = Arc::new(Self(provider));
*slot = Arc::downgrade(&owner);
Some(owner)
}
pub(crate) fn admit(store: &ExtensionData, provider: WebSearchProvider) -> bool {
let Some(owner) = Self::reserve(store, provider) else {
return false;
};
store.insert_arc(owner);
true
}
pub(crate) fn reserve_native_fallback(
step: &ExtensionData,
thread: &ExtensionData,
) -> Result<Option<Arc<Self>>, &'static str> {
if matches!(
step.get::<ModelSearch>().as_deref(),
Some(ModelSearch::Native { fallback: true, .. })
) {
return Self::reserve(thread, WebSearchProvider::Standalone)
.map(Some)
.ok_or("Search MCP is already in use in this thread");
}
Ok(None)
}
// Responses can execute its built-in search before the harness receives the
// first search event. Reserve it while that request can still issue a call.
pub(crate) fn reserve_server_fallback(
step: &ExtensionData,
thread: &ExtensionData,
tools: &mut Arc<[ToolSpec]>,
) -> Option<Arc<Self>> {
let is_search = |tool: &ToolSpec| matches!(tool, ToolSpec::WebSearch { .. });
if !tools.iter().any(is_search) {
return None;
}
match Self::reserve_native_fallback(step, thread) {
Ok(owner) => owner,
Err(_) => {
*tools = tools
.iter()
.filter(|tool| !is_search(tool))
.cloned()
.collect();
None
}
}
}
fn current(store: &ExtensionData) -> Option<WebSearchProvider> {
store.get::<Self>().map(|owner| owner.0)
}
pub(crate) fn restore(store: &ExtensionData, history: &InitialHistory) {
let used = |item: &ResponseItemEnvelope| {
item.metadata
.as_ref()
.is_some_and(|meta| meta.native_search_fallback_used)
};
if history.scan_rollout_items(|item| match item {
RolloutItem::ResponseItem(item) => used(item),
RolloutItem::Compacted(item) => item.replacement_history.iter().flatten().any(used),
_ => false,
}) {
Self::admit(store, WebSearchProvider::Standalone);
}
}
pub(crate) fn checkpoint(store: &ExtensionData, items: &mut [ResponseItemEnvelope]) {
if Self::current(store) == Some(WebSearchProvider::Standalone) {
for item in items {
item.metadata
.get_or_insert_default()
.native_search_fallback_used = true;
}
}
}
}
fn object(value: Option<&Value>) -> Option<Map<String, Value>> {
match value {
None => Some(Map::new()),
Some(Value::Object(map)) => Some(map.clone()),
Some(Value::String(encoded)) => serde_json::from_str(encoded).ok(),
_ => None,
}
}
impl crate::config::Config {
// Read the scalar override from the highest enabled configuration layer. Hosts can
// supply it through normal session flags without extending the shared Config struct.
pub(crate) fn client_search_provider(&self) -> Option<WebSearchProvider> {
self.config_layer_stack
.layers_high_to_low()
.find_map(|layer| layer.config.get("tools")?.get("client_search_provider"))?
.clone()
.try_into()
.ok()
}
pub(crate) fn requires_native_search(&self) -> bool {
let standalone = codex_features::Feature::StandaloneWebSearch;
if !self.features.enabled(standalone) {
return false;
}
let mut features = self.features.clone();
features.disable(standalone).is_err() || features.enabled(standalone)
}
}
fn requested_mode(context: &Map<String, Value>, default: WebSearchMode) -> Option<WebSearchMode> {
context.get("mode").map_or(Some(default), |value| {
serde_json::from_value(value.clone()).ok()
})
}
// These host metadata fields do not select connectors. Preserve the installation ID
// when narrowing discovery, and validate the search context separately for native.
fn unrestricted_connectors(request: &Map<String, Value>) -> bool {
request.keys().all(|key| {
matches!(
key.as_str(),
"openai/search_context" | "openai/codex_installation_id"
)
})
}
fn native_search_mode(turn: &TurnContext, request: &Map<String, Value>) -> Option<WebSearchMode> {
if !unrestricted_connectors(request) {
return None;
}
let context = object(request.get("openai/search_context"))?;
let configured = turn.config.web_search_mode.value();
let mut mode = configured.restrict_to(requested_mode(&context, configured)?);
// A host domain allowlist covers opens and clicks. Native filters only the query.
for (key, value) in &context {
match key.as_str() {
"mode" | "telemetry_attributes" | "model_supported_tools" => {}
"is_web_access_restricted" => {
if value.as_bool()? {
mode = mode.restrict_to(WebSearchMode::Cached);
}
}
"session_persistent" if value.as_bool()? || turn.config.ephemeral => {}
_ => return None,
}
}
turn.config.web_search_mode.can_set(&mode).ok()?;
(mode != WebSearchMode::Disabled).then_some(mode)
}
fn search_context(turn: &TurnContext, request: &Map<String, Value>) -> Option<Map<String, Value>> {
let native_mode = turn.config.web_search_mode.value();
if !turn.apps_enabled() {
return None;
}
let config = turn.config.web_search_config.as_ref();
if config.is_some_and(|config| {
config.user_location.is_some() || config.search_context_size.is_some()
}) {
return None;
}
let mut context = object(request.get("openai/search_context"))?;
let host_mode = requested_mode(&context, native_mode)?;
let mode = if native_mode == WebSearchMode::Disabled
&& turn
.config
.features
.enabled(codex_features::Feature::HostedSearchPolicy)
{
host_mode
} else {
native_mode.restrict_to(host_mode)
};
if mode == WebSearchMode::Disabled {
return None;
}
turn.config.web_search_mode.can_set(&mode).ok()?;
context.insert("mode".into(), json!(mode));
let restricted = context
.get("is_web_access_restricted")
.map_or(Some(false), Value::as_bool)?;
context.insert(
"is_web_access_restricted".into(),
json!(restricted || mode == WebSearchMode::Cached),
);
let persistent = context
.get("session_persistent")
.map_or(Some(true), Value::as_bool)?;
context.insert(
"session_persistent".into(),
json!(persistent && !turn.config.ephemeral),
);
if let Some(domains) = config
.and_then(|c| c.filters.as_ref()?.allowed_domains.as_ref())
.filter(|d| !d.is_empty())
{
// Distinct domain policies can contain wildcards. Don't guess at their intersection.
if context
.get("allowed_domains")
.is_some_and(|value| *value != json!(domains))
{
return None;
}
context.insert("allowed_domains".into(), json!(domains));
}
Some(context)
}
pub(crate) async fn resolve(
turn: &TurnContext,
settings: &ResolvedStepSettings,
base: &Arc<McpBinding>,
services: &SessionServices,
) -> Option<ModelSearch> {
let native_required = turn.config.requires_native_search();
let provider = if native_required {
WebSearchProvider::Standalone
} else if let Some(provider) = settings.model_info.web_search_provider {
provider
} else {
match turn.config.client_search_provider()? {
WebSearchProvider::SearchMcp => return Some(ModelSearch::ClientMcp),
WebSearchProvider::Standalone => WebSearchProvider::Standalone,
}
};
let observe = |decision, reason| {
turn.session_telemetry.counter(
"codex.model_search.discovery",
/*inc*/ 1,
&[("reason", reason)],
);
Some(decision)
};
let mut turn_meta = turn
.turn_metadata_state
.current_meta_value_for_mcp_request(ExecutionMetadata::from_settings(settings))
.unwrap_or_else(|| json!({}));
let Some(mut request) = object(turn_meta.get("mcp_request_meta")) else {
return observe(ModelSearch::Disabled, "invalid_request_metadata");
};
let native_mode = native_search_mode(turn, &request);
if native_required {
return match native_mode {
Some(mode) => observe(
ModelSearch::Native {
mode,
fallback: false,
},
"native_required",
),
None => observe(ModelSearch::Disabled, "fallback_restricted"),
};
}
if provider == WebSearchProvider::Standalone {
return Some(
native_mode.map_or(ModelSearch::Disabled, |mode| ModelSearch::Native {
mode,
fallback: false,
}),
);
}
if !crate::mcp_tool_exposure::filter_codex_apps_mcp_tools(base.tools(), &turn.config)
.any(codex_mcp::is_model_search_tool)
{
return observe(ModelSearch::Disabled, "connector_unavailable");
}
let usage = turn.extension_data.get_or_init(SearchTurnUsage::default);
let Some(mut context) = search_context(turn, &request) else {
return observe(ModelSearch::Disabled, "search_policy");
};
context.insert(
"model_supported_tools".into(),
json!(settings.model_info.experimental_supported_tools),
);
// Scoped discovery may narrow an unrestricted catalog. Keep existing allowlists,
// denylists, plugin selection, and all other host metadata intact.
if unrestricted_connectors(&request) {
request.insert(
"selected_connector_ids".into(),
json!([codex_mcp::SEARCH_CONNECTOR]),
);
}
request.insert("openai/search_context".into(), json!(&context));
turn_meta["mcp_request_meta"] = json!(&request);
// Only discovery is stable within the turn. A yielded search can finish on a later step.
let cached = turn
.extension_data
.get::<CachedSearch>()
.filter(|cached| Arc::ptr_eq(&cached.base, base) && cached.request == request);
let fresh = cached.is_none();
let cached = match cached {
Some(cached) => cached,
None => {
let cached = Arc::new(CachedSearch {
base: Arc::clone(base),
request,
discovery: base.discover_model_search(turn_meta).await,
});
turn.extension_data.insert_arc(Arc::clone(&cached));
cached
}
};
let finish = |decision, reason| {
if fresh {
observe(decision, reason)
} else {
Some(decision)
}
};
let owner = SearchOwner::current(&services.thread_extension_data);
let reason = match &cached.discovery {
SearchDiscovery::Ready(binding) => {
if owner != Some(WebSearchProvider::Standalone) {
return finish(
ModelSearch::Hosted {
binding: Arc::clone(binding),
context,
},
"hosted",
);
}
"native_browsing_state"
}
SearchDiscovery::Critical(reason) => *reason,
SearchDiscovery::Unavailable => return finish(ModelSearch::Disabled, "unavailable"),
};
let attribution = services.executed_tool_calls.mcp_attribution_snapshot();
if let Some(mode) = native_mode
&& owner != Some(WebSearchProvider::SearchMcp)
&& usage.0.load(Ordering::Relaxed) & SearchTurnUsage::MCP == 0
&& attribution.status != McpAttributionStatus::AttributionError
&& !attribution
.sources
.iter()
.any(|source| source.connector_id.as_deref() == Some(codex_mcp::SEARCH_CONNECTOR))
{
finish(
ModelSearch::Native {
mode,
fallback: true,
},
reason,
)
} else {
finish(ModelSearch::Disabled, "fallback_restricted")
}
}
/// Preserve all other per-call metadata while replacing the policy with the one used for discovery.
pub(crate) fn with_search_context(
request: &mut Value,
context: &Map<String, Value>,
) -> anyhow::Result<()> {
let envelope = request
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("invalid search turn metadata"))?;
let mut meta = object(envelope.get("mcp_request_meta"))
.ok_or_else(|| anyhow::anyhow!("invalid MCP request metadata"))?;
meta.insert("openai/search_context".into(), json!(context));
envelope.insert("mcp_request_meta".into(), Value::Object(meta));
Ok(())
}
+62 -3
View File
@@ -2870,6 +2870,24 @@ impl Session {
}
pub(crate) async fn emit_turn_item_started(&self, turn_context: &TurnContext, item: &TurnItem) {
if matches!(
item,
TurnItem::WebSearch(_)
| TurnItem::Extension(codex_extension_items::ExtensionItem::WebSearch(_))
) && let Some(usage) = turn_context
.extension_data
.get::<crate::model_search::SearchTurnUsage>()
{
// This turn selected Search MCP. Pin native only when a fallback call starts.
crate::model_search::SearchOwner::admit(
&self.services.thread_extension_data,
codex_protocol::openai_models::WebSearchProvider::Standalone,
);
usage.record(
turn_context,
codex_protocol::openai_models::WebSearchProvider::Standalone,
);
}
let started_at_ms = turn_context
.turn_timing_state
.record_item_started(item.id(), now_unix_timestamp_ms())
@@ -3901,6 +3919,10 @@ impl Session {
// Preserve any existing tool-specific override.
let policy: codex_utils_output_truncation::TruncationPolicy =
model_info.truncation_policy.into();
crate::model_search::SearchOwner::checkpoint(
&self.services.thread_extension_data,
&mut items,
);
for envelope in &mut items {
if matches!(
envelope.item,
@@ -4231,8 +4253,6 @@ impl Session {
let extension_data =
codex_extension_api::ExtensionData::new(turn_context.sub_id.clone());
extension_data.insert_arc(Arc::clone(&settings.model_info.model));
// Standalone and hosted search must use the same turn-scoped permission mode.
extension_data.insert(turn_context.config.web_search_mode.value());
if let Some(messages) = settings
.model_info
.model_messages
@@ -4249,16 +4269,51 @@ impl Session {
if let Some(messages) = model_messages.notes() {
extension_data.insert(messages.clone());
}
let mut required_servers = required_servers.to_vec();
if settings.model_info.web_search_provider
== Some(codex_protocol::openai_models::WebSearchProvider::SearchMcp)
&& turn_context.apps_enabled()
&& !turn_context.config.requires_native_search()
&& (turn_context.config.web_search_mode.value()
!= codex_protocol::config_types::WebSearchMode::Disabled
|| turn_context
.config
.features
.enabled(codex_features::Feature::HostedSearchPolicy))
{
required_servers.push(codex_mcp::CODEX_APPS_MCP_SERVER_NAME.to_owned());
}
let (mcp, prepared_recommendations) = tokio::join!(
// MCP refresh can be large; keep it off the sampling request's stack.
Box::pin(self.mcp_runtime_for_step(
turn_context.as_ref(),
&selected_capability_roots,
required_servers,
&required_servers,
required_plugins,
)),
turn::prepare_tool_recommendations(self.as_ref(), turn_context.as_ref()),
);
let search = crate::model_search::resolve(
turn_context.as_ref(),
&settings,
&mcp,
&self.services,
)
.await;
extension_data.insert(match &search {
Some(crate::model_search::ModelSearch::Native { mode, .. }) => *mode,
_ => turn_context.config.web_search_mode.value(),
});
let mcp = if let Some(search) = search {
let binding = match &search {
crate::model_search::ModelSearch::Hosted { binding, .. } => Arc::clone(binding),
_ => mcp,
};
extension_data.insert(search);
binding
} else {
mcp
};
extension_data.insert(selected_capability_roots);
// A step keeps the plugins from the environments it captured, even if shared MCP
// moves on to another environment. Its skill tools and the model use this same copy.
@@ -4527,6 +4582,10 @@ impl Session {
CompactedHistoryInstallation::Complete(metadata) => &metadata.compaction_model_hash,
CompactedHistoryInstallation::Prefix { model_info, .. } => &model_info.comp_hash,
};
crate::model_search::SearchOwner::checkpoint(
&self.services.thread_extension_data,
&mut items,
);
let mcp_revision = self
.services
.executed_tool_calls
+1
View File
@@ -1091,6 +1091,7 @@ impl Session {
thread_extension_init,
);
thread_extension_data.insert(crate::context::GuardianReviewEvidence::default());
crate::model_search::SearchOwner::restore(&thread_extension_data, &initial_history);
// Kick off independent async setup tasks in parallel to reduce startup latency.
//
// - initialize thread persistence with new or resumed session info
+5
View File
@@ -1872,6 +1872,11 @@ async fn run_sampling_request(
if first_attempt && turn_context.config.notify.is_some() {
notify_messages = legacy_notify_input_messages(&prompt.input);
}
let _native_search = crate::model_search::SearchOwner::reserve_server_fallback(
&step_context.extension_data,
&sess.services.thread_extension_data,
&mut prompt.tools,
);
let mut draining_for_mailbox = false;
let err = match try_run_sampling_request(
tool_runtime.clone(),
@@ -66,6 +66,17 @@ impl ToolExecutor<ToolInvocation> for ExtensionToolAdapter {
{
Box::pin(async move {
let step = &invocation.step_context;
let _native_search = if invocation.tool_name.namespace.as_deref() == Some("web")
&& invocation.tool_name.name == "run"
{
crate::model_search::SearchOwner::reserve_native_fallback(
&step.extension_data,
&invocation.session.services.thread_extension_data,
)
.map_err(|err| codex_tools::FunctionCallError::RespondToModel(err.to_string()))?
} else {
None
};
let selected: Vec<_> = match &invocation.environments {
ToolEnvironments::Arguments(environments) => {
environments.iter().map(|(_, env)| env.as_ref()).collect()
+37 -6
View File
@@ -159,6 +159,7 @@ pub(crate) fn build_tool_router(
session.services.mcp_manager.shared_definitions(),
&mut registry,
);
let search = step_store.get::<crate::model_search::ModelSearch>();
apply_mcp_tool_exposure_policy(
turn_context,
model_info,
@@ -172,12 +173,40 @@ pub(crate) fn build_tool_router(
extension_tool_executors(session, step_store),
&mut registry,
);
let native_mode = match search.as_deref() {
None => Some(turn_context.config.web_search_mode.value()),
Some(crate::model_search::ModelSearch::Native { mode, .. }) => Some(*mode),
_ => None,
};
if matches!(
search.as_deref(),
Some(
crate::model_search::ModelSearch::Native { .. }
| crate::model_search::ModelSearch::Disabled
)
) {
for tool in mcp
.tools()
.iter()
.filter(|tool| codex_mcp::is_model_search_tool(tool))
{
registry.remove(&tool.canonical_tool_name());
}
}
if native_mode.is_none() {
registry.remove(&ToolName::namespaced("web", "run"));
}
append_dynamic_tool_runtimes(dynamic_tools, &mut registry);
let hosted_specs = hosted_model_tool_specs(
turn_context,
model_info,
standalone_web_search_tool.as_slice(),
);
let hosted_specs = if let Some(mode) = native_mode {
hosted_model_tool_specs(
turn_context,
model_info,
mode,
standalone_web_search_tool.as_slice(),
)
} else {
Vec::new()
};
finalize_tool_router(
turn_context,
@@ -324,6 +353,7 @@ pub(crate) fn append_source_tools(
hosted_model_tool_specs(
turn_context,
model_info,
turn_context.config.web_search_mode.value(),
standalone_web_search_tool.as_slice(),
)
}
@@ -647,6 +677,7 @@ fn spec_for_model_request(
fn hosted_model_tool_specs(
turn_context: &TurnContext,
model_info: &ModelInfo,
mode: WebSearchMode,
registered_extension_tool_names: &[ToolName],
) -> Vec<ToolSpec> {
// Responses Lite accepts schemas for client-executed tools, not hosted Responses tools.
@@ -661,7 +692,7 @@ fn hosted_model_tool_specs(
// and the provider supports hosted search. `None` prevents emitting a hosted search tool.
let web_search_mode = (!standalone_web_search_available
&& turn_context.provider.capabilities().web_search)
.then_some(turn_context.config.web_search_mode.value());
.then_some(mode);
let web_search_config = web_search_mode
.as_ref()
.and(turn_context.config.web_search_config.as_ref());
+9 -1
View File
@@ -1142,7 +1142,12 @@ async fn environment_tools_follow_the_step_context() {
/*tool_suggest_candidates*/ None,
/*wait_for_environment_tool_config*/ None,
),
super::hosted_model_tool_specs(turn.as_ref(), turn.model_info(), &[]),
super::hosted_model_tool_specs(
turn.as_ref(),
turn.model_info(),
turn.config.web_search_mode.value(),
&[],
),
&Default::default(),
));
@@ -2273,6 +2278,7 @@ async fn tool_search_cache_rebuilds_when_deferred_sources_change() {
super::hosted_model_tool_specs(
first_step_context.turn.as_ref(),
first_step_context.turn.model_info(),
first_step_context.turn.config.web_search_mode.value(),
&[],
),
&cache,
@@ -2307,6 +2313,7 @@ async fn tool_search_cache_rebuilds_when_deferred_sources_change() {
super::hosted_model_tool_specs(
second_step_context.turn.as_ref(),
second_step_context.turn.model_info(),
second_step_context.turn.config.web_search_mode.value(),
&[],
),
&cache,
@@ -2372,6 +2379,7 @@ async fn tool_search_cache_rebuilds_when_deferred_world_state_changes() {
super::hosted_model_tool_specs(
step_context.turn.as_ref(),
step_context.turn.model_info(),
step_context.turn.config.web_search_mode.value(),
&[],
),
&cache,