Centralize Guardian transcript policy in context profiles (#43806)

## What changed

Add `ContextProfile` to `codex-guardian-context` and route synchronous and asynchronous Guardian transcript rendering through it. Move default limits, retention, formatting, and async chunked eviction into the shared crate while preserving the distinct retention policies and host-managed full/delta cursors.

Carry transcript truncation observations through `RenderedTranscript` into context composition, and derive async image collection flags from the resolved profile.

## Testing

Add a profile regression test covering distinct sync/async retention priorities, original entry numbering, omission notes, and async truncation observations. Adapt existing transcript tests to the updated input API.

GitOrigin-RevId: 7f7dc249629d8017e9547085f6617f815ae43a1b
This commit is contained in:
felixxia-oai
2026-09-08 11:57:42 +00:00
committed by copyberry
parent 0034ef93a7
commit 0337192dfd
11 changed files with 412 additions and 358 deletions

View File

@@ -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.

View File

@@ -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<String>, Option<String>) {
render_guardian_transcript_entries_with_offset(
entries,
/*entry_number_offset*/ 0,
"<no retained transcript entries>",
)
}
fn render_guardian_transcript_entries_with_offset(
entries: &[ConversationTranscriptEntry],
entry_number_offset: usize,
empty_placeholder: &str,
) -> (Vec<String>, Option<String>) {
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("<no retained transcript entries>".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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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<CollectedContext, SectionError> {
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,

View File

@@ -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 {

View File

@@ -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<TranscriptImageInput<'a>>,
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<String>, Vec<TruncationObservation>) {
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::<Vec<_>>();
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)
}
}

View File

@@ -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();

View File

@@ -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<String>,
pub omission_note: Option<String>,
pub truncations: Vec<TruncationObservation>,
}
/// 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<ComposedContext, SectionError> {
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,

View File

@@ -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;

View File

@@ -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::<Vec<_>>();
let user_messages = entries
.iter()
.enumerate()
.filter_map(|(index, entry)| {
(entry.kind == TranscriptEntryKind::User).then_some(UserMessageCost {
index,
tokens: entry.tokens,
})
})
.collect::<Vec<_>>();
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;

View File

@@ -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;

View File

@@ -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::<Vec<_>>();
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<_>>(),
vec![("transcript_message", "working".len(), 0)],
);
}

View File

@@ -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,