Extract guardian transcript selection into guardian-context (#45417)

## What changed

Move full/delta transcript selection into the shared `TranscriptMode::select` API and use it when building guardian prompts. Export `TranscriptCursor`, `TranscriptMode`, and `TranscriptSelection` from `codex-guardian-context`.

Preserve full-transcript fallback when the history version changes or the saved cursor exceeds the collected entry count. Select entries before profile retention, preserving their numbering and returning a proposed cursor that counts all collected entries. Hosts remain responsible for committing and invalidating cursors.

## Testing

Add a regression test verifying that sliding-window retention preserves the collected-entry cursor and that an appended entry is selected and numbered correctly in the next delta.

GitOrigin-RevId: a2192c08e23c18302b0105ba47aeb2780eb4a015
This commit is contained in:
felixxia-oai
2026-09-14 12:41:00 +00:00
committed by copyberry
parent 99cda7a9a5
commit b3e0c49dfb
4 changed files with 142 additions and 48 deletions

View File

@@ -13,6 +13,9 @@ use codex_guardian_context::PlannedActionKind;
use codex_guardian_context::SectionError;
use codex_guardian_context::SectionHistory;
use codex_guardian_context::SectionInput;
pub(crate) use codex_guardian_context::TranscriptCursor as GuardianTranscriptCursor;
pub(crate) use codex_guardian_context::TranscriptMode as GuardianPromptMode;
use codex_guardian_context::TranscriptSelection;
use codex_guardian_context::default_registry;
use codex_protocol::models::ResponseItem;
@@ -44,19 +47,6 @@ pub(crate) struct GuardianPromptItems {
pub(crate) node_repl_evidence_sequence: u64,
}
/// Points to the end of the transcript that the guardian has already reviewed.
/// The saved count is only reusable when `parent_history_version` still matches.
#[derive(Clone, Copy, Debug)]
pub(crate) struct GuardianTranscriptCursor {
pub(crate) parent_history_version: u64,
pub(crate) transcript_entry_count: usize,
}
pub(crate) enum GuardianPromptMode {
Full,
Delta { cursor: GuardianTranscriptCursor },
}
/// Builds the guardian user content items from:
/// - a compact transcript for authorization and local context
/// - the exact action JSON being proposed for approval
@@ -180,41 +170,23 @@ pub(crate) async fn build_guardian_prompt_items_with_parent_turn(
permissions.as_ref(),
node_repl_context.as_ref(),
)?;
let transcript_entries = sections.transcript_entries();
let transcript_cursor = GuardianTranscriptCursor {
parent_history_version: history.review_history_version(),
transcript_entry_count: transcript_entries.len(),
};
let prompt_shape = match mode {
GuardianPromptMode::Full => GuardianPromptShape::Full,
GuardianPromptMode::Delta { cursor } => {
if cursor.parent_history_version == transcript_cursor.parent_history_version
&& cursor.transcript_entry_count <= transcript_cursor.transcript_entry_count
{
GuardianPromptShape::Delta {
already_seen_entry_count: cursor.transcript_entry_count,
}
} else {
GuardianPromptShape::Full
}
}
};
let (selection, transcript_cursor) = mode.select(
sections.transcript_entries(),
history.review_history_version(),
);
let session_id = session.thread_id.to_string();
let (transcript_entries, offset, placeholder, presentation) = match prompt_shape {
GuardianPromptShape::Full => (
transcript_entries,
let (transcript_entries, offset, placeholder, presentation) = match selection {
TranscriptSelection::Full(entries) => (
entries,
0,
"<no retained transcript entries>",
ContextPresentation::SyncFull {
session_id: &session_id,
},
),
GuardianPromptShape::Delta {
already_seen_entry_count,
} => (
&transcript_entries[already_seen_entry_count..],
already_seen_entry_count,
TranscriptSelection::Delta { entries, offset } => (
entries,
offset,
"<no retained transcript delta entries>",
ContextPresentation::SyncDelta {
session_id: &session_id,
@@ -257,11 +229,6 @@ fn parent_turn_permissions(context: &GuardianReviewContext) -> PermissionContext
}
}
enum GuardianPromptShape {
Full,
Delta { already_seen_entry_count: usize },
}
/// Exercises the sync profile through the host's existing transcript tests.
#[cfg(test)]
pub(crate) fn render_guardian_transcript_entries(

View File

@@ -0,0 +1,60 @@
//! Selects full or incremental review evidence before profile retention.
//! Selection only proposes the next cursor; callers commit it with reviewed history.
//! Hosts must invalidate cursors when collection settings or history offsets change.
use crate::ConversationTranscriptEntry;
/// End of the collected transcript in one host-owned review-history generation.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TranscriptCursor {
pub parent_history_version: u64,
pub transcript_entry_count: usize,
}
/// Whether to start a transcript or continue from previously reviewed evidence.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TranscriptMode {
Full,
Delta { cursor: TranscriptCursor },
}
/// Selected evidence and its original numbering, before rendering or admission.
pub enum TranscriptSelection<'a> {
Full(&'a [ConversationTranscriptEntry]),
Delta {
entries: &'a [ConversationTranscriptEntry],
offset: usize,
},
}
impl TranscriptMode {
/// Falls back to full evidence if the saved cursor no longer addresses this history.
/// The returned cursor counts collected entries, including any later omitted by a profile.
pub fn select(
self,
entries: &[ConversationTranscriptEntry],
parent_history_version: u64,
) -> (TranscriptSelection<'_>, TranscriptCursor) {
let next_cursor = TranscriptCursor {
parent_history_version,
transcript_entry_count: entries.len(),
};
let selection = match self {
Self::Delta { cursor }
if cursor.parent_history_version == parent_history_version
&& cursor.transcript_entry_count <= entries.len() =>
{
TranscriptSelection::Delta {
entries: &entries[cursor.transcript_entry_count..],
offset: cursor.transcript_entry_count,
}
}
Self::Full | Self::Delta { .. } => TranscriptSelection::Full(entries),
};
(selection, next_cursor)
}
}
#[cfg(test)]
#[path = "cursor_tests.rs"]
mod tests;

View File

@@ -0,0 +1,63 @@
//! Checks that profile retention cannot move the collected-transcript cursor backwards.
use super::*;
use crate::ContextProfile;
use crate::ConversationTranscriptEntryKind;
use pretty_assertions::assert_eq;
#[test]
fn delta_cursor_tracks_collected_entries_across_sliding_window_retention() {
let entries = ["first", "second", "third", "fourth"]
.into_iter()
.map(|text| ConversationTranscriptEntry {
kind: ConversationTranscriptEntryKind::Assistant,
text: text.to_owned(),
original_bytes: text.len(),
})
.collect::<Vec<_>>();
let mut profile = ContextProfile::asynchronous();
profile.retention.max_recent_non_user_entries = 1;
let (selection, cursor) =
TranscriptMode::Full.select(&entries[..3], /*parent_history_version*/ 7);
let TranscriptSelection::Full(initial) = selection else {
panic!("first request must select a full transcript");
};
let initial = profile.render_transcript(initial, /*entry_number_offset*/ 0);
assert_eq!(
initial
.items
.into_iter()
.map(|item| item.content)
.collect::<Vec<_>>(),
vec!["[3] assistant: third\n"],
);
let (selection, next_cursor) =
TranscriptMode::Delta { cursor }.select(&entries, /*parent_history_version*/ 7);
let TranscriptSelection::Delta {
entries: delta,
offset,
} = selection
else {
panic!("an append must select only the new entry");
};
let delta = profile.render_transcript(delta, offset);
assert_eq!(
(
delta
.items
.into_iter()
.map(|item| item.content)
.collect::<Vec<_>>(),
next_cursor,
),
(
vec!["[4] assistant: fourth\n".to_owned()],
TranscriptCursor {
parent_history_version: 7,
transcript_entry_count: 4,
},
),
);
}

View File

@@ -4,8 +4,8 @@
//! without section composition.
//! Contributor failures abort collection without returning partial context.
//! Sections preserve source-specific evidence and share prompt framing, while
//! profiles retain the consumer-specific transcript policy. Hosts own full/delta
//! cursors, compaction and request lifecycles.
//! profiles retain the consumer-specific transcript policy. Shared full/delta selection
//! proposes cursors; hosts own their admission, compaction and request lifecycles.
//! Registered contributors declare their scope once and are collected only for
//! matching context consumers. History and collection settings are borrowed for
//! each request so the default registry can be reused without retaining state.
@@ -52,6 +52,7 @@ pub use enforcement::HistoryTruncation;
pub(crate) use enforcement::Retention;
mod budget;
mod composition;
mod cursor;
pub use budget::DEFAULT_MAX_INPUT_TOKENS;
pub use budget::REQUEST_TOKENS_BOUNDARIES;
pub use budget::REQUEST_TOKENS_METRIC;
@@ -61,6 +62,9 @@ pub use budget::SECTION_COST_METRIC;
pub use budget::SectionCost;
pub use budget::effective_input_token_limit;
pub use budget::estimate_input_tokens;
pub use cursor::TranscriptCursor;
pub use cursor::TranscriptMode;
pub use cursor::TranscriptSelection;
mod profile;
pub use composition::CollectedContext;
pub use composition::ComposedContext;