From 2f5e242d9f869b0553bf37e007b85b2cbd9b31ef Mon Sep 17 00:00:00 2001 From: swastik-oai Date: Tue, 20 Oct 2026 20:13:18 +0000 Subject: [PATCH] Omit reasoning and compaction from Decisions snapshot comparisons (#55666) ## Why Decisions snapshot comparisons reject requests containing reasoning or compaction, while conversation comparisons already omit those items. A parent compaction also causes snapshot comparisons to be rejected. ## What changed - Share filtering of reasoning, compaction, and context compaction across both comparison modes, and ignore parent compaction when building the Decisions request. - Distinguish malformed request setup from unsupported input items. Record setup errors as failures and unsupported input items as skipped comparisons. ## Testing Extend the HTTP contract test to cover both comparison modes with reasoning, compaction, context compaction, and parent compaction while preserving message content. Update assertions for the distinct setup and unsupported-input errors. GitOrigin-RevId: e3ba0cce3b6457b3e3eab3589352b70288cbb1ef --- .../guardian-v2/src/async_scorer/decisions.rs | 49 +++++++++---------- .../src/async_scorer/decisions_tests.rs | 46 ++++++----------- .../guardian-v2/src/async_scorer/metrics.rs | 5 +- 3 files changed, 39 insertions(+), 61 deletions(-) diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/decisions.rs b/codex-rs/ext/guardian-v2/src/async_scorer/decisions.rs index a6933979b2..9f5f666b2b 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/decisions.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/decisions.rs @@ -1,6 +1,7 @@ //! Bounded Decisions transport for the optional Guardian comparison classifier. //! Admission retains the newest requests and cancels the oldest unfinished request at capacity. -//! Retained comparisons omit reasoning and compaction; other unsupported evidence is rejected intact. +//! Comparisons omit reasoning and compaction. +//! Other unsupported evidence is rejected intact. //! Errors never contain credentials or wire bodies. //! Evidence preserves message roles, content order, and boundaries. //! The caller records measurements after baseline publication; dropping its task aborts Decisions work. @@ -41,8 +42,10 @@ pub(super) enum DecisionsError { Credentials, #[error("Decisions HTTP client setup failed")] ClientSetup, - #[error("Decisions cannot represent the complete classifier evidence")] - UnsupportedEvidence, + #[error("Decisions received unexpected classifier request setup")] + UnexpectedRequestSetup, + #[error("Decisions cannot represent this input item")] + UnsupportedInputItem, #[error("Decisions cannot represent this image evidence")] UnsupportedImage, #[error("Decisions cannot represent audio evidence")] @@ -126,7 +129,6 @@ impl DecisionsSampler { let body = request_body( request.instructions.annotated_content().content(), &request.input, - request.parent_compaction.as_ref(), ) .and_then(|body| { // Reuse Guardian's complete budget without changing its sampling path. @@ -156,36 +158,25 @@ impl DecisionsSampler { let started = Instant::now(); let body = (|| { let [tools, instructions, evidence @ ..] = input else { - return Err(DecisionsError::UnsupportedEvidence); + return Err(DecisionsError::UnexpectedRequestSetup); }; if !matches!( &tools.item, ResponseItem::AdditionalTools { role, tools, .. } if role == "developer" && tools.is_empty() ) { - return Err(DecisionsError::UnsupportedEvidence); + return Err(DecisionsError::UnexpectedRequestSetup); } let ResponseItem::Message { role, content, .. } = &instructions.item else { - return Err(DecisionsError::UnsupportedEvidence); + return Err(DecisionsError::UnexpectedRequestSetup); }; let [rubric] = content.as_slice() else { - return Err(DecisionsError::UnsupportedEvidence); + return Err(DecisionsError::UnexpectedRequestSetup); }; if role != "developer" { - return Err(DecisionsError::UnsupportedEvidence); + return Err(DecisionsError::UnexpectedRequestSetup); } - request_body( - rubric, - evidence.iter().map(|item| &item.item).filter(|item| { - !matches!( - item, - ResponseItem::Reasoning { .. } - | ResponseItem::Compaction { .. } - | ResponseItem::ContextCompaction { .. } - ) - }), - /*parent_compaction*/ None, - ) + request_body(rubric, evidence.iter().map(|item| &item.item)) })(); self.spawn_request(body, started) } @@ -263,17 +254,21 @@ impl DecisionsSampler { fn request_body<'a>( instructions: &ContentItem, evidence: impl IntoIterator, - parent_compaction: Option<&ResponseItem>, ) -> Result { - if parent_compaction.is_some() { - return Err(DecisionsError::UnsupportedEvidence); - } let ContentItem::InputText { text: rubric } = instructions else { - return Err(DecisionsError::UnsupportedEvidence); + return Err(DecisionsError::UnexpectedRequestSetup); }; let mut image_bytes = 0usize; let mut messages = Vec::new(); for item in evidence { + if matches!( + item, + ResponseItem::Reasoning { .. } + | ResponseItem::Compaction { .. } + | ResponseItem::ContextCompaction { .. } + ) { + continue; + } let ResponseItem::Message { role, content, @@ -281,7 +276,7 @@ fn request_body<'a>( .. } = item else { - return Err(DecisionsError::UnsupportedEvidence); + return Err(DecisionsError::UnsupportedInputItem); }; let mut parts = Vec::with_capacity(content.len()); for part in content { diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/decisions_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/decisions_tests.rs index ef9f1d7098..460edb10c6 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/decisions_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/decisions_tests.rs @@ -25,13 +25,6 @@ fn adapter_rejects_unsupported_evidence() { let rubric = ContentItem::InputText { text: "Classify risk.".into(), }; - let user: ResponseItem = serde_json::from_value(json!({"type":"message", "role":"user", "content":[ - {"type":"input_text", "text":"Authorized task"}, {"type":"input_text", "text":"Planned action"} - ]})).unwrap(); - assert_eq!( - request_body(&rubric, std::slice::from_ref(&user), Some(&user)), - Err(DecisionsError::UnsupportedEvidence) - ); for (role, content, expected_error) in [ ( "user", @@ -53,10 +46,7 @@ fn adapter_rejects_unsupported_evidence() { "type":"message", "role":role, "content":[content] })) .unwrap(); - assert_eq!( - request_body(&rubric, &[evidence], /*parent_compaction*/ None), - Err(expected_error) - ); + assert_eq!(request_body(&rubric, &[evidence]), Err(expected_error)); } } @@ -89,11 +79,7 @@ fn adapter_preserves_trusted_tool_authority_and_scope() { })) .unwrap(); assert_eq!( - request_body( - &rubric, - &[trusted, user, review_message, assistant], - /*parent_compaction*/ None - ), + request_body(&rubric, &[trusted, user, review_message, assistant]), Ok(json!({ "model": "gpt-6-luna", "input": [ @@ -216,11 +202,20 @@ async fn http_contract_and_untrusted_response_validation() { ContentItemMetadata::harness(), ), ); + request.parent_compaction = Some( + serde_json::from_value(json!({ + "type":"context_compaction", "encrypted_content":"parent compaction" + })) + .unwrap(), + ); request.input.extend( serde_json::from_value::>(json!([ {"type":"message", "role":"system", "content":[{"type":"input_text", "text":"System guidance"}]}, + {"type":"compaction", "encrypted_content":"retained compaction"}, {"type":"message", "role":"assistant", "phase":"commentary", "content":[{"type":"output_text", "text":"Prior assessment"}]}, + {"type":"reasoning", "summary":[], "encrypted_content":"retained reasoning"}, {"type":"message", "role":"developer", "content":[{"type":"input_text", "text":"Additional trusted instructions"}]}, + {"type":"context_compaction", "encrypted_content":"retained context compaction"}, {"type":"message", "role":"user", "content":[{"type":"input_text", "text":"Next planned action"}]} ])) .unwrap(), @@ -251,6 +246,7 @@ async fn http_contract_and_untrusted_response_validation() { ) .unwrap(); let mut sampler = Arc::new(sampler); + // Both modes omit reasoning and compaction while preserving the messages. let task = sampler.start_snapshot_comparison(&request, /*max_input_tokens*/ 128_000); assert_eq!(task.finish().await.unwrap().0, Ok("low")); let (mut retained, _) = LunaSampler::prepare_input( @@ -263,31 +259,19 @@ async fn http_contract_and_untrusted_response_validation() { .map(ResponseItemEnvelope::new) .collect(), ); - retained.splice( - 4..4, - serde_json::from_value::>(json!([ - {"type":"reasoning", "summary":[], "encrypted_content":"retained reasoning"}, - {"type":"compaction", "encrypted_content":"retained compaction"}, - {"type":"context_compaction", "encrypted_content":"retained context compaction"} - ])) - .unwrap() - .into_iter() - .map(ResponseItemEnvelope::new), - ); - // Only the three unsupported retained item types are omitted from the wire body. let task = sampler.start_conversation_comparison(&retained); assert_eq!(task.finish().await.unwrap().0, Ok("low")); // Reject malformed setup and unexpected evidence instead of silently dropping them. let task = sampler.start_conversation_comparison(&retained[1..]); assert_eq!( task.finish().await.unwrap().0, - Err(DecisionsError::UnsupportedEvidence) + Err(DecisionsError::UnexpectedRequestSetup) ); retained.push(ResponseItemEnvelope::new(ResponseItem::Other)); let task = sampler.start_conversation_comparison(&retained); assert_eq!( task.finish().await.unwrap().0, - Err(DecisionsError::UnsupportedEvidence) + Err(DecisionsError::UnsupportedInputItem) ); server.verify().await; let mut invalid = response; @@ -354,7 +338,6 @@ fn adapter_preserves_inline_images_and_bounds_image_bytes() { let body = request_body( request.instructions.annotated_content().content(), &request.input, - /*parent_compaction*/ None, ) .unwrap(); assert_eq!( @@ -374,7 +357,6 @@ fn adapter_preserves_inline_images_and_bounds_image_bytes() { request_body( request.instructions.annotated_content().content(), &request.input, - /*parent_compaction*/ None ), Err(DecisionsError::InputTooLarge) ); diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/metrics.rs b/codex-rs/ext/guardian-v2/src/async_scorer/metrics.rs index eefcdd12f7..3b7b12c02a 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/metrics.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/metrics.rs @@ -201,7 +201,8 @@ pub(super) fn decisions_failure_reason(error: DecisionsError) -> &'static str { match error { DecisionsError::Credentials => "credentials_unavailable", DecisionsError::ClientSetup => "request_build_error", - DecisionsError::UnsupportedEvidence => "decisions_unsupported_evidence", + DecisionsError::UnexpectedRequestSetup => "unexpected_request_setup", + DecisionsError::UnsupportedInputItem => "unsupported_input_item", DecisionsError::UnsupportedImage => "image", DecisionsError::UnsupportedAudio => "audio", DecisionsError::InputTooLarge => "input_too_large", @@ -232,7 +233,7 @@ pub(super) fn record_decisions_comparison( let outcome = match &result { Ok(_) => "success", Err( - DecisionsError::UnsupportedEvidence + DecisionsError::UnsupportedInputItem | DecisionsError::UnsupportedImage | DecisionsError::UnsupportedAudio | DecisionsError::InputTooLarge,