diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index b7b3e85a5e..5e8fdb648d 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -61,15 +61,14 @@ pub(crate) const MAX_RECENT_AUTO_REVIEW_DENIALS_PER_TURN: u32 = 10; pub(crate) const AUTO_REVIEW_DENIAL_WINDOW_SIZE: usize = 50; pub(crate) const AUTO_REVIEW_DENIED_ACTION_APPROVAL_DEVELOPER_PREFIX: &str = codex_guardian_context::MANUAL_APPROVAL_DEVELOPER_PREFIX; -const GUARDIAN_MAX_MESSAGE_TRANSCRIPT_TOKENS: usize = 20_000; -const GUARDIAN_MAX_TOOL_TRANSCRIPT_TOKENS: usize = 10_000; -const GUARDIAN_MAX_MESSAGE_ENTRY_TOKENS: usize = 5_000; -const GUARDIAN_MAX_TOOL_ENTRY_TOKENS: usize = 1_000; +const GUARDIAN_MAX_TOOL_ENTRY_TOKENS: usize = codex_guardian_context::ContextProfile::synchronous() + .transcript + .entry_limits + .tool_tokens; pub(crate) const GUARDIAN_MAX_ROOT_MESSAGE_TOKENS: usize = 900; pub(crate) const GUARDIAN_MAX_NODE_REPL_TOOL_RESULT_TOKENS: usize = 6_000; pub(crate) const GUARDIAN_MAX_ACTION_BYTES: usize = 8_000; const GUARDIAN_MAX_ACTION_STRING_TOKENS: usize = 16_000; -const GUARDIAN_RECENT_ENTRY_LIMIT: usize = 40; /// Captures review inputs from the issuing step without retaining its MCP bindings or tool router. /// Background network approvals and Unix interception use the active task's resolved settings. diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 439b52fbaa..0f88ec67d2 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -1,21 +1,16 @@ use codex_extension_api::ConversationHistorySnapshot; use codex_guardian_context::CollectedContext; use codex_guardian_context::ContextPresentation; -use codex_guardian_context::ContextTarget; -use codex_guardian_context::ConversationTranscriptConfig; +use codex_guardian_context::ContextProfile; +#[cfg(test)] use codex_guardian_context::ConversationTranscriptEntry; -use codex_guardian_context::ConversationTranscriptEntryKind; -use codex_guardian_context::ConversationTranscriptOptions; use codex_guardian_context::GuardianRootMessage; use codex_guardian_context::PermissionContext; use codex_guardian_context::PlannedAction; use codex_guardian_context::PlannedActionKind; -use codex_guardian_context::RenderedTranscript; use codex_guardian_context::SectionError; use codex_guardian_context::SectionHistory; use codex_guardian_context::SectionInput; -use codex_guardian_context::TranscriptEntryLimits; -use codex_guardian_context::TranscriptRetentionConfig; use codex_guardian_context::default_registry; use codex_protocol::models::ResponseItem; use codex_protocol::user_input::UserInput; @@ -28,26 +23,16 @@ use crate::event_mapping::is_contextual_user_message_content; use crate::session::session::Session; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::approx_bytes_for_tokens; -use codex_utils_output_truncation::approx_token_count; use codex_utils_output_truncation::truncate_text; use super::ApprovalRequestReasons; -use super::GUARDIAN_MAX_MESSAGE_ENTRY_TOKENS; -use super::GUARDIAN_MAX_MESSAGE_TRANSCRIPT_TOKENS; use super::GUARDIAN_MAX_NODE_REPL_TOOL_RESULT_TOKENS; use super::GUARDIAN_MAX_TOOL_ENTRY_TOKENS; -use super::GUARDIAN_MAX_TOOL_TRANSCRIPT_TOKENS; -use super::GUARDIAN_RECENT_ENTRY_LIMIT; use super::GuardianApprovalRequest; use super::GuardianReviewContext; use super::approval_request::format_guardian_action_pretty; const GUARDIAN_MAX_APPROVAL_REASON_TOKENS: usize = 512; -const GUARDIAN_TRANSCRIPT_RETENTION: TranscriptRetentionConfig = TranscriptRetentionConfig { - max_message_transcript_tokens: GUARDIAN_MAX_MESSAGE_TRANSCRIPT_TOKENS, - max_tool_transcript_tokens: GUARDIAN_MAX_TOOL_TRANSCRIPT_TOKENS, - max_recent_non_user_entries: GUARDIAN_RECENT_ENTRY_LIMIT, -}; pub(super) const GUARDIAN_TRANSCRIPT_START: &str = ">>> TRANSCRIPT START\n"; pub(crate) struct GuardianPromptItems { @@ -220,16 +205,13 @@ pub(crate) async fn build_guardian_prompt_items_with_parent_turn( }, ), }; - let (items, omission_note) = - render_guardian_transcript_entries_with_offset(transcript_entries, offset, placeholder); + let profile = ContextProfile::synchronous(); + let mut transcript = profile.render_transcript(transcript_entries, offset); + if transcript_entries.is_empty() { + transcript.items.push(placeholder.to_owned()); + } let items = sections - .compose( - presentation, - RenderedTranscript { - items, - omission_note, - }, - )? + .compose(presentation, transcript)? .into_user_inputs()?; Ok(GuardianPromptItems { items, @@ -265,124 +247,19 @@ enum GuardianPromptShape { Delta { already_seen_entry_count: usize }, } -/// Renders a compact guardian transcript from shared, per-entry-bounded evidence. -/// -/// Selection is intentionally simple and predictable: -/// - collection has already applied each entry's per-entry cap -/// - user and assistant entries share the message budget -/// - tool calls/results use a separate tool budget so tool evidence cannot -/// crowd out the human conversation -/// - if all user turns fit, keep them all -/// - otherwise keep the first and latest user turns as anchors, then fill the -/// remaining message budget with other user turns from newest to oldest -/// - after user turns are selected, keep recent non-user entries from newest to -/// oldest while the budgets and recent-entry limit allow -/// -/// Returns the rendered transcript plus an omission note when some entries were -/// skipped. +/// Exercises the sync profile through the host's existing transcript tests. #[cfg(test)] pub(crate) fn render_guardian_transcript_entries( entries: &[ConversationTranscriptEntry], ) -> (Vec, Option) { - render_guardian_transcript_entries_with_offset( - entries, - /*entry_number_offset*/ 0, - "", - ) -} - -fn render_guardian_transcript_entries_with_offset( - entries: &[ConversationTranscriptEntry], - entry_number_offset: usize, - empty_placeholder: &str, -) -> (Vec, Option) { + let mut transcript = + ContextProfile::synchronous().render_transcript(entries, /*entry_number_offset*/ 0); if entries.is_empty() { - return (vec![empty_placeholder.to_string()], None); + transcript + .items + .push("".to_owned()); } - - let rendered_entries = entries - .iter() - .enumerate() - .map(|(index, entry)| { - let rendered = format!( - "[{}] {}: {}", - index + entry_number_offset + 1, - entry.kind.role(), - entry.text - ); - let token_count = approx_token_count(&rendered); - (rendered, token_count) - }) - .collect::>(); - - let mut included = vec![false; entries.len()]; - let user_messages = entries - .iter() - .enumerate() - .filter_map(|(index, entry)| { - matches!(entry.kind, ConversationTranscriptEntryKind::User).then_some( - codex_guardian_context::UserMessageCost { - index, - tokens: rendered_entries[index].1, - }, - ) - }) - .collect::>(); - 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; - } - 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() { - let entry = &entries[index]; - if matches!(entry.kind, ConversationTranscriptEntryKind::User) - || retained_non_user_entries - >= GUARDIAN_TRANSCRIPT_RETENTION.max_recent_non_user_entries - { - continue; - } - - let token_count = rendered_entries[index].1; - let is_tool = matches!( - entry.kind, - ConversationTranscriptEntryKind::ToolCall(_) - | ConversationTranscriptEntryKind::ToolOutput(_) - | ConversationTranscriptEntryKind::NodeReplToolOutput(_) - ); - let within_budget = if is_tool { - tool_tokens + token_count <= GUARDIAN_TRANSCRIPT_RETENTION.max_tool_transcript_tokens - } else { - message_tokens + token_count - <= GUARDIAN_TRANSCRIPT_RETENTION.max_message_transcript_tokens - }; - if !within_budget { - continue; - } - - included[index] = true; - retained_non_user_entries += 1; - if is_tool { - tool_tokens += token_count; - } else { - message_tokens += token_count; - } - } - - let transcript = entries - .iter() - .enumerate() - .filter(|(index, _)| included[*index]) - .map(|(index, _)| rendered_entries[index].0.clone()) - .collect::>(); - let omitted_any = included.iter().any(|included_entry| !included_entry); - let omission_note = omitted_any.then(|| "Some conversation entries were omitted.".to_string()); - (transcript, omission_note) + (transcript.items, transcript.omission_note) } /// Retains the human-readable conversation plus recent tool call / result @@ -404,18 +281,12 @@ pub(super) fn collect_guardian_context( permissions: Option<&PermissionContext>, node_repl: Option<&codex_guardian_context::NodeReplContext<'_>>, ) -> Result { - let transcript = ConversationTranscriptConfig { - options: ConversationTranscriptOptions::default(), - entry_limits: TranscriptEntryLimits { - message_tokens: GUARDIAN_MAX_MESSAGE_ENTRY_TOKENS, - tool_tokens: GUARDIAN_MAX_TOOL_ENTRY_TOKENS, - node_repl_output_tokens: node_repl_result_token_limit, - }, - }; + let mut profile = ContextProfile::synchronous(); + profile.transcript.entry_limits.node_repl_output_tokens = node_repl_result_token_limit; default_registry().prepare(&SectionInput { - target: ContextTarget::Sync, + target: profile.target, history: &FilteredGuardianHistory(history), - transcript: &transcript, + transcript: &profile.transcript, root_conversation, trusted_user_answers, planned_action, diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs b/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs index e3a35e417e..3259bc54b4 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs @@ -71,7 +71,6 @@ use super::wrapper_lag::WrapperLag; use codex_core::context::GuardianReviewEvidenceFragment; use codex_guardian_context::PreviousReviews; use codex_guardian_context::ReviewEvidence; -use codex_guardian_context::TranscriptImageInput; use codex_guardian_context::render_review_evidence; enum ClassificationOutcome { @@ -577,14 +576,7 @@ impl GuardianV2Extension { previous_reviews: Some(&reviews), trusted_tool: trusted_tool_context.as_ref(), trusted_skill_paths: &trusted_skill_paths, - images: Some(TranscriptImageInput { - enabled: guardian_config.transcript.include_images, - include_tool_outputs: guardian_config - .transcript - .sources - .contains(&super::transcript::TranscriptSource::ToolOutputs), - node_repl_images: &node_repl_images, - }), + node_repl_images: Some(&node_repl_images), }) }); let mut transcript = match transcript { diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/transcript.rs b/codex-rs/ext/guardian-v2/src/async_scorer/transcript.rs index a8976b231b..cbb81a3d8d 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/transcript.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/transcript.rs @@ -3,17 +3,15 @@ use codex_extension_api::ResponseItem; pub(crate) use codex_features::GuardianV2TranscriptSource as TranscriptSource; use codex_guardian_context::ComposedContext; use codex_guardian_context::ContextPresentation; +use codex_guardian_context::ContextProfile; use codex_guardian_context::ContextTarget; use codex_guardian_context::ConversationTranscriptConfig; -use codex_guardian_context::ConversationTranscriptEntry; -use codex_guardian_context::ConversationTranscriptEntryKind; use codex_guardian_context::ConversationTranscriptOptions; use codex_guardian_context::GuardianRootMessage; #[cfg(test)] use codex_guardian_context::MANUAL_APPROVAL_DEVELOPER_PREFIX; use codex_guardian_context::PlannedAction; use codex_guardian_context::PreviousReviews; -use codex_guardian_context::RenderedTranscript; use codex_guardian_context::SectionError; use codex_guardian_context::SectionHistory; use codex_guardian_context::SectionInput; @@ -23,34 +21,24 @@ use codex_guardian_context::TranscriptRetentionConfig; use codex_guardian_context::TrustedTool; use codex_guardian_context::default_registry; pub(crate) use codex_guardian_context::truncate_text as truncate_entry; -use codex_protocol::protocol::TruncationPolicy; - -use self::window::TranscriptWindow; -use super::truncation::TruncationObservation; - -mod window; - -pub(crate) const MAX_MESSAGE_ENTRY_TOKENS: usize = 2_000; -pub(crate) const MAX_TOOL_ENTRY_TOKENS: usize = 1_000; -pub(crate) const MAX_MESSAGE_TRANSCRIPT_TOKENS: usize = 10_000; -pub(crate) const MAX_TOOL_TRANSCRIPT_TOKENS: usize = 10_000; -pub(crate) const MAX_RECENT_NON_USER_ENTRIES: usize = 40; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum TranscriptEntryKind { - User, - ProtectedMessage, - Message, - Tool, -} - -struct TranscriptEntry { - kind: TranscriptEntryKind, - text: String, - tokens: usize, - original_bytes: usize, - retained_bytes: usize, -} +pub(crate) const MAX_MESSAGE_ENTRY_TOKENS: usize = ContextProfile::asynchronous() + .transcript + .entry_limits + .message_tokens; +pub(crate) const MAX_TOOL_ENTRY_TOKENS: usize = ContextProfile::asynchronous() + .transcript + .entry_limits + .tool_tokens; +pub(crate) const MAX_MESSAGE_TRANSCRIPT_TOKENS: usize = ContextProfile::asynchronous() + .retention + .max_message_transcript_tokens; +pub(crate) const MAX_TOOL_TRANSCRIPT_TOKENS: usize = ContextProfile::asynchronous() + .retention + .max_tool_transcript_tokens; +pub(crate) const MAX_RECENT_NON_USER_ENTRIES: usize = ContextProfile::asynchronous() + .retention + .max_recent_non_user_entries; /// Host snapshot and evidence borrowed for a single section collection. pub(crate) struct ContextInput<'a> { pub(crate) target: ContextTarget, @@ -61,7 +49,7 @@ pub(crate) struct ContextInput<'a> { pub(crate) previous_reviews: Option<&'a PreviousReviews>, pub(crate) trusted_tool: Option<&'a TrustedTool>, pub(crate) trusted_skill_paths: &'a [String], - pub(crate) images: Option>, + pub(crate) node_repl_images: Option<&'a [codex_protocol::models::ContentItem]>, } pub(crate) type RenderedContext = ComposedContext; @@ -81,7 +69,7 @@ impl Default for TranscriptConfig { fn default() -> Self { Self { sources: vec![TranscriptSource::ToolCalls, TranscriptSource::ToolOutputs], - include_images: true, + include_images: ContextProfile::asynchronous().include_images, max_message_entry_tokens: MAX_MESSAGE_ENTRY_TOKENS, max_tool_entry_tokens: MAX_TOOL_ENTRY_TOKENS, max_message_transcript_tokens: MAX_MESSAGE_TRANSCRIPT_TOKENS, @@ -105,30 +93,34 @@ impl TranscriptConfig { previous_reviews, trusted_tool, trusted_skill_paths, - images, + node_repl_images, } = input; let history = SnapshotHistory(history); - let retention = TranscriptRetentionConfig { - max_message_transcript_tokens: self.max_message_transcript_tokens, - max_tool_transcript_tokens: self.max_tool_transcript_tokens, - max_recent_non_user_entries: self.max_recent_non_user_entries, - }; - let transcript = ConversationTranscriptConfig { - options: ConversationTranscriptOptions { - include_tool_calls: self.sources.contains(&TranscriptSource::ToolCalls), - include_tool_outputs: self.sources.contains(&TranscriptSource::ToolOutputs), - include_reasoning: self.sources.contains(&TranscriptSource::Reasoning), + let profile = ContextProfile { + target: ContextTarget::Async, + include_images: self.include_images, + retention: TranscriptRetentionConfig { + max_message_transcript_tokens: self.max_message_transcript_tokens, + max_tool_transcript_tokens: self.max_tool_transcript_tokens, + max_recent_non_user_entries: self.max_recent_non_user_entries, }, - entry_limits: TranscriptEntryLimits { - message_tokens: self.max_message_entry_tokens, - tool_tokens: self.max_tool_entry_tokens, - node_repl_output_tokens: self.max_tool_entry_tokens, + transcript: ConversationTranscriptConfig { + options: ConversationTranscriptOptions { + include_tool_calls: self.sources.contains(&TranscriptSource::ToolCalls), + include_tool_outputs: self.sources.contains(&TranscriptSource::ToolOutputs), + include_reasoning: self.sources.contains(&TranscriptSource::Reasoning), + }, + entry_limits: TranscriptEntryLimits { + message_tokens: self.max_message_entry_tokens, + tool_tokens: self.max_tool_entry_tokens, + node_repl_output_tokens: self.max_tool_entry_tokens, + }, }, }; let context = default_registry().prepare(&SectionInput { target, history: &history, - transcript: &transcript, + transcript: &profile.transcript, root_conversation, trusted_user_answers, planned_action, @@ -136,120 +128,16 @@ impl TranscriptConfig { previous_reviews, trusted_tool, trusted_skill_paths, - images, + images: node_repl_images.map(|node_repl_images| TranscriptImageInput { + enabled: profile.include_images, + include_tool_outputs: profile.transcript.options.include_tool_outputs, + node_repl_images, + }), node_repl: None, })?; - let (items, mut truncations) = Self::render(context.transcript_entries(), &retention); - let mut context = context.compose( - ContextPresentation::Async, - RenderedTranscript { - items, - omission_note: None, - }, - )?; - truncations.append(&mut context.truncations); - context.truncations = truncations; - Ok(context) - } - - fn render( - transcript_entries: &[ConversationTranscriptEntry], - retention: &TranscriptRetentionConfig, - ) -> (Vec, Vec) { - let mut entries = Vec::new(); - - for entry in transcript_entries { - let role = entry.kind.role(); - let kind = match &entry.kind { - ConversationTranscriptEntryKind::User => TranscriptEntryKind::User, - ConversationTranscriptEntryKind::Developer - | ConversationTranscriptEntryKind::ProtectedAssistant => { - TranscriptEntryKind::ProtectedMessage - } - ConversationTranscriptEntryKind::Assistant - | ConversationTranscriptEntryKind::Reasoning => TranscriptEntryKind::Message, - ConversationTranscriptEntryKind::ToolCall(_) - | ConversationTranscriptEntryKind::ToolOutput(_) - | ConversationTranscriptEntryKind::NodeReplToolOutput(_) => { - TranscriptEntryKind::Tool - } - }; - let original_bytes = entry.original_bytes; - let text = &entry.text; - let retained_bytes = text.len(); - let entry_number = entries.len() + 1; - let text = format!("[{entry_number}] {role}: {text}\n"); - let tokens = TruncationPolicy::Bytes(text.len()).token_budget(); - entries.push(TranscriptEntry { - kind, - text, - tokens, - original_bytes, - retained_bytes, - }); - } - - let mut included = vec![false; entries.len()]; - let user_messages = entries - .iter() - .enumerate() - .filter_map(|(index, entry)| { - (entry.kind == TranscriptEntryKind::User).then_some( - codex_guardian_context::UserMessageCost { - index, - tokens: entry.tokens, - }, - ) - }) - .collect::>(); - let selection = codex_guardian_context::select_user_messages( - &user_messages, - retention.max_message_transcript_tokens, - ); - for index in selection.indices { - included[index] = true; - } - let available_message_tokens = retention - .max_message_transcript_tokens - .saturating_sub(selection.tokens); - let mut window = TranscriptWindow::new(&entries, retention, available_message_tokens); - for index in 0..entries.len() { - window.insert(index); - } - - for index in window.into_indices() { - included[index] = true; - } - - let mut truncations = Vec::new(); - let entries = entries - .into_iter() - .enumerate() - .filter_map(|(index, entry)| { - let component = match entry.kind { - TranscriptEntryKind::User => "transcript_user", - TranscriptEntryKind::ProtectedMessage | TranscriptEntryKind::Message => { - "transcript_message" - } - TranscriptEntryKind::Tool => "transcript_tool", - }; - let retained_bytes = if included[index] { - entry.retained_bytes - } else { - 0 - }; - if entry.original_bytes > retained_bytes { - truncations.push(TruncationObservation { - component, - original_bytes: entry.original_bytes, - retained_bytes, - }); - } - included[index].then_some(entry.text) - }) - .collect(); - - (entries, truncations) + let transcript = + profile.render_transcript(context.transcript_entries(), /*entry_number_offset*/ 0); + context.compose(ContextPresentation::Async, transcript) } } diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs index 34f59bf5f4..1dd594451c 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs @@ -109,7 +109,7 @@ fn transcript_keeps_conversation_and_configured_sources() { previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("collect transcript") .transcript_entries(); @@ -136,7 +136,7 @@ fn transcript_keeps_conversation_and_configured_sources() { previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("compose authorization and transcript"); let mut expected_text = vec![ @@ -180,7 +180,7 @@ fn transcript_keeps_conversation_and_configured_sources() { previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("collect transcript") .transcript_entries(); @@ -208,7 +208,7 @@ fn transcript_keeps_conversation_and_configured_sources() { previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("collect transcript") .transcript_entries(); @@ -261,7 +261,7 @@ fn transcript_truncates_oversized_entries_without_splitting_characters() { previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("collect transcript"); let truncations = std::mem::take(&mut rendered.truncations); @@ -329,7 +329,7 @@ fn transcript_preserves_first_and_latest_user_messages_and_recent_history() { previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("collect transcript") .transcript_entries(); @@ -395,7 +395,7 @@ fn transcript_preserves_user_restrictions_before_final_assistant_messages() { previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("collect transcript") .transcript_entries(); @@ -435,7 +435,7 @@ fn transcript_preserves_recent_tool_evidence_when_protected_messages_fill_entry_ previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("collect transcript"); @@ -501,7 +501,7 @@ fn transcript_reserves_five_recent_tool_entries_from_protected_messages() { previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("collect transcript") .transcript_entries(); @@ -557,7 +557,7 @@ fn rejected_commentary_does_not_evict_retained_message_evidence() { previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("collect transcript") .transcript_entries(); @@ -598,7 +598,7 @@ fn transcript_evicts_protected_messages_in_cacheable_chunks() { previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("collect transcript") .transcript_entries() @@ -669,7 +669,7 @@ fn transcript_preserves_latest_final_when_reserved_tools_fill_entry_window() { previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("collect transcript") .transcript_entries(); @@ -742,7 +742,7 @@ fn transcript_does_not_protect_legacy_inter_agent_instructions() { previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("collect transcript") .transcript_entries(); @@ -789,7 +789,7 @@ fn transcript_reserves_separate_budget_for_recent_tool_evidence() { previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("collect transcript") .transcript_entries(); @@ -851,7 +851,7 @@ fn transcript_reserves_separate_budget_for_recent_tool_evidence() { previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("collect transcript") .transcript_entries(); @@ -915,7 +915,7 @@ fn transcript_preserves_newest_manual_approval_when_message_budget_overflows() { previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("collect transcript") .transcript_entries(); @@ -975,7 +975,7 @@ fn rejected_message_does_not_evict_retained_tool_entries() { previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("collect transcript") .transcript_entries(); @@ -1020,7 +1020,7 @@ fn transcript_evicts_non_user_entries_in_cacheable_chunks() { previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("collect transcript") .transcript_entries() @@ -1093,7 +1093,7 @@ fn transcript_truncates_tool_results_using_standard_budget() { previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("collect transcript") .transcript_entries(); @@ -1148,7 +1148,7 @@ fn transcript_preserves_outputs_with_call_ids_or_explicit_names() { previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("collect transcript") .transcript_entries(), @@ -1177,7 +1177,7 @@ fn transcript_preserves_outputs_with_call_ids_or_explicit_names() { previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("collect transcript") .transcript_entries(), @@ -1224,7 +1224,7 @@ fn configured_reasoning_counts_against_message_budget() { previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("collect transcript") .transcript_entries(); @@ -1293,7 +1293,7 @@ fn transcript_keeps_only_manual_approval_developer_messages() { previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("collect transcript") .transcript_entries(); @@ -1380,7 +1380,7 @@ fn transcript_omits_media_payloads_and_keeps_readable_content() { previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("collect transcript") .transcript_entries(); @@ -1449,7 +1449,7 @@ fn transcript_omits_encrypted_messages_arguments_and_tool_outputs() { previous_reviews: None, trusted_tool: None, trusted_skill_paths: &[], - images: None, + node_repl_images: None, }) .expect("collect transcript") .transcript_entries(); diff --git a/codex-rs/guardian-context/src/composition.rs b/codex-rs/guardian-context/src/composition.rs index 3783dc30b7..eb74bc8b82 100644 --- a/codex-rs/guardian-context/src/composition.rs +++ b/codex-rs/guardian-context/src/composition.rs @@ -1,5 +1,5 @@ //! Composes collected evidence into ordered sections with explicit delivery. -//! Hosts select the transcript and retain cursor/budget policy; composition owns +//! Profiles retain the host-selected transcript slice; composition owns //! framing, message boundaries and section placement, without retaining history. use codex_context_fragments::ContextualUserFragment; @@ -24,6 +24,7 @@ pub enum ContextPresentation<'a> { pub struct RenderedTranscript { pub items: Vec, pub omission_note: Option, + pub truncations: Vec, } /// Evidence collected successfully before host transcript selection. @@ -67,7 +68,7 @@ impl CollectedContext { pub fn compose( self, presentation: ContextPresentation<'_>, - transcript: RenderedTranscript, + mut transcript: RenderedTranscript, ) -> Result { let (action, intro, start, end, session_id) = match presentation { ContextPresentation::SyncFull { session_id } => ( @@ -97,7 +98,7 @@ impl CollectedContext { ), }; let mut sections = Vec::new(); - let mut truncations = Vec::new(); + let mut truncations = std::mem::take(&mut transcript.truncations); if let Some(intro) = intro { sections.push(( 0, diff --git a/codex-rs/guardian-context/src/lib.rs b/codex-rs/guardian-context/src/lib.rs index 5926ec1f46..906174012e 100644 --- a/codex-rs/guardian-context/src/lib.rs +++ b/codex-rs/guardian-context/src/lib.rs @@ -4,7 +4,8 @@ //! without section composition. //! Contributor failures abort collection without returning partial context. //! Sections preserve source-specific evidence and share prompt framing, while -//! hosts retain transcript selection, compaction and request lifecycles. +//! profiles retain the consumer-specific transcript policy. Hosts own full/delta +//! cursors, 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. @@ -48,10 +49,12 @@ mod retained_instructions; mod action; mod composition; +mod profile; pub use composition::CollectedContext; pub use composition::ComposedContext; pub use composition::ContextPresentation; pub use composition::RenderedTranscript; +pub use profile::ContextProfile; mod authorization; mod entry; mod history; diff --git a/codex-rs/guardian-context/src/profile.rs b/codex-rs/guardian-context/src/profile.rs new file mode 100644 index 0000000000..5e700f9bee --- /dev/null +++ b/codex-rs/guardian-context/src/profile.rs @@ -0,0 +1,237 @@ +//! Resolved Guardian evidence profiles and pure transcript retention. +//! Sync keeps recent entries; async protects approvals/final answers and evicts +//! in cacheable chunks. Hosts still own history snapshots and delivery cursors. + +use codex_protocol::protocol::TruncationPolicy; + +use crate::ContextTarget; +use crate::ConversationTranscriptConfig; +use crate::ConversationTranscriptEntry; +use crate::ConversationTranscriptEntryKind; +use crate::ConversationTranscriptOptions; +use crate::RenderedTranscript; +use crate::TranscriptEntryLimits; +use crate::TranscriptRetentionConfig; +use crate::TruncationObservation; +use crate::UserMessageCost; +use crate::select_user_messages; + +use self::window::TranscriptWindow; +mod window; + +/// Request-local policy resolved from the consumer's model and configuration. +/// Registry scope follows `target`; source flags and caps apply before retention. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ContextProfile { + pub target: ContextTarget, + pub transcript: ConversationTranscriptConfig, + pub retention: TranscriptRetentionConfig, + pub include_images: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TranscriptEntryKind { + User, + ProtectedMessage, + Message, + Tool, +} + +struct TranscriptEntry { + kind: TranscriptEntryKind, + text: String, + tokens: usize, + original_bytes: usize, + retained_bytes: usize, +} + +impl ContextProfile { + /// Existing synchronous defaults before the host resolves the REPL output cap. + pub const fn synchronous() -> Self { + Self { + target: ContextTarget::Sync, + transcript: ConversationTranscriptConfig { + options: ConversationTranscriptOptions { + include_tool_calls: true, + include_tool_outputs: true, + include_reasoning: false, + }, + entry_limits: TranscriptEntryLimits { + message_tokens: 5_000, + tool_tokens: 1_000, + node_repl_output_tokens: 1_000, + }, + }, + retention: TranscriptRetentionConfig { + max_message_transcript_tokens: 20_000, + max_tool_transcript_tokens: 10_000, + max_recent_non_user_entries: 40, + }, + include_images: false, + } + } + + /// Existing asynchronous defaults before model and local overrides. + pub const fn asynchronous() -> Self { + Self { + target: ContextTarget::Async, + transcript: ConversationTranscriptConfig { + options: ConversationTranscriptOptions { + include_tool_calls: true, + include_tool_outputs: true, + include_reasoning: false, + }, + entry_limits: TranscriptEntryLimits { + message_tokens: 2_000, + tool_tokens: 1_000, + node_repl_output_tokens: 1_000, + }, + }, + retention: TranscriptRetentionConfig { + max_message_transcript_tokens: 10_000, + max_tool_transcript_tokens: 10_000, + max_recent_non_user_entries: 40, + }, + include_images: true, + } + } + + /// Selects bounded entries without advancing the host's full/delta cursor. + /// The host supplies the slice and original offset; empty placeholders depend + /// on its full/delta presentation and are supplied after selection. + pub fn render_transcript( + &self, + transcript_entries: &[ConversationTranscriptEntry], + entry_number_offset: usize, + ) -> RenderedTranscript { + let entries = transcript_entries + .iter() + .enumerate() + .map(|(index, entry)| { + let kind = match &entry.kind { + ConversationTranscriptEntryKind::User => TranscriptEntryKind::User, + ConversationTranscriptEntryKind::Developer + | ConversationTranscriptEntryKind::ProtectedAssistant => { + TranscriptEntryKind::ProtectedMessage + } + ConversationTranscriptEntryKind::Assistant + | ConversationTranscriptEntryKind::Reasoning => TranscriptEntryKind::Message, + ConversationTranscriptEntryKind::ToolCall(_) + | ConversationTranscriptEntryKind::ToolOutput(_) + | ConversationTranscriptEntryKind::NodeReplToolOutput(_) => { + TranscriptEntryKind::Tool + } + }; + let number = index + entry_number_offset + 1; + let role = entry.kind.role(); + let suffix = match self.target { + ContextTarget::Sync => "", + ContextTarget::Async => "\n", + }; + let text = format!("[{number}] {role}: {}{suffix}", entry.text); + TranscriptEntry { + kind, + tokens: TruncationPolicy::Bytes(text.len()).token_budget(), + text, + original_bytes: entry.original_bytes, + retained_bytes: entry.text.len(), + } + }) + .collect::>(); + let user_messages = entries + .iter() + .enumerate() + .filter_map(|(index, entry)| { + (entry.kind == TranscriptEntryKind::User).then_some(UserMessageCost { + index, + tokens: entry.tokens, + }) + }) + .collect::>(); + let selection = + select_user_messages(&user_messages, self.retention.max_message_transcript_tokens); + let mut included = vec![false; entries.len()]; + for index in selection.indices { + included[index] = true; + } + match self.target { + ContextTarget::Sync => { + let mut message_tokens = selection.tokens; + let mut tool_tokens = 0usize; + let mut retained_non_user_entries = 0; + for (index, entry) in entries.iter().enumerate().rev() { + if entry.kind == TranscriptEntryKind::User + || retained_non_user_entries >= self.retention.max_recent_non_user_entries + { + continue; + } + let (tokens, limit) = if entry.kind == TranscriptEntryKind::Tool { + (&mut tool_tokens, self.retention.max_tool_transcript_tokens) + } else { + ( + &mut message_tokens, + self.retention.max_message_transcript_tokens, + ) + }; + if tokens.saturating_add(entry.tokens) <= limit { + included[index] = true; + retained_non_user_entries += 1; + *tokens += entry.tokens; + } + } + } + ContextTarget::Async => { + let available = self + .retention + .max_message_transcript_tokens + .saturating_sub(selection.tokens); + let mut window = TranscriptWindow::new(&entries, &self.retention, available); + for index in 0..entries.len() { + window.insert(index); + } + for index in window.into_indices() { + included[index] = true; + } + } + } + let omission_note = (self.target == ContextTarget::Sync + && included.iter().any(|included| !included)) + .then(|| "Some conversation entries were omitted.".to_owned()); + let mut truncations = Vec::new(); + let items = entries + .into_iter() + .enumerate() + .filter_map(|(index, entry)| { + let component = match entry.kind { + TranscriptEntryKind::User => "transcript_user", + TranscriptEntryKind::ProtectedMessage | TranscriptEntryKind::Message => { + "transcript_message" + } + TranscriptEntryKind::Tool => "transcript_tool", + }; + let retained_bytes = if included[index] { + entry.retained_bytes + } else { + 0 + }; + if self.target == ContextTarget::Async && entry.original_bytes > retained_bytes { + truncations.push(TruncationObservation { + component, + original_bytes: entry.original_bytes, + retained_bytes, + }); + } + included[index].then_some(entry.text) + }) + .collect(); + RenderedTranscript { + items, + omission_note, + truncations, + } + } +} + +#[cfg(test)] +#[path = "profile_tests.rs"] +mod tests; diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/transcript/window.rs b/codex-rs/guardian-context/src/profile/window.rs similarity index 97% rename from codex-rs/ext/guardian-v2/src/async_scorer/transcript/window.rs rename to codex-rs/guardian-context/src/profile/window.rs index ccc72649ac..fba26bcfe0 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/transcript/window.rs +++ b/codex-rs/guardian-context/src/profile/window.rs @@ -1,6 +1,9 @@ +//! Retains protected async messages and recent tools with chunked eviction. +//! Entry pools preserve the existing async cache behavior and tool reservation. + use std::collections::VecDeque; -use codex_guardian_context::TranscriptRetentionConfig; +use crate::TranscriptRetentionConfig; use super::TranscriptEntry; use super::TranscriptEntryKind; diff --git a/codex-rs/guardian-context/src/profile_tests.rs b/codex-rs/guardian-context/src/profile_tests.rs new file mode 100644 index 0000000000..ea94cfc2cb --- /dev/null +++ b/codex-rs/guardian-context/src/profile_tests.rs @@ -0,0 +1,61 @@ +//! Profile retention keeps the existing sync and async evidence priorities. + +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn profiles_preserve_distinct_retention_and_original_numbering() { + let entries = [ + (ConversationTranscriptEntryKind::User, "inspect only"), + ( + ConversationTranscriptEntryKind::Developer, + "approved action", + ), + (ConversationTranscriptEntryKind::Assistant, "working"), + ] + .into_iter() + .map(|(kind, text)| ConversationTranscriptEntry { + kind, + text: text.to_owned(), + original_bytes: text.len(), + }) + .collect::>(); + let mut sync = ContextProfile::synchronous(); + sync.retention.max_recent_non_user_entries = 1; + let mut asynchronous = ContextProfile::asynchronous(); + asynchronous.retention.max_recent_non_user_entries = 1; + let sync = sync.render_transcript(&entries, /*entry_number_offset*/ 7); + let asynchronous = asynchronous.render_transcript(&entries, /*entry_number_offset*/ 0); + assert_eq!( + (sync.items, sync.omission_note), + ( + vec![ + "[8] user: inspect only".to_owned(), + "[10] assistant: working".to_owned() + ], + Some("Some conversation entries were omitted.".to_owned()), + ), + ); + assert_eq!( + (asynchronous.items, asynchronous.omission_note), + ( + vec![ + "[1] user: inspect only\n".to_owned(), + "[2] developer: approved action\n".to_owned() + ], + None, + ), + ); + assert_eq!( + asynchronous + .truncations + .into_iter() + .map(|observation| ( + observation.component, + observation.original_bytes, + observation.retained_bytes, + )) + .collect::>(), + vec![("transcript_message", "working".len(), 0)], + ); +} diff --git a/codex-rs/guardian-context/src/transcript.rs b/codex-rs/guardian-context/src/transcript.rs index b99b7cc0c1..3213a28112 100644 --- a/codex-rs/guardian-context/src/transcript.rs +++ b/codex-rs/guardian-context/src/transcript.rs @@ -1,8 +1,8 @@ //! Collects bounded conversation evidence before consumer-specific rendering. //! //! Both Guardian consumers receive the same role and tool-source attribution, -//! with per-entry caps applied before accumulation. Consumers retain their own -//! transcript selection, aggregate budgets, and formatting. Tool outputs with a +//! with per-entry caps applied before accumulation. 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. @@ -65,9 +65,8 @@ pub struct TranscriptEntryLimits { /// Aggregate limits for retaining rendered transcript entries. /// -/// Sync and async consumers keep their existing selection rules. These limits -/// configure those rules without introducing another sync/async policy selector. -/// Collection applies per-entry caps; aggregate retention remains with the host. +/// Context profiles apply the sync or async selection rules using these limits. +/// Collection applies per-entry caps before profile retention. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct TranscriptRetentionConfig { /// Budget for rendered user, developer, assistant, and reasoning entries. @@ -106,7 +105,7 @@ impl SectionContributor for ConversationTranscriptSection { /// Extracts bounded transcript entries without composing other context sections. /// /// Entries preserve conversation order and role/tool attribution. Per-entry -/// limits apply during collection; consumers own aggregate retention and rendering. +/// limits apply during collection; context profiles own aggregate retention. pub fn collect_transcript( history: &dyn SectionHistory, config: &ConversationTranscriptConfig,