diff --git a/codex-rs/guardian-context/src/authorization.rs b/codex-rs/guardian-context/src/authorization.rs index 0f09881f65..c66883ecda 100644 --- a/codex-rs/guardian-context/src/authorization.rs +++ b/codex-rs/guardian-context/src/authorization.rs @@ -121,8 +121,11 @@ pub(crate) fn root_conversation_items( | GuardianRootMessage::RetainedContextScope | GuardianRootMessage::LegacyContextScope => true, }; + let verified_answer = matches!(message, GuardianRootMessage::UserInput(_)); let text = message.render(); - let item = if protected { + let item = if verified_answer { + crate::Budgeted::verified_answer(text) + } else if protected { crate::Budgeted::protected(text) } else { crate::Budgeted::required(text) diff --git a/codex-rs/guardian-context/src/composition.rs b/codex-rs/guardian-context/src/composition.rs index 6638e9f2d3..1820dc3cbc 100644 --- a/codex-rs/guardian-context/src/composition.rs +++ b/codex-rs/guardian-context/src/composition.rs @@ -336,6 +336,7 @@ impl CollectedContext { items .into_iter() .map(|item| Budgeted { + verified_answer: false, protected: matches!( item.kind, RetainedInstructionKind::Framing @@ -364,10 +365,12 @@ impl CollectedContext { items .into_iter() .map(|item| { - let mut output = - Budgeted::required(ContentItem::InputText { text: item.text }); - output.protected = item.protected; - output + let content = ContentItem::InputText { text: item.text }; + if item.protected { + Budgeted::protected(content) + } else { + Budgeted::verified_answer(content) + } }) .collect(), ), @@ -396,6 +399,7 @@ impl CollectedContext { id: "conversation_transcript", delivery: SectionDelivery::Message(Budgeted { protected: entry.protected, + verified_answer: entry.verified_answer, content: Box::new(ResponseItemEnvelope::new(*message)), retention: entry.retention, source: entry.source, @@ -426,6 +430,7 @@ impl CollectedContext { }; items.push(Budgeted { protected: entry.protected, + verified_answer: entry.verified_answer, content, retention: entry.retention, source: entry.source, @@ -499,6 +504,7 @@ impl CollectedContext { let Budgeted { content, protected, + verified_answer, source, retention, } = item; @@ -524,6 +530,7 @@ impl CollectedContext { Some(Ok(Budgeted { content, protected, + verified_answer, source, retention, })) diff --git a/codex-rs/guardian-context/src/enforcement.rs b/codex-rs/guardian-context/src/enforcement.rs index f9a3091235..9ccff54580 100644 --- a/codex-rs/guardian-context/src/enforcement.rs +++ b/codex-rs/guardian-context/src/enforcement.rs @@ -45,6 +45,8 @@ pub struct Budgeted { pub content: T, /// Host framing and exact actions use global space, outside section quotas. pub(crate) protected: bool, + /// Host-verified question/answer records must be kept or omitted whole. + pub(crate) verified_answer: bool, pub(crate) source: Option, pub(crate) retention: Retention, } @@ -54,6 +56,7 @@ impl Budgeted { Self { content, protected: false, + verified_answer: false, source: None, retention: Retention::Required, } @@ -66,10 +69,18 @@ impl Budgeted { } } + pub(crate) fn verified_answer(content: T) -> Self { + Self { + verified_answer: true, + ..Self::required(content) + } + } + pub(crate) fn map(self, transform: impl FnOnce(T) -> U) -> Budgeted { Budgeted { content: transform(self.content), protected: self.protected, + verified_answer: self.verified_answer, source: self.source, retention: self.retention, } @@ -79,6 +90,7 @@ impl Budgeted { Self { content, protected: false, + verified_answer: false, source: None, retention: Retention::Historical, } @@ -88,6 +100,7 @@ impl Budgeted { Self { content, protected: false, + verified_answer: false, source: None, retention: Retention::Optional(priority), } diff --git a/codex-rs/guardian-context/src/preparation/elastic.rs b/codex-rs/guardian-context/src/preparation/elastic.rs index f73e4be614..a23eb36bce 100644 --- a/codex-rs/guardian-context/src/preparation/elastic.rs +++ b/codex-rs/guardian-context/src/preparation/elastic.rs @@ -1,6 +1,7 @@ //! Applies configured section text caps exactly once before allocation. //! Supporting records remain candidates after truncation. Framing, exact actions, -//! images and opaque native payloads stay whole. Selection cannot change prepared bytes. +//! images and opaque native payloads stay whole. Verified answers that exceed their +//! entry cap are omitted whole. Selection cannot change prepared bytes. use crate::ComposedContext; use crate::ElasticBudgetConfig; @@ -22,7 +23,7 @@ impl ComposedContext { /// can only select whole entries, so later grants cannot change their text. /// Omitted groups inherit the same defaults used by allocation. /// The existing minimum marker/framing is retained when the cap cannot hold it. - /// Entry preparation never selects items; allocation selects whole supporting records. + /// Oversized verified answers leave an omission notice; allocation selects the remaining records. pub fn prepare_elastic_entries( mut self, config: &ElasticBudgetConfig, @@ -67,6 +68,18 @@ impl ComposedContext { continue; } let original_bytes = item.content.evidence_bytes(); + if item.verified_answer { + self.truncations.push(TruncationObservation { + source: item.source.clone(), + component: section.id, + original_bytes, + retained_bytes: 0, + }); + *item = crate::Budgeted::protected(SectionContent::from( + crate::GuardianRootMessage::IncompleteVerifiedAnswers.render(), + )); + continue; + } truncate_content_to_fit( &mut item.content, maximum.saturating_sub(content_framing_tokens(/*item_count*/ 1)), diff --git a/codex-rs/guardian-context/tests/cache_prefix.rs b/codex-rs/guardian-context/tests/cache_prefix.rs index eb4a88e9ab..dd244ac0a9 100644 --- a/codex-rs/guardian-context/tests/cache_prefix.rs +++ b/codex-rs/guardian-context/tests/cache_prefix.rs @@ -1,23 +1,35 @@ -//! Exercises cache-prefix stability through the public collection and composition API. +//! Exercises cache-prefix and evidence integrity through public collection and composition. use codex_context_fragments::ContextualUserFragment; use codex_guardian_context::ActionPresentation; +use codex_guardian_context::ContextBudgeting; use codex_guardian_context::ContextPresentation; use codex_guardian_context::ContextProfile; use codex_guardian_context::ContextTarget; +use codex_guardian_context::ElasticBudgetConfig; +use codex_guardian_context::ElasticSection; +use codex_guardian_context::ElasticSectionLimits; +use codex_guardian_context::GuardianRootMessage; use codex_guardian_context::PlannedAction; use codex_guardian_context::PlannedActionKind; use codex_guardian_context::PreviousReviews; +use codex_guardian_context::RequestBudget; use codex_guardian_context::SectionHistory; use codex_guardian_context::SectionInput; use codex_guardian_context::TranscriptFormat; use codex_guardian_context::TrustedSkills; use codex_guardian_context::TrustedTool; use codex_guardian_context::TrustedToolSource; +use codex_guardian_context::collect_verified_answer_candidates; use codex_guardian_context::default_registry; +use codex_guardian_context::prepare_sync_context; +use codex_guardian_context::render_original_verified_answer; use codex_history::RetainedContext; +use codex_history::RetainedContextEvent; use codex_history::RetainedInputSource; use codex_history::RetainedUserMessage; +use codex_history::VerifiedAnswer; +use codex_history::VerifiedQuestionAnswer; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::user_input::UserInput; @@ -202,3 +214,127 @@ fn changing_retained_context_and_attestations_preserves_history_before_the_curre } } } + +#[test] +fn elastic_verified_answers_preserve_question_answer_identity() { + let answer = VerifiedAnswer { + turn_id: "turn".to_owned(), + call_id: "confirm".to_owned(), + questions: vec![ + VerifiedQuestionAnswer { + question: format!( + "May I delete the production database? {}", + "A".repeat(2_500) + ), + answer: "No.".to_owned(), + }, + VerifiedQuestionAnswer { + question: format!("May I list local files instead? {}", "B".repeat(2_500)), + answer: "Yes.".to_owned(), + }, + ], + }; + let original = render_original_verified_answer(&answer); + let mut retained = RetainedContext::default(); + retained.record( + &RetainedContextEvent::VerifiedAnswer { + answer, + acceptance_order: Some(7), + }, + &uuid_v7_item_id_generator_for_test(), + ); + let trusted_answers = collect_verified_answer_candidates(&retained); + let root = [GuardianRootMessage::UserInput(original.clone())]; + // The retained record must stand on its own after the raw transcript is compacted. + let history = History { + items: Vec::new(), + retained: Some(retained), + }; + let profile = ContextProfile::synchronous(); + for entry_tokens in [900, 1_800] { + let collected = default_registry() + .prepare(&SectionInput { + target: ContextTarget::Sync, + history: &history, + transcript: &profile.transcript, + root_conversation: &root, + root_conversation_candidates: None, + trusted_user_answers: &trusted_answers, + planned_action: None, + permissions: None, + previous_reviews: None, + trusted_tool: None, + trusted_skill_paths: &[], + images: None, + node_repl: None, + }) + .unwrap(); + let limits = ElasticSectionLimits::new( + /*min_total_section_tokens*/ entry_tokens, + /*max_section_entry_tokens*/ entry_tokens, + ) + .unwrap(); + let config = ElasticBudgetConfig { + sections: [ + (ElasticSection::RootConversationMessages, limits), + (ElasticSection::TrustedUserAnswers, limits), + ( + ElasticSection::TranscriptMessages, + // The other section defaults total 9,100 tokens; minimums must sum to 128K. + ElasticSectionLimits::new( + /*min_total_section_tokens*/ 118_900 - 2 * entry_tokens, + /*max_section_entry_tokens*/ 5_000, + ) + .unwrap(), + ), + ] + .into(), + }; + let messages = prepare_sync_context( + collected, + &profile, + /*entry_number_offset*/ 0, + ContextPresentation::SyncFull { + session_id: "worker", + }, + ContextBudgeting::Elastic, + ) + .unwrap() + .prepare_elastic_entries(&config) + .unwrap() + .enforce_budget( + RequestBudget { + max_input_tokens: 128_000, + existing_context_tokens: 0, + }, + "Evidence omitted.".to_owned(), + ) + .unwrap() + .into_messages(); + let omission = GuardianRootMessage::IncompleteVerifiedAnswers.render(); + let evidence = messages + .into_iter() + .flat_map(|message| match message { + ResponseItem::Message { content, .. } => content, + _ => panic!("text evidence"), + }) + .filter_map(|item| match item { + ContentItem::InputText { text } + if text.contains("assistant:") || text == omission => + { + Some(text) + } + _ => None, + }) + .collect::>(); + let expected = if entry_tokens == 900 { + vec![omission.clone(), omission] + } else { + vec![ + original.clone(), + format!("Retained source order: 7\n{original}"), + ] + }; + assert_eq!(evidence, expected); + } +}