mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user