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,