From 5deceaa92732d1e4a400b93854e369e33f7e4bee Mon Sep 17 00:00:00 2001 From: ioanbudea-OAI Date: Fri, 23 Oct 2026 18:18:18 +0000 Subject: [PATCH] Apply sync Guardian message limits during transcript preparation (#56555) ## What changed Defer truncation of synchronous Guardian assistant and reasoning entries until `ContextProfile::prepare_transcript`, so the resolved profile's message limit applies before rendering and retention accounting. Keep encrypted agent messages whole when they fit, or replace them with an omission notice at the same transcript position when they exceed the limit. ## Testing Extend coverage to verify that oversized-action reviews retain assistant commentary and final-answer evidence up to the synchronous message cap, and that encrypted-message omission produces equivalent output through synchronous transcript collection. GitOrigin-RevId: e17fbfa7b7131559b0599ac1d57987a8d94e137c --- .../tests/suite/guardian_context_budget.rs | 37 ++++++++-- codex-rs/guardian-context/src/profile.rs | 71 ++++++++++++------- codex-rs/guardian-context/src/transcript.rs | 25 +++++-- .../guardian-context/src/transcript_tests.rs | 35 +++++++++ 4 files changed, 132 insertions(+), 36 deletions(-) diff --git a/codex-rs/core/tests/suite/guardian_context_budget.rs b/codex-rs/core/tests/suite/guardian_context_budget.rs index d424350b19..d966d37b92 100644 --- a/codex-rs/core/tests/suite/guardian_context_budget.rs +++ b/codex-rs/core/tests/suite/guardian_context_budget.rs @@ -697,11 +697,26 @@ async fn oversized_action_preserves_review_policy_and_next_review( "justification": "Run the requested command.", }) .to_string(); + let assistant_evidence = [ + "commentary evidence ".repeat(/*n*/ 1_300), + "final evidence ".repeat(/*n*/ 1_600), + ]; + let mut parent_events = Vec::new(); + if matches!(review, OversizedActionReview::Fits) { + let mut commentary = ev_assistant_message("history-commentary", &assistant_evidence[0]); + commentary["item"]["phase"] = json!("commentary"); + parent_events.push(commentary); + parent_events.push(ev_assistant_message( + "history-final", + &assistant_evidence[1], + )); + } + parent_events.extend([ + ev_function_call("oversized", "exec_command", &oversized), + ev_completed("oversized"), + ]); let mut events = vec![ - sse(vec![ - ev_function_call("oversized", "exec_command", &oversized), - ev_completed("oversized"), - ]), + sse(parent_events), sse(vec![ ev_function_call("next", "exec_command", &next), ev_completed("next"), @@ -799,8 +814,20 @@ async fn oversized_action_preserves_review_policy_and_next_review( assert_eq!(reviews.len(), if fits { 2 } else { 1 }); if fits { let parts = reviews[0].message_input_texts("user"); - assert!(parts.concat().contains(&oversized_command)); + let context = parts.concat(); + assert!(context.contains(&oversized_command)); assert!(parts.iter().all(|part| part.len() <= 36_000)); + let transcript = context + .split_once(">>> TRANSCRIPT START\n") + .expect("full transcript") + .1 + .split_once(">>> TRANSCRIPT END\n") + .expect("full transcript end") + .0; + for text in &assistant_evidence { + let capped = codex_guardian_context::truncate_text(text, /*max_tokens*/ 5_000); + assert!(transcript.contains(&format!("assistant: {capped}\n"))); + } } let context = reviews .last() diff --git a/codex-rs/guardian-context/src/profile.rs b/codex-rs/guardian-context/src/profile.rs index 504404ee5b..b0364d0595 100644 --- a/codex-rs/guardian-context/src/profile.rs +++ b/codex-rs/guardian-context/src/profile.rs @@ -148,40 +148,25 @@ impl ContextProfile { .as_ref() .filter(|_| entry.kind == ConversationTranscriptEntryKind::User); let prepare_text = |text: &str| { - let text = match self.target { - ContextTarget::Sync => match &entry.kind { - ConversationTranscriptEntryKind::ToolCall(_) - | ConversationTranscriptEntryKind::ToolOutput(_) => { - std::borrow::Cow::Owned(crate::truncate_text( - text, - self.transcript.entry_limits.tool_tokens, - )) - } - ConversationTranscriptEntryKind::User - | ConversationTranscriptEntryKind::Developer - | ConversationTranscriptEntryKind::Assistant - | ConversationTranscriptEntryKind::ProtectedAssistant - | ConversationTranscriptEntryKind::Reasoning - | ConversationTranscriptEntryKind::NodeReplToolOutput(_) => { - std::borrow::Cow::Borrowed(text) - } - }, - ContextTarget::Async => std::borrow::Cow::Borrowed(text), - }; - let retained_bytes = text.len(); let record = TranscriptRecord::new( &entry.kind, number, - text.into_owned(), + text.to_owned(), retained_source.map(|retained| retained.order.clone()), self.transcript_format, suffix, ); let tokens = TruncationPolicy::Bytes(record.rendered().text_bytes).token_budget(); - (TranscriptContent::Record(record), tokens, retained_bytes) + (TranscriptContent::Record(record), tokens, text.len()) }; - let (content, tokens, retained_bytes) = match &entry.content { + let content = match self.target { + ContextTarget::Sync => { + prepare_sync_content(entry, &self.transcript.entry_limits) + } + ContextTarget::Async => std::borrow::Cow::Borrowed(&entry.content), + }; + let (content, tokens, retained_bytes) = match content.as_ref() { TranscriptContent::Text(text) => prepare_text(text), TranscriptContent::Record(record) => prepare_text(record.text()), TranscriptContent::AgentMessage(message) => ( @@ -321,6 +306,44 @@ impl ContextProfile { } } +/// Bounds sync content before rendering or calculating retention costs. +fn prepare_sync_content<'a>( + entry: &'a crate::ConversationTranscriptEntry, + limits: &crate::TranscriptEntryLimits, +) -> std::borrow::Cow<'a, TranscriptContent> { + let text = match &entry.content { + TranscriptContent::Text(text) => text.as_str(), + TranscriptContent::Record(record) => record.text(), + TranscriptContent::AgentMessage(_) => { + // Native ciphertext stays whole, or leaves an omission at the same position. + let limit = codex_protocol::protocol::TruncationPolicy::Tokens( + limits.message_tokens.min(/*other*/ 9_000), + ) + .byte_budget(); + return if entry.original_bytes > limit { + std::borrow::Cow::Owned(TranscriptContent::Text( + crate::transcript::TRANSCRIPT_OMISSION_NOTICE.to_owned(), + )) + } else { + std::borrow::Cow::Borrowed(&entry.content) + }; + } + }; + let tokens = match &entry.kind { + ConversationTranscriptEntryKind::ToolCall(_) + | ConversationTranscriptEntryKind::ToolOutput(_) => limits.tool_tokens, + ConversationTranscriptEntryKind::Assistant + | ConversationTranscriptEntryKind::ProtectedAssistant + | ConversationTranscriptEntryKind::Reasoning => limits.message_tokens, + ConversationTranscriptEntryKind::User + | ConversationTranscriptEntryKind::Developer + | ConversationTranscriptEntryKind::NodeReplToolOutput(_) => { + return std::borrow::Cow::Borrowed(&entry.content); + } + }; + std::borrow::Cow::Owned(TranscriptContent::Text(crate::truncate_text(text, tokens))) +} + #[cfg(test)] #[path = "profile_tests.rs"] mod tests; diff --git a/codex-rs/guardian-context/src/transcript.rs b/codex-rs/guardian-context/src/transcript.rs index 0d59a14c17..d146be7673 100644 --- a/codex-rs/guardian-context/src/transcript.rs +++ b/codex-rs/guardian-context/src/transcript.rs @@ -2,7 +2,7 @@ //! //! Both Guardian consumers receive the same role and tool-source attribution, //! with generated compaction summaries attributed to the assistant, complete user -//! messages and capped non-user entries, except sync tool calls and outputs prepared later. Resolved context profiles +//! messages and capped non-user entries, except sync tool and assistant entries prepared later. Resolved context profiles //! apply aggregate retention after the host selects its full/delta slice. Tool outputs with a //! call ID retain their generic label when the call is unavailable. Outputs //! without a call ID require an explicit name. @@ -213,14 +213,18 @@ fn collect_transcript_for_target( // an explicit omission at the same cursor position. Serialized bytes // conservatively charge the encrypted payload to the message budget. let bytes = serde_json::to_vec(item).map_or(usize::MAX, |item| item.len()); - let limit = codex_protocol::protocol::TruncationPolicy::Tokens( - config.entry_limits.message_tokens.min(9_000), - ) - .byte_budget(); - let content = if bytes <= limit { + let content = if target == crate::ContextTarget::Sync { TranscriptContent::AgentMessage(Box::new(item.clone())) } else { - TranscriptContent::Text(TRANSCRIPT_OMISSION_NOTICE.to_owned()) + let limit = codex_protocol::protocol::TruncationPolicy::Tokens( + config.entry_limits.message_tokens.min(9_000), + ) + .byte_budget(); + if bytes <= limit { + TranscriptContent::AgentMessage(Box::new(item.clone())) + } else { + TranscriptContent::Text(TRANSCRIPT_OMISSION_NOTICE.to_owned()) + } }; entries.push(ConversationTranscriptEntry { kind: ConversationTranscriptEntryKind::Assistant, @@ -407,6 +411,13 @@ fn collect_transcript_for_target( } ConversationTranscriptEntryKind::Assistant | ConversationTranscriptEntryKind::ProtectedAssistant + | ConversationTranscriptEntryKind::Reasoning + if target == crate::ContextTarget::Sync => + { + text + } + ConversationTranscriptEntryKind::Assistant + | ConversationTranscriptEntryKind::ProtectedAssistant | ConversationTranscriptEntryKind::Reasoning => { truncate_text(&text, config.entry_limits.message_tokens) } diff --git a/codex-rs/guardian-context/src/transcript_tests.rs b/codex-rs/guardian-context/src/transcript_tests.rs index 7ac7fa0edc..89e1f9edb2 100644 --- a/codex-rs/guardian-context/src/transcript_tests.rs +++ b/codex-rs/guardian-context/src/transcript_tests.rs @@ -622,5 +622,40 @@ fn encrypted_messages_preserve_order_and_budget_for_both_reviewers() { assert!( matches!(&entries[1].content, crate::TranscriptContent::Text(text) if text.contains("omitted")) ); + if profile.target == ContextTarget::Sync { + let mut limited_profile = profile; + limited_profile.transcript = limited; + let raw = super::collect_transcript_for_target(&history, &limited, ContextTarget::Sync); + let actual = { + let context = crate::CollectedContext { + sections: vec![ContextSection::ConversationTranscript { items: raw }], + }; + let profile = &limited_profile; + let transcript = profile.prepare_transcript( + context.transcript_entries(), + /*entry_number_offset*/ 0, + ); + context.compose( + ContextPresentation::SyncFull { + session_id: "worker", + }, + transcript, + ) + } + .unwrap(); + let expected = crate::CollectedContext { + sections: vec![ContextSection::ConversationTranscript { + items: entries.clone(), + }], + } + .compose( + ContextPresentation::SyncFull { + session_id: "worker", + }, + limited_profile.prepare_transcript(&entries, /*entry_number_offset*/ 0), + ) + .unwrap(); + assert_eq!(actual.into_messages(), expected.into_messages()); + } } }