From b3e0c49dfbc6744cd71a8c36a9ad1840966404e4 Mon Sep 17 00:00:00 2001 From: felixxia-oai Date: Mon, 14 Sep 2026 12:41:00 +0000 Subject: [PATCH] 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 --- codex-rs/core/src/guardian/prompt.rs | 59 ++++------------- codex-rs/guardian-context/src/cursor.rs | 60 ++++++++++++++++++ codex-rs/guardian-context/src/cursor_tests.rs | 63 +++++++++++++++++++ codex-rs/guardian-context/src/lib.rs | 8 ++- 4 files changed, 142 insertions(+), 48 deletions(-) create mode 100644 codex-rs/guardian-context/src/cursor.rs create mode 100644 codex-rs/guardian-context/src/cursor_tests.rs diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index d5260ddb32..52e035e76f 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -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, "", 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, "", 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( diff --git a/codex-rs/guardian-context/src/cursor.rs b/codex-rs/guardian-context/src/cursor.rs new file mode 100644 index 0000000000..af85c6c02b --- /dev/null +++ b/codex-rs/guardian-context/src/cursor.rs @@ -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; diff --git a/codex-rs/guardian-context/src/cursor_tests.rs b/codex-rs/guardian-context/src/cursor_tests.rs new file mode 100644 index 0000000000..5c5e91bd61 --- /dev/null +++ b/codex-rs/guardian-context/src/cursor_tests.rs @@ -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::>(); + 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!["[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::>(), + next_cursor, + ), + ( + vec!["[4] assistant: fourth\n".to_owned()], + TranscriptCursor { + parent_history_version: 7, + transcript_entry_count: 4, + }, + ), + ); +} diff --git a/codex-rs/guardian-context/src/lib.rs b/codex-rs/guardian-context/src/lib.rs index c4bd652cb1..f12d9b1baa 100644 --- a/codex-rs/guardian-context/src/lib.rs +++ b/codex-rs/guardian-context/src/lib.rs @@ -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;