Share Guardian user-message retention logic (#42031)

## What changed

- Add a shared `select_user_messages` helper in `guardian-context` that always
  anchors the first user message and fills the remaining token budget from
  newest to oldest.
- Use the helper for both core Guardian prompt rendering and the Guardian V2
  async scorer so they apply the same retention policy.

## Testing

- Cover empty and single-message transcripts, over-budget anchors, and
  newest-first selection across multiple token budgets.

GitOrigin-RevId: 7db246013875263ceb5806d2a6a19b4e2a3db1c4
This commit is contained in:
felixxia-oai
2026-09-01 10:40:33 +00:00
committed by copyberry
parent 2b7c279735
commit 8209978616
5 changed files with 129 additions and 60 deletions
+14 -32
View File
@@ -398,45 +398,27 @@ fn render_guardian_transcript_entries_with_offset(
.collect::<Vec<_>>();
let mut included = vec![false; entries.len()];
let mut message_tokens = 0usize;
let mut tool_tokens = 0usize;
let user_indices = entries
let user_messages = entries
.iter()
.enumerate()
.filter_map(|(index, entry)| {
matches!(entry.kind, ConversationTranscriptEntryKind::User).then_some(index)
matches!(entry.kind, ConversationTranscriptEntryKind::User).then_some(
codex_guardian_context::UserMessageCost {
index,
tokens: rendered_entries[index].1,
},
)
})
.collect::<Vec<_>>();
if let Some(&first_user_index) = user_indices.first() {
included[first_user_index] = true;
message_tokens += rendered_entries[first_user_index].1;
}
if let Some(&last_user_index) = user_indices.last()
&& !included[last_user_index]
&& message_tokens + rendered_entries[last_user_index].1
<= GUARDIAN_TRANSCRIPT_RETENTION.max_message_transcript_tokens
{
included[last_user_index] = true;
message_tokens += rendered_entries[last_user_index].1;
}
for &index in user_indices.iter().rev() {
if included[index] {
continue;
}
let token_count = rendered_entries[index].1;
if message_tokens + token_count
> GUARDIAN_TRANSCRIPT_RETENTION.max_message_transcript_tokens
{
continue;
}
let selection = codex_guardian_context::select_user_messages(
&user_messages,
GUARDIAN_TRANSCRIPT_RETENTION.max_message_transcript_tokens,
);
for index in selection.indices {
included[index] = true;
message_tokens += token_count;
}
let mut message_tokens = selection.tokens;
let mut tool_tokens = 0usize;
let mut retained_non_user_entries = 0usize;
for index in (0..entries.len()).rev() {
@@ -225,41 +225,28 @@ impl TranscriptConfig {
}
let mut included = vec![false; entries.len()];
let mut message_tokens = 0;
let user_indices = entries
let user_messages = entries
.iter()
.enumerate()
.filter_map(|(index, entry)| (entry.kind == TranscriptEntryKind::User).then_some(index))
.filter_map(|(index, entry)| {
(entry.kind == TranscriptEntryKind::User).then_some(
codex_guardian_context::UserMessageCost {
index,
tokens: entry.tokens,
},
)
})
.collect::<Vec<_>>();
if let Some(&first_user_index) = user_indices.first() {
included[first_user_index] = true;
message_tokens += entries[first_user_index].tokens;
}
if let Some(&latest_user_index) = user_indices.last()
&& !included[latest_user_index]
&& message_tokens + entries[latest_user_index].tokens
<= retention.max_message_transcript_tokens
{
included[latest_user_index] = true;
message_tokens += entries[latest_user_index].tokens;
}
for &index in user_indices.iter().rev() {
if included[index]
|| message_tokens + entries[index].tokens > retention.max_message_transcript_tokens
{
continue;
}
let selection = codex_guardian_context::select_user_messages(
&user_messages,
retention.max_message_transcript_tokens,
);
for index in selection.indices {
included[index] = true;
message_tokens += entries[index].tokens;
}
let available_message_tokens = retention
.max_message_transcript_tokens
.saturating_sub(message_tokens);
.saturating_sub(selection.tokens);
let mut window = TranscriptWindow::new(&entries, retention, available_message_tokens);
for index in 0..entries.len() {
window.insert(index);
+4
View File
@@ -19,6 +19,9 @@ use transcript::ConversationTranscriptSection;
pub use entry::ConversationTranscriptEntry;
pub use entry::ConversationTranscriptEntryKind;
pub use history::TranscriptHistory;
pub use retention::UserMessageCost;
pub use retention::UserMessageSelection;
pub use retention::select_user_messages;
pub use transcript::ConversationTranscriptConfig;
pub use transcript::ConversationTranscriptOptions;
pub use transcript::MANUAL_APPROVAL_DEVELOPER_PREFIX;
@@ -29,6 +32,7 @@ pub use truncation::truncate_text;
mod entry;
mod history;
mod retention;
mod transcript;
mod truncation;
@@ -0,0 +1,48 @@
//! Shared user-message selection, independent of rendering and non-user eviction.
//!
//! Callers supply costs including their own formatting. The first user message
//! remains an anchor even when it exceeds the budget, preserving existing behavior.
/// Rendered cost and original transcript position of a genuine user message.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct UserMessageCost {
pub index: usize,
pub tokens: usize,
}
/// Selected original indices and their total rendered token cost.
#[derive(Debug, Default, Eq, PartialEq)]
pub struct UserMessageSelection {
pub indices: Vec<usize>,
pub tokens: usize,
}
/// Keeps the first and latest user messages, then fills from newest to oldest.
///
/// Supply messages in conversation order with distinct original indices. The
/// returned indices are in selection order; callers render in conversation order.
/// Non-user evidence uses the remaining budget under the caller's own policy.
pub fn select_user_messages(
messages: &[UserMessageCost],
max_message_tokens: usize,
) -> UserMessageSelection {
let Some((first, remaining)) = messages.split_first() else {
return UserMessageSelection::default();
};
let mut selection = UserMessageSelection {
indices: vec![first.index],
tokens: first.tokens,
};
// The latest message is considered first, including when it cannot fit.
for message in remaining.iter().rev() {
if selection.tokens.saturating_add(message.tokens) <= max_message_tokens {
selection.indices.push(message.index);
selection.tokens += message.tokens;
}
}
selection
}
#[cfg(test)]
#[path = "retention_tests.rs"]
mod tests;
@@ -0,0 +1,48 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn user_selection_preserves_anchors_and_fills_from_newest() {
let messages = [
UserMessageCost {
index: 2,
tokens: 4,
},
UserMessageCost {
index: 5,
tokens: 3,
},
UserMessageCost {
index: 8,
tokens: 6,
},
UserMessageCost {
index: 10,
tokens: 2,
},
];
for (budget, indices, tokens) in [
(0, vec![2], 4),
(4, vec![2], 4),
(6, vec![2, 10], 6),
(9, vec![2, 10, 5], 9),
(12, vec![2, 10, 8], 12),
(15, vec![2, 10, 8, 5], 15),
] {
assert_eq!(
select_user_messages(&messages, budget),
UserMessageSelection { indices, tokens },
);
}
assert_eq!(
select_user_messages(&[], /*max_message_tokens*/ 10),
UserMessageSelection::default(),
);
assert_eq!(
select_user_messages(&messages[..1], /*max_message_tokens*/ 10),
UserMessageSelection {
indices: vec![2],
tokens: 4
},
);
}