From 9abfbcea0d5b6e37b14cb32d5c42338872fdd10a Mon Sep 17 00:00:00 2001 From: jif Date: Thu, 5 Nov 2026 20:42:33 +0000 Subject: [PATCH] Restrict Guardian native multi-agent history to the OpenAI provider (#59737) ## What changed Require the `openai` provider ID as well as `GuardianNativeMultiAgentHistory` to enable native agent history and review of multi-agent calls. Apply the same check to Guardian prompt construction, collaboration tool registration, and Guardian v2 configuration. ## Testing Add coverage for fresh and resumed sessions with the feature enabled and disabled on OpenAI, and enabled on Bedrock and LiteLLM. Verify that only OpenAI sessions with the feature enabled request native review and include the agent call as evidence. GitOrigin-RevId: bfbe547b0544e66763dca7d1e1376f805994bcb2 --- codex-rs/core/src/config/mod.rs | 9 ++ codex-rs/core/src/guardian/prompt.rs | 5 +- codex-rs/core/src/tools/spec_plan.rs | 3 +- ...scenarios_guardian_agent_messages_tests.rs | 98 ++++++++++++++++++- .../guardian-v2/src/async_scorer/config.rs | 4 +- 5 files changed, 109 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 897a07cc00..56aeec9903 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -97,6 +97,7 @@ use codex_model_provider_info::GCP_PROVIDER_ID; use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR; +use codex_model_provider_info::OPENAI_PROVIDER_ID; use codex_model_provider_info::built_in_model_providers; use codex_model_provider_info::merge_configured_model_providers; use codex_models_manager::ModelsManagerConfig; @@ -1711,6 +1712,14 @@ impl Config { .unwrap_or(model_messages.auto_review().policy) } + /// Native Guardian agent history requires the reviewed thread's OpenAI provider. + pub fn guardian_native_multi_agent_history_enabled(&self) -> bool { + self.model_provider_id == OPENAI_PROVIDER_ID + && self + .features + .enabled(Feature::GuardianNativeMultiAgentHistory) + } + pub(crate) fn multi_agent_version_override(&self) -> Option { if self.features.enabled(Feature::MultiAgentV2) { Some(MultiAgentVersion::V2) diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index a4afaae26a..b05088d7ab 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -1,5 +1,4 @@ use codex_extension_api::ConversationHistorySnapshot; -use codex_features::Feature; use codex_guardian_context::CollectedContext; use codex_guardian_context::ContextProfile; use codex_guardian_context::GuardianRootMessage; @@ -145,9 +144,7 @@ pub(super) async fn collect_guardian_prompt_items_with_parent_turn( profile.transcript.options.native_multi_agent_namespace = parent_context .is_some_and(|context| { let turn = context.turn(); - turn.config - .features - .enabled(Feature::GuardianNativeMultiAgentHistory) + turn.config.guardian_native_multi_agent_history_enabled() && turn.multi_agent_version == codex_protocol::protocol::MultiAgentVersion::V2 }) .then(|| { diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 56a716bc04..6274bc6fb7 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -1500,8 +1500,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, registry: &mut Too let tool_namespace = turn_context.config.multi_agent_v2.tool_namespace.as_deref(); let native_review = if turn_context .config - .features - .enabled(Feature::GuardianNativeMultiAgentHistory) + .guardian_native_multi_agent_history_enabled() { NativeReview::Required } else { diff --git a/codex-rs/core/tests/suite/scenarios_guardian_agent_messages_tests.rs b/codex-rs/core/tests/suite/scenarios_guardian_agent_messages_tests.rs index 61f2ef6d24..514b914504 100644 --- a/codex-rs/core/tests/suite/scenarios_guardian_agent_messages_tests.rs +++ b/codex-rs/core/tests/suite/scenarios_guardian_agent_messages_tests.rs @@ -1,4 +1,4 @@ -//! Guardian retains native encrypted messages and historical calls across incremental reviews. +//! OpenAI-only native agent review preserves encrypted history across reviews and resumes. use codex_core::config::Constrained; use codex_features::Feature; @@ -21,6 +21,102 @@ use pretty_assertions::assert_eq; use serde_json::json; use test_case::test_case; +#[test_case("openai", true, true; "openai_treatment")] +#[test_case("openai", false, false; "openai_control")] +#[test_case("amazon-bedrock-runtime", true, false; "bedrock")] +#[test_case("litellm-hybrid", true, false; "litellm")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn native_agent_review_requires_openai_on_start_and_resume( + provider_id: &'static str, + requested: bool, + expected_review: bool, +) -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let server = responses::start_mock_server().await; + let mut previous = None; + for phase in ["fresh", "resumed"] { + let mut builder = test_codex() + .with_model("gpt-5.5") + .with_config(move |config| { + super::configure_scenario_catalog(config); + config.model_provider_id = provider_id.to_owned(); + // Keep the mock Responses transport and OpenAI display name for every provider ID. + config + .model_providers + .insert(provider_id.to_owned(), config.model_provider.clone()); + for feature in [Feature::Collab, Feature::MultiAgentV2] { + config + .features + .enable(feature) + .expect("enable multi-agent feature"); + } + config + .features + .set_enabled(Feature::GuardianNativeMultiAgentHistory, requested) + .expect("set native agent review flag"); + config.multi_agent_v2.tool_namespace = Some("collaboration".to_owned()); + config.approvals_reviewer = ApprovalsReviewer::AutoReview; + config.permissions.approval_policy = + Constrained::allow_any(AskForApproval::OnRequest); + config + .permissions + .set_permission_profile(PermissionProfile::read_only()) + .expect("set read-only permissions"); + }); + let test = if let Some(previous) = &previous { + builder.restart_with_auto_env(&server, previous).await? + } else { + builder.build_with_auto_env(&server).await? + }; + let call_id = format!("send-{phase}"); + let mut call = responses::ev_function_call_with_namespace( + &call_id, + "collaboration", + "send_message", + r#"{"target":"/root/missing","message":"opaque-message"}"#, + ); + call["item"]["encrypted_content"] = json!("opaque-replay"); + call["item"]["encrypted_function_args"] = json!(["message"]); + let mut stream = vec![responses::sse(vec![call, responses::ev_completed("send")])]; + if expected_review { + stream.push(responses::sse(vec![ + responses::ev_assistant_message("assessment", r#"{"outcome":"allow","rationale":"Mock approval.","risk_level":"low","user_authorization":"high"}"#), + responses::ev_completed("review"), + ])); + } + stream.push(responses::sse(vec![ + responses::ev_assistant_message("done", "The worker is unavailable."), + responses::ev_completed("done"), + ])); + let mock = responses::mount_sse_sequence(&server, stream).await; + test.submit_text_turn("Ask the missing worker to confirm receipt.") + .await?; + let requests = mock.requests(); + let reviews = requests + .iter() + .filter(|request| { + request.body_json()["client_metadata"]["x-openai-subagent"] == "guardian" + }) + .collect::>(); + assert_eq!( + reviews.len(), + usize::from(expected_review), + "{phase}: {provider_id}" + ); + if let Some(review) = reviews.first() { + assert!( + review + .inputs_of_type("function_call") + .iter() + .any(|call| call["call_id"] == call_id), + "{phase}: native review evidence" + ); + } + previous = Some(test); + } + Ok(()) +} + #[cfg_attr(not(target_os = "windows"), test_case(None; "agent_reply"))] #[test_case(Some("functions"); "native_default_namespace")] #[test_case(Some("team"); "native_custom_namespace")] diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/config.rs b/codex-rs/ext/guardian-v2/src/async_scorer/config.rs index 6cc765638c..0d3454eade 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/config.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/config.rs @@ -80,9 +80,7 @@ impl GuardianV2Config { ..configured }); resolved.policy = GuardianPolicyLoader::new(Some(&legacy)); - resolved.review_multi_agent_calls = config - .features - .enabled(codex_features::Feature::GuardianNativeMultiAgentHistory); + resolved.review_multi_agent_calls = config.guardian_native_multi_agent_history_enabled(); resolved.multi_agent_namespace = config.multi_agent_v2.tool_namespace.clone(); Ok(resolved) }