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
This commit is contained in:
ioanbudea-OAI
2026-10-23 18:41:23 +00:00
committed by copyberry
parent 15fbcac045
commit 5deceaa927
4 changed files with 132 additions and 36 deletions
@@ -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()
+47 -24
View File
@@ -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;
+18 -7
View File
@@ -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)
}
@@ -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());
}
}
}