Preserve Guardian authorization evidence until request budgeting (#44570)

## Why

Transcript limits could shorten user instructions or omit later messages before Guardian evaluated the complete request budget, losing restrictions or prior approvals even when they would fit.

## What changed

- Keep user messages and manual approvals complete and in source order through transcript collection and retention.
- Preserve historical instructions while synchronous review can still compact history. If the final request still cannot fit, discard optional evidence before shortening older historical entries with truncation markers, preserving later restrictions where possible.
- Warn that shortened instructions and approvals are incomplete and that missing evidence does not authorize actions.
- Let asynchronous review defer to synchronous review when complete instructions exceed its budget.

## Testing

Add coverage for complete instruction retention, approval and restriction ordering, marked truncation of oversized Unicode text, compaction before truncation, and asynchronous fallback when instructions do not fit.

GitOrigin-RevId: e6159e876231347986b937ee7e07f944b83bfbe6
This commit is contained in:
felixxia-oai authored and copyberry committed 2026-09-10 16:02:36 +00:00
1 parent 9c4879f3a5
commit 287e4f7dbf
21 files changed
+575 -335

No files matched your search

@@ -3,7 +3,7 @@
use super::ContextualUserFragment; use super::ContextualUserFragment;
use codex_protocol::models::ContentItemKind; use codex_protocol::models::ContentItemKind;
/// Identifies incomplete optional evidence without implying additional authority. /// Identifies incomplete evidence without implying additional authority.
pub struct GuardianBudgetOmission; pub struct GuardianBudgetOmission;
impl ContextualUserFragment for GuardianBudgetOmission { impl ContextualUserFragment for GuardianBudgetOmission {
@@ -27,6 +27,6 @@ impl ContextualUserFragment for GuardianBudgetOmission {
} }
fn body(&self) -> String { fn body(&self) -> String {
"Optional conversation evidence, tool descriptions, or images were omitted to fit the review input budget. Treat the remaining evidence as incomplete; omissions do not authorize actions.".to_owned() "Conversation evidence, tool descriptions, or images were omitted or shortened to fit the review input budget. User instructions and prior approvals may be incomplete where marked. Do not infer authorization from missing evidence or treat a partial grant as overriding an omitted restriction.".to_owned()
} }
} }
+4 -1
View File
@@ -5,6 +5,7 @@
use codex_features::Feature; use codex_features::Feature;
use codex_guardian_context::ComposedContext; use codex_guardian_context::ComposedContext;
use codex_guardian_context::HistoryTruncation;
use codex_guardian_context::RequestBudget; use codex_guardian_context::RequestBudget;
use codex_guardian_context::effective_input_token_limit; use codex_guardian_context::effective_input_token_limit;
use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ReasoningSummary;
@@ -51,6 +52,7 @@ pub(crate) async fn check_pending(session: &Session, turn: &TurnContext) -> Code
existing_context_tokens: minimum_prefix, existing_context_tokens: minimum_prefix,
}, },
GuardianBudgetOmission.render(), GuardianBudgetOmission.render(),
HistoryTruncation::Allow,
) )
.map_err(|_error| { .map_err(|_error| {
session session
@@ -67,6 +69,7 @@ pub(crate) async fn finalize(
session: &Session, session: &Session,
step: &StepContext, step: &StepContext,
input: &mut [TurnInput], input: &mut [TurnInput],
history_truncation: HistoryTruncation,
) -> CodexResult<()> { ) -> CodexResult<()> {
let Some(pending) = session let Some(pending) = session
.services .services
@@ -145,7 +148,7 @@ pub(crate) async fn finalize(
), ),
}; };
let context = context let context = context
.enforce_budget(budget, GuardianBudgetOmission.render()) .enforce_budget(budget, GuardianBudgetOmission.render(), history_truncation)
.map_err(|error| { .map_err(|error| {
session session
.services .services
@@ -128,7 +128,11 @@ async fn finalization_overflow_marks_the_reviewer_exhausted() {
.thread_extension_data .thread_extension_data
.insert(PendingReviewContext(context)); .insert(PendingReviewContext(context));
assert!(finalize(&session, &step, &mut input).await.is_err()); assert!(
finalize(&session, &step, &mut input, HistoryTruncation::Preserve)
.await
.is_err()
);
assert!( assert!(
session session
.services .services
+6 -8
View File
@@ -1778,10 +1778,11 @@ fn build_guardian_transcript_preserves_recent_tool_context_when_user_history_is_
let entries = collect_guardian_transcript_entries(&items, GUARDIAN_MAX_TOOL_ENTRY_TOKENS); let entries = collect_guardian_transcript_entries(&items, GUARDIAN_MAX_TOOL_ENTRY_TOKENS);
let (transcript, omission) = render_guardian_transcript_entries(&entries); let (transcript, omission) = render_guardian_transcript_entries(&entries);
assert!( assert_eq!(
transcript transcript[..8],
.iter() (1..=8)
.any(|entry| entry.starts_with("[1] user: ")) .map(|index| format!("[{index}] user: {repeated}"))
.collect::<Vec<_>>()
); );
assert!(transcript.iter().any(|entry| { assert!(transcript.iter().any(|entry| {
entry.contains("tool shell call:") entry.contains("tool shell call:")
@@ -1794,10 +1795,7 @@ fn build_guardian_transcript_preserves_recent_tool_context_when_user_history_is_
.any(|entry| entry .any(|entry| entry
.contains("tool shell result: sandbox blocked outbound network access")) .contains("tool shell result: sandbox blocked outbound network access"))
); );
assert_eq!( assert_eq!(omission, None);
omission,
Some("Some conversation entries were omitted.".to_string())
);
} }
enum GuardianTestCatalog { enum GuardianTestCatalog {
+14 -3
View File
@@ -321,8 +321,13 @@ pub(crate) async fn run_turn(
return Ok(None); return Ok(None);
} }
if crate::guardian::is_basic_session_source(&turn_context.session_source) if crate::guardian::is_basic_session_source(&turn_context.session_source)
&& let Err(error) = && let Err(error) = crate::guardian::finalize_guardian_input(
crate::guardian::finalize_guardian_input(&sess, &first_step_context, &mut input).await &sess,
&first_step_context,
&mut input,
codex_guardian_context::HistoryTruncation::Preserve,
)
.await
{ {
// Token-budget compaction resets history, which can discard the evidence // Token-budget compaction resets history, which can discard the evidence
// referenced by a pending delta review. Leave budget failures unreusable. // referenced by a pending delta review. Leave budget failures unreusable.
@@ -349,7 +354,13 @@ pub(crate) async fn run_turn(
world_state = sess world_state = sess
.record_context_updates_and_set_reference_context_item(first_step_context.as_ref()) .record_context_updates_and_set_reference_context_item(first_step_context.as_ref())
.await?; .await?;
crate::guardian::finalize_guardian_input(&sess, &first_step_context, &mut input).await?; crate::guardian::finalize_guardian_input(
&sess,
&first_step_context,
&mut input,
codex_guardian_context::HistoryTruncation::Allow,
)
.await?;
} }
let mut can_drain_pending_input = input.is_empty(); let mut can_drain_pending_input = input.is_empty();
if run_hooks_and_record_inputs( if run_hooks_and_record_inputs(
@@ -9,6 +9,8 @@ use codex_core::config::CurrentTimeReminderConfig;
use codex_core::config::RolloutBudgetConfig; use codex_core::config::RolloutBudgetConfig;
use codex_features::Feature; use codex_features::Feature;
use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::AutoReviewMessages; use codex_protocol::openai_models::AutoReviewMessages;
use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::EventMsg;
@@ -30,6 +32,168 @@ use pretty_assertions::assert_eq;
use serde_json::json; use serde_json::json;
use test_case::test_case; use test_case::test_case;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(160_000, &[0, 0]; "complete_instructions_fit")]
#[test_case(28_000, &[0, 1]; "oversized_history_compacts_then_truncates")]
#[test_case(16_000, &[1]; "first_review_can_shorten_oversized_instructions")]
async fn review_preserves_user_instructions_until_request_budgeting(
window: i64,
compactions_per_turn: &[usize],
) -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_wine_exec!(
Ok(()),
"Guardian approval actions require host-native paths"
);
let server = responses::start_mock_server().await;
let test = test_codex()
.with_model_info_override("gpt-5.6-luna", move |model| {
model.context_window = Some(window);
})
.with_model_info_override("gpt-5.5", |model| {
model.auto_review_model_override = Some("gpt-5.6-luna".to_owned());
})
.with_config(|config| {
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
})
.build_with_auto_env(&server)
.await?;
let command = json!({
"cmd": "echo complete-instructions",
"sandbox_permissions": "require_escalated",
"justification": "Run the requested command."
})
.to_string();
let events = compactions_per_turn
.iter()
.enumerate()
.flat_map(|(turn, compactions)| {
let mut events = vec![
sse(vec![
ev_function_call(&format!("exec-{turn}"), "exec_command", &command),
ev_completed(&format!("parent-{turn}")),
]),
sse(vec![
ev_assistant_message(
"decision",
r#"{"risk_level":"low","user_authorization":"high","outcome":"allow"}"#,
),
ev_completed(&format!("review-{turn}")),
]),
sse(vec![
ev_assistant_message("done", "done"),
ev_completed(&format!("done-{turn}")),
]),
];
for _ in 0..*compactions {
events.insert(
/*index*/ 1,
sse(vec![
json!({
"type": "response.output_item.done",
"item": {"type": "compaction", "encrypted_content": "Earlier reviewer context."},
}),
ev_completed("review-compaction"),
]),
);
}
events
})
.collect();
let responses = responses::mount_sse_sequence(&server, events).await;
let padding = "é🙂 ".repeat(/*n*/ 3_500);
let initial = format!(
"{padding}Run the requested echo command. You may edit scratch files only.{padding}"
);
test.submit_text_turn(&initial).await?;
// This whole message exceeds the old transcript allowance. A following
// restriction must still reach the reviewer, with the original source order.
let followup = format!("{padding}{padding}Keep all files private.{padding}{padding}");
let approval = format!(
"{}\nApproved action: {command}",
codex_guardian_context::MANUAL_APPROVAL_DEVELOPER_PREFIX
);
let restriction = "Revoke permission to edit files. Only run the echo command.";
// V2 retains the first review's user input. The smallest window exercises
// first-review truncation only; the larger windows cover follow-up delivery.
if compactions_per_turn.len() == 2 {
test.codex
.inject_response_items(vec![
responses::user_message_item(&followup),
ResponseItem::Message {
id: None,
role: "developer".to_owned(),
content: vec![ContentItem::InputText {
text: approval.clone(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
])
.await?;
test.submit_text_turn(restriction).await?;
}
let (compact_requests, requests): (Vec<_>, Vec<_>) = responses
.requests()
.into_iter()
.partition(|request| !request.inputs_of_type("compaction_trigger").is_empty());
let reviews = requests
.iter()
.filter(|request| request.body_json()["client_metadata"]["x-openai-subagent"] == "guardian")
.collect::<Vec<_>>();
assert_eq!(reviews.len(), compactions_per_turn.len());
let initial_inputs = reviews[0].message_input_text_groups("user");
let initial_context = initial_inputs.last().expect("first review input").concat();
if compactions_per_turn[0] == 0 {
assert!(initial_context.contains(&initial));
} else {
assert!(initial_context.contains("<truncated omitted_approx_tokens="));
}
assert_eq!(
compact_requests.len(),
compactions_per_turn.iter().sum::<usize>()
);
if let Some(review) = reviews.get(1) {
let delta_inputs = review.message_input_text_groups("user");
let delta = delta_inputs.last().expect("delta review input");
let delta_context = delta.concat();
if window == 160_000 {
assert!(delta_context.contains(&followup));
} else {
assert!(delta_context.contains("<truncated omitted_approx_tokens="));
assert!(
delta_context.contains(
"User instructions and prior approvals may be incomplete where marked."
)
);
}
let approval_start = delta_context
.find(&approval)
.expect("complete prior approval");
let restriction_start = delta_context.find(restriction).expect("later restriction");
assert!(approval_start + approval.len() < restriction_start);
assert!(
!delta_context.contains(&initial),
"old entries are not resent"
);
assert_eq!(
reviews[0].body_json()["client_metadata"]["thread_id"],
review.body_json()["client_metadata"]["thread_id"]
);
}
assert!(
requests
.last()
.expect("parent resumes after review")
.function_call_output(&format!("exec-{}", reviews.len() - 1))
.to_string()
.contains("complete-instructions")
);
test.codex.shutdown_and_wait().await?;
Ok(())
}
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
enum ReviewerResponse { enum ReviewerResponse {
Decision, Decision,
@@ -1,6 +1,7 @@
//! Contributor integration tests use the normal catalog-backed lifecycle setup. //! Contributor integration tests use the normal catalog-backed lifecycle setup.
use super::*; use super::*;
use crate::async_scorer::authorization::ScoreAuthorization;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -63,6 +64,7 @@ async fn catalog_budget_fixture(base_url: String, window: i64) -> Result<Guardia
enum BudgetEvidence { enum BudgetEvidence {
Checkpoint, Checkpoint,
Image, Image,
UserInstructions,
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -77,11 +79,18 @@ async fn contributor_preserves_optional_evidence_or_defers_to_sync() -> Result<(
assert_catalog_budget(BudgetEvidence::Image).await assert_catalog_budget(BudgetEvidence::Image).await
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn contributor_preserves_complete_user_instructions_or_defers_to_sync() -> Result<()> {
skip_if_no_network!(Ok(()));
assert_catalog_budget(BudgetEvidence::UserInstructions).await
}
async fn assert_catalog_budget(evidence: BudgetEvidence) -> Result<()> { async fn assert_catalog_budget(evidence: BudgetEvidence) -> Result<()> {
let windows = match evidence { let windows = match evidence {
BudgetEvidence::Checkpoint => [18_000, 32_000], BudgetEvidence::Checkpoint => [18_000, 32_000],
// In the smaller window the text fits, but the 10K image reservation cannot. // In the smaller window the text fits, but the 10K image reservation cannot.
BudgetEvidence::Image => [14_000, 40_000], BudgetEvidence::Image => [14_000, 40_000],
BudgetEvidence::UserInstructions => [18_000, 50_000],
}; };
for (window, outcome) in windows for (window, outcome) in windows
.into_iter() .into_iter()
@@ -97,8 +106,18 @@ async fn assert_catalog_budget(evidence: BudgetEvidence) -> Result<()> {
]), ]),
) )
.await; .await;
let instruction = "Inspect README.md; do not change files."; let instruction = if matches!(evidence, BudgetEvidence::UserInstructions) {
let mut user = user_instruction(instruction); let padding = "é🙂 ".repeat(/*n*/ 3_500);
format!("{padding}You may edit files only in the scratch directory.{padding}")
} else {
"Inspect README.md; do not change files.".to_owned()
};
let restriction = "Revoke permission to edit files. Only read README.md.";
let approval = format!(
"{}\nApproved action: read_file README.md",
codex_guardian_context::MANUAL_APPROVAL_DEVELOPER_PREFIX
);
let mut user = user_instruction(&instruction);
let (checkpoint, commentary) = match evidence { let (checkpoint, commentary) = match evidence {
BudgetEvidence::Checkpoint => ( BudgetEvidence::Checkpoint => (
Some(ResponseItem::ContextCompaction { Some(ResponseItem::ContextCompaction {
@@ -125,9 +144,22 @@ async fn assert_catalog_budget(evidence: BudgetEvidence) -> Result<()> {
}); });
(None, vec!["optional old commentary ".repeat(/*n*/ 500)]) (None, vec!["optional old commentary ".repeat(/*n*/ 500)])
} }
BudgetEvidence::UserInstructions => (None, Vec::new()),
}; };
let mut history = checkpoint.iter().cloned().collect::<Vec<_>>(); let mut history = checkpoint.iter().cloned().collect::<Vec<_>>();
history.push(user); history.push(user);
if matches!(evidence, BudgetEvidence::UserInstructions) {
history.push(ResponseItem::Message {
id: None,
role: "developer".to_owned(),
content: vec![ContentItem::InputText {
text: approval.clone(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
});
history.push(user_instruction(restriction));
}
history.extend(commentary.into_iter().map(|text| ResponseItem::Message { history.extend(commentary.into_iter().map(|text| ResponseItem::Message {
id: None, id: None,
role: "assistant".to_owned(), role: "assistant".to_owned(),
@@ -146,9 +178,32 @@ async fn assert_catalog_budget(evidence: BudgetEvidence) -> Result<()> {
current: TestConversationHistory(history), current: TestConversationHistory(history),
compaction_model_hash: Some("budget-checkpoint".to_owned()), compaction_model_hash: Some("budget-checkpoint".to_owned()),
}), }),
BudgetEvidence::Image => fixture.test.codex.conversation_history_snapshot().await, BudgetEvidence::Image | BudgetEvidence::UserInstructions => {
fixture.test.codex.conversation_history_snapshot().await
}
}; };
let thread_store = fixture.test.codex.thread_extension_data(); let thread_store = fixture.test.codex.thread_extension_data();
if matches!(evidence, BudgetEvidence::UserInstructions) {
thread_store.insert(SecurityRiskScore {
scores: BTreeMap::from([("action_risk".to_owned(), 0.25)]),
call_id: None,
action: None,
sampled_at: None,
});
let progress = thread_store.get::<GuardianV2ScoreProgress>().unwrap();
let authorization = ScoreAuthorization::current(&fixture.test.codex).await;
*progress.authorization.lock().unwrap() = Some(authorization);
assert_eq!(
cached_approval(
&fixture.registry,
thread_store,
"review action",
/*metrics*/ None
)
.await,
Some(ReviewDecision::Approved)
);
}
let turn_store = ExtensionData::new("turn-1"); let turn_store = ExtensionData::new("turn-1");
let tool_name = ToolName::plain("read_file"); let tool_name = ToolName::plain("read_file");
let payload = ToolPayload::Function { let payload = ToolPayload::Function {
@@ -192,16 +247,22 @@ async fn assert_catalog_budget(evidence: BudgetEvidence) -> Result<()> {
} }
}) })
.await?; .await?;
let request = response.single_request().body_json(); let request = response.single_request();
let text = request.message_input_texts("user").concat();
let request = request.body_json();
let input = request["input"].to_string(); let input = request["input"].to_string();
if let Some(checkpoint) = checkpoint { if let Some(checkpoint) = checkpoint {
assert_eq!(request["input"][2], serde_json::to_value(checkpoint)?); assert_eq!(request["input"][2], serde_json::to_value(checkpoint)?);
assert!(input.contains("commentary 0:")); assert!(input.contains("commentary 0:"));
} else { } else if matches!(evidence, BudgetEvidence::Image) {
assert!(input.contains("input_image")); assert!(input.contains("input_image"));
assert!(input.contains("optional old commentary")); assert!(input.contains("optional old commentary"));
} else {
assert!(text.contains(&format!(
"[1] user: {instruction}\n[2] developer: {approval}\n[3] user: {restriction}\n"
)));
} }
assert!(input.contains(instruction)); assert!(text.contains(&instruction));
assert!(input.contains("read_file")); assert!(input.contains("read_file"));
assert_eq!( assert_eq!(
cached_approval( cached_approval(
@@ -2825,9 +2825,12 @@ async fn contributor_sends_compacted_conversation_history_to_luna() -> Result<()
.filter_map(|entry| entry["text"].as_str()) .filter_map(|entry| entry["text"].as_str())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert!(entries.iter().any(|entry| entry.contains("user turn 0:"))); for index in 0..8 {
assert!(entries.iter().any(|entry| entry.contains("user turn 7:"))); assert!(entries.iter().any(|entry| entry.contains(&format!(
assert!(!entries.iter().any(|entry| entry.contains("user turn 1:"))); "user turn {index}: {}",
"authorization ".repeat(/*n*/ 1_000)
))));
}
assert!( assert!(
entries entries
.iter() .iter()
@@ -2876,6 +2879,9 @@ async fn contributor_sends_compacted_conversation_history_to_luna() -> Result<()
for entry in entries.into_iter().filter(|entry| entry.starts_with('[')) { for entry in entries.into_iter().filter(|entry| entry.starts_with('[')) {
let (label, text) = entry.split_once(": ").expect("numbered transcript entry"); let (label, text) = entry.split_once(": ").expect("numbered transcript entry");
if label.ends_with(" user") {
continue;
}
let max_tokens = if label.contains("tool ") { let max_tokens = if label.contains("tool ") {
MAX_TOOL_ENTRY_TOKENS MAX_TOOL_ENTRY_TOKENS
} else { } else {
@@ -222,7 +222,7 @@ fn transcript_keeps_conversation_and_configured_sources() {
} }
#[test] #[test]
fn transcript_truncates_oversized_entries_without_splitting_characters() { fn transcript_truncates_oversized_assistant_entries_without_splitting_characters() {
let prefix = "start é"; let prefix = "start é";
let suffix = "é end"; let suffix = "é end";
let oversized_message = format!( let oversized_message = format!(
@@ -231,24 +231,8 @@ fn transcript_truncates_oversized_entries_without_splitting_characters() {
); );
let original_bytes = oversized_message.len(); let original_bytes = oversized_message.len();
let items = vec![ let items = vec![
ResponseItem::Message { assistant_message(oversized_message, MessagePhase::FinalAnswer),
id: None, assistant_message("latest response", MessagePhase::FinalAnswer),
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: oversized_message,
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "latest response".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
]; ];
let mut rendered = TranscriptConfig::default() let mut rendered = TranscriptConfig::default()
@@ -268,14 +252,14 @@ fn transcript_truncates_oversized_entries_without_splitting_characters() {
let transcript = rendered.transcript_entries(); let transcript = rendered.transcript_entries();
assert_eq!(transcript.len(), 2); assert_eq!(transcript.len(), 2);
let user_entry = &transcript[0]; let entry = &transcript[0];
assert!(user_entry.starts_with(&format!("[1] user: {prefix}"))); assert!(entry.starts_with(&format!("[1] assistant: {prefix}")));
assert!(user_entry.contains("<truncated omitted_approx_tokens=\"")); assert!(entry.contains("<truncated omitted_approx_tokens=\""));
assert!(user_entry.ends_with(&format!("{suffix}\n"))); assert!(entry.ends_with(&format!("{suffix}\n")));
assert!( assert!(
user_entry.len() entry.len()
<= TruncationPolicy::Tokens(MAX_MESSAGE_ENTRY_TOKENS).byte_budget() <= TruncationPolicy::Tokens(MAX_MESSAGE_ENTRY_TOKENS).byte_budget()
+ "[1] user: \n".len() + "[1] assistant: \n".len()
); );
assert_eq!(transcript[1], "[2] assistant: latest response\n"); assert_eq!(transcript[1], "[2] assistant: latest response\n");
assert_eq!( assert_eq!(
@@ -288,81 +272,13 @@ fn transcript_truncates_oversized_entries_without_splitting_characters() {
)) ))
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
vec![( vec![(
"transcript_user", "transcript_message",
original_bytes, original_bytes,
user_entry.len() - "[1] user: \n".len() entry.len() - "[1] assistant: \n".len()
)] )]
); );
} }
#[test]
fn transcript_preserves_first_and_latest_user_messages_and_recent_history() {
let oversized_user_message = "authorization ".repeat(1_000);
let mut items = (0..8)
.map(|index| ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!("user turn {index}: {oversized_user_message}"),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
})
.collect::<Vec<_>>();
items.push(ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "Most recent assistant context.".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
});
let transcript = TranscriptConfig::default()
.build_context(ContextInput {
target: ContextTarget::Async,
history: &TestConversationHistory(&items),
root_conversation: &[],
trusted_user_answers: &[],
planned_action: None,
previous_reviews: None,
trusted_tool: None,
trusted_skill_paths: &[],
node_repl_images: None,
})
.expect("collect transcript")
.transcript_entries();
assert!(transcript[0].starts_with("[1] user: user turn 0:"));
assert!(
transcript
.iter()
.any(|entry| entry.starts_with("[8] user: user turn 7:"))
);
assert!(
!transcript
.iter()
.any(|entry| entry.contains("user turn 1:"))
);
assert!(
transcript
.iter()
.any(|entry| entry.contains("user turn 6:"))
);
assert_eq!(
transcript.last().map(String::as_str),
Some("[9] assistant: Most recent assistant context.\n")
);
assert!(
transcript
.iter()
.map(|entry| TruncationPolicy::Bytes(entry.len()).token_budget())
.sum::<usize>()
<= MAX_MESSAGE_TRANSCRIPT_TOKENS
);
}
#[test] #[test]
fn transcript_preserves_user_restrictions_before_final_assistant_messages() { fn transcript_preserves_user_restrictions_before_final_assistant_messages() {
let first_user_entry = "[1] user: Find a flight to New York.\n"; let first_user_entry = "[1] user: Find a flight to New York.\n";
@@ -1190,62 +1106,52 @@ fn transcript_preserves_outputs_with_call_ids_or_explicit_names() {
#[test] #[test]
fn configured_reasoning_counts_against_message_budget() { fn configured_reasoning_counts_against_message_budget() {
let mut items = (0..8) for (repeats, include_reasoning) in [(200, true), (1_000, false)] {
.map(|index| ResponseItem::Message { let mut expected = Vec::new();
let mut items = (0..8)
.map(|index| {
let text = format!("user turn {index}: {}", "authorization ".repeat(repeats));
expected.push(format!("[{}] user: {text}\n", index + 1));
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText { text }],
phase: None,
internal_chat_message_metadata_passthrough: None,
}
})
.collect::<Vec<_>>();
items.push(ResponseItem::Reasoning {
id: None, id: None,
role: "user".to_string(), summary: vec![ReasoningItemReasoningSummary::SummaryText {
content: vec![ContentItem::InputText { text: "Recent reasoning evidence.".to_string(),
text: format!("user turn {index}: {}", "authorization ".repeat(1_000)),
}], }],
phase: None, content: None,
encrypted_content: None,
internal_chat_message_metadata_passthrough: None, internal_chat_message_metadata_passthrough: None,
});
if include_reasoning {
expected.push("[9] reasoning: Recent reasoning evidence.\n".to_owned());
}
let transcript = TranscriptConfig {
sources: vec![TranscriptSource::Reasoning],
..TranscriptConfig::default()
}
.build_context(ContextInput {
target: ContextTarget::Async,
history: &TestConversationHistory(&items),
root_conversation: &[],
trusted_user_answers: &[],
planned_action: None,
previous_reviews: None,
trusted_tool: None,
trusted_skill_paths: &[],
node_repl_images: None,
}) })
.collect::<Vec<_>>(); .expect("collect transcript")
items.push(ResponseItem::Reasoning { .transcript_entries();
id: None, assert_eq!(transcript, expected);
summary: vec![ReasoningItemReasoningSummary::SummaryText {
text: "Recent reasoning evidence.".to_string(),
}],
content: None,
encrypted_content: None,
internal_chat_message_metadata_passthrough: None,
});
let transcript = TranscriptConfig {
sources: vec![TranscriptSource::Reasoning],
..TranscriptConfig::default()
} }
.build_context(ContextInput {
target: ContextTarget::Async,
history: &TestConversationHistory(&items),
root_conversation: &[],
trusted_user_answers: &[],
planned_action: None,
previous_reviews: None,
trusted_tool: None,
trusted_skill_paths: &[],
node_repl_images: None,
})
.expect("collect transcript")
.transcript_entries();
assert!(transcript[0].contains("user turn 0:"));
assert!(
transcript
.iter()
.any(|entry| entry.contains("user turn 7:"))
);
assert_eq!(
transcript.last().map(String::as_str),
Some("[9] reasoning: Recent reasoning evidence.\n")
);
assert!(
transcript
.iter()
.map(|entry| TruncationPolicy::Bytes(entry.len()).token_budget())
.sum::<usize>()
<= MAX_MESSAGE_TRANSCRIPT_TOKENS
);
} }
#[test] #[test]
+1 -1
View File
@@ -25,7 +25,7 @@ pub enum ContextPresentation<'a> {
Async, Async,
} }
/// Host-selected, already bounded transcript entries and omission notice. /// Host-selected transcript entries and omission notice, before request admission.
pub struct RenderedTranscript { pub struct RenderedTranscript {
pub items: Vec<Budgeted<String>>, pub items: Vec<Budgeted<String>>,
pub omission_note: Option<String>, pub omission_note: Option<String>,
+81 -5
View File
@@ -1,11 +1,13 @@
//! Fits newly composed evidence into the remaining complete-request allowance. //! Fits newly composed evidence into the remaining complete-request allowance.
//! Required evidence is never truncated. Optional content is removed in a stable //! Required action evidence is never truncated. Optional evidence leaves first;
//! order, and a host-owned omission fragment is reserved before any removal. //! hosts may shorten historical instructions after compaction cannot make room.
//! Every reduction reserves an omission notice and preserves source order.
use std::collections::HashSet; use std::collections::HashSet;
use codex_protocol::models::ContentItem; use codex_protocol::models::ContentItem;
use codex_protocol::models::ImageDetail; use codex_protocol::models::ImageDetail;
use codex_protocol::protocol::TruncationPolicy;
use crate::ComposedContext; use crate::ComposedContext;
use crate::RequestBudget; use crate::RequestBudget;
@@ -42,6 +44,13 @@ impl<T> Budgeted<T> {
} }
} }
pub(crate) fn historical(content: T) -> Self {
Self {
content,
retention: Retention::Historical,
}
}
pub(crate) fn optional(content: T, priority: BudgetPriority) -> Self { pub(crate) fn optional(content: T, priority: BudgetPriority) -> Self {
Self { Self {
content, content,
@@ -62,9 +71,19 @@ impl<T> std::fmt::Debug for Budgeted<T> {
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum Retention { pub(crate) enum Retention {
Required, Required,
Historical,
Optional(BudgetPriority), Optional(BudgetPriority),
} }
/// Whether the host has reached the final attempt to fit historical evidence.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HistoryTruncation {
/// Preserve original instructions while the host can still compact history.
Preserve,
/// Shorten older historical entries only after optional evidence is exhausted.
Allow,
}
impl ComposedContext { impl ComposedContext {
/// Applies host image admission before aggregate selection, preserving section /// Applies host image admission before aggregate selection, preserving section
/// identity and each retained item's selection policy. /// identity and each retained item's selection policy.
@@ -77,12 +96,13 @@ impl ComposedContext {
} }
} }
/// Returns complete evidence that fits, or no context at all. The host owns /// Fits evidence according to the host recovery phase, or returns no context.
/// the bounded omission fragment and the existing reviewer history/checkpoint. /// The host owns the bounded omission fragment and existing reviewer history.
pub fn enforce_budget( pub fn enforce_budget(
mut self, mut self,
budget: RequestBudget, budget: RequestBudget,
omission_notice: String, omission_notice: String,
history_truncation: HistoryTruncation,
) -> Result<Self, SectionError> { ) -> Result<Self, SectionError> {
let remaining = budget let remaining = budget
.max_input_tokens .max_input_tokens
@@ -130,6 +150,62 @@ impl ComposedContext {
remaining_items[section_index] = content.len(); remaining_items[section_index] = content.len();
} }
} }
let mut history_truncated = false;
if history_truncation == HistoryTruncation::Allow && required_tokens > remaining {
// Source order makes older evidence yield first. Keep each source's
// label, both ends and the standard marker; later restrictions stay
// complete whenever older entries can supply the needed space.
'history: for section in &mut self.sections {
let SectionDelivery::UserContent(content) = &mut section.delivery else {
continue;
};
for item in content {
if item.retention != Retention::Historical {
continue;
}
let original_tokens = content_tokens(&item.content);
let ContentItem::InputText { text } = &mut item.content else {
continue;
};
let target = original_tokens.saturating_sub(required_tokens - remaining);
let mut upper = TruncationPolicy::Bytes(text.len()).token_budget();
let mut lower = 32.min(upper);
let mut shortened = crate::truncate_text(text, lower);
while lower < upper {
let mid = lower + (upper - lower).div_ceil(2);
let candidate = crate::truncate_text(text, mid);
if content_tokens(&ContentItem::InputText {
text: candidate.clone(),
}) <= target
{
lower = mid;
shortened = candidate;
} else {
upper = mid - 1;
}
}
let saved =
original_tokens.saturating_sub(content_tokens(&ContentItem::InputText {
text: shortened.clone(),
}));
if saved == 0 {
continue;
}
self.truncations.push(TruncationObservation {
component: section.id,
original_bytes: text.len(),
retained_bytes: shortened.len(),
});
*text = shortened;
history_truncated = true;
required_tokens = required_tokens.saturating_sub(saved);
needed = needed.saturating_sub(saved);
if required_tokens <= remaining {
break 'history;
}
}
}
}
// Evidence that cannot fit beside the required content and notice must // Evidence that cannot fit beside the required content and notice must
// leave first, without evicting useful smaller entries on its behalf. // leave first, without evicting useful smaller entries on its behalf.
let optional_allowance = let optional_allowance =
@@ -162,7 +238,7 @@ impl ComposedContext {
} }
needed = needed.saturating_sub(remove(section_index, index, tokens)); needed = needed.saturating_sub(remove(section_index, index, tokens));
} }
if removed.is_empty() { if removed.is_empty() && !history_truncated {
return Err(SectionError::EvidenceLimitExceeded { return Err(SectionError::EvidenceLimitExceeded {
section: "request_budget", section: "request_budget",
}); });
@@ -12,6 +12,86 @@ fn text(value: &str) -> ContentItem {
} }
} }
#[test]
fn recovery_shortens_older_history_only_after_optional_evidence() {
let older = format!("[1] user: {}original suffix", "é🙂\"\n".repeat(/*n*/ 6_000));
let commentary = text(&"optional commentary ".repeat(/*n*/ 1_000));
let approval = text("[3] developer: user approved this action");
let restriction = text("[4] user: only modify scratch files");
let action = text("complete action");
let notice = SectionOutput {
id: "budget_omission",
delivery: SectionDelivery::UserContent(vec![Budgeted::required(text(
"evidence omitted or shortened",
))]),
};
let context = ComposedContext {
sections: vec![SectionOutput {
id: "conversation_transcript",
delivery: SectionDelivery::UserContent(vec![
Budgeted::historical(text(&older)),
Budgeted::optional(commentary.clone(), BudgetPriority::Commentary),
Budgeted::historical(approval.clone()),
Budgeted::historical(restriction.clone()),
Budgeted::required(action.clone()),
]),
}],
truncations: Vec::new(),
};
for reduction in [0, 4_000] {
let available = context.estimated_tokens() - content_tokens(&commentary)
+ section_tokens(&notice)
- reduction;
let budget = RequestBudget {
max_input_tokens: available + 2_000,
existing_context_tokens: 2_000,
};
if reduction > 0 {
assert!(
context
.clone()
.enforce_budget(
budget,
"evidence omitted or shortened".to_owned(),
HistoryTruncation::Preserve
)
.is_err()
);
}
let selected = context
.clone()
.enforce_budget(
budget,
"evidence omitted or shortened".to_owned(),
HistoryTruncation::Allow,
)
.unwrap();
assert!(selected.estimated_tokens() <= available);
let SectionDelivery::UserContent(content) = &selected.sections[0].delivery else {
panic!("expected user evidence")
};
let ContentItem::InputText { text: retained } = &content[0].content else {
panic!("expected historical text")
};
if reduction == 0 {
assert_eq!(retained, &older);
} else {
assert!(retained.starts_with("[1] user: "));
assert!(retained.ends_with("original suffix"));
assert!(retained.contains("<truncated omitted_approx_tokens="));
}
assert_eq!(
content,
&vec![
Budgeted::historical(text(retained)),
Budgeted::historical(approval.clone()),
Budgeted::historical(restriction.clone()),
Budgeted::required(action.clone()),
]
);
}
}
#[test] #[test]
fn planned_action_budget_omits_descriptions_without_changing_arguments() { fn planned_action_budget_omits_descriptions_without_changing_arguments() {
let action = crate::PlannedAction { let action = crate::PlannedAction {
@@ -46,6 +126,7 @@ fn planned_action_budget_omits_descriptions_without_changing_arguments() {
existing_context_tokens: 0, existing_context_tokens: 0,
}, },
"evidence omitted".to_owned(), "evidence omitted".to_owned(),
HistoryTruncation::Preserve,
) )
.unwrap(); .unwrap();
// The sync preamble is also required and remains ahead of the action. // The sync preamble is also required and remains ahead of the action.
@@ -115,6 +196,7 @@ fn budget_reserves_existing_context_and_preserves_required_messages() {
existing_context_tokens: 2_000, existing_context_tokens: 2_000,
}, },
"evidence omitted".to_owned(), "evidence omitted".to_owned(),
HistoryTruncation::Preserve,
) )
.unwrap(); .unwrap();
assert!(context.estimated_tokens() <= available); assert!(context.estimated_tokens() <= available);
@@ -137,6 +219,7 @@ fn budget_reserves_existing_context_and_preserves_required_messages() {
existing_context_tokens: 0, existing_context_tokens: 0,
}, },
"evidence omitted".to_owned(), "evidence omitted".to_owned(),
HistoryTruncation::Preserve,
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -177,6 +260,7 @@ fn image_omission_preserves_text_and_later_eviction_policy() {
existing_context_tokens: 0, existing_context_tokens: 0,
}, },
"evidence omitted".to_owned(), "evidence omitted".to_owned(),
HistoryTruncation::Preserve,
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -197,6 +281,7 @@ fn image_omission_preserves_text_and_later_eviction_policy() {
existing_context_tokens: 0, existing_context_tokens: 0,
}, },
"evidence omitted".to_owned(), "evidence omitted".to_owned(),
HistoryTruncation::Preserve,
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -210,6 +295,7 @@ fn image_omission_preserves_text_and_later_eviction_policy() {
existing_context_tokens: 0, existing_context_tokens: 0,
}, },
"evidence omitted".to_owned(), "evidence omitted".to_owned(),
HistoryTruncation::Preserve,
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -261,6 +347,7 @@ fn image_omission_preserves_text_and_later_eviction_policy() {
existing_context_tokens: 0, existing_context_tokens: 0,
}, },
"evidence omitted".to_owned(), "evidence omitted".to_owned(),
HistoryTruncation::Preserve,
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
+3 -3
View File
@@ -1,8 +1,8 @@
//! Structured transcript evidence shared by synchronous and asynchronous Guardian. //! Structured transcript evidence shared by synchronous and asynchronous Guardian.
//! //!
//! Entry kinds preserve source attribution for consumer-specific retention and //! Entry kinds preserve source attribution for consumer-specific retention and
//! rendering. Text is bounded during collection, with its original size retained //! rendering. User text and manual approvals stay complete for admission; other text is
//! for truncation accounting. //! capped during collection, with its original size retained for accounting.
/// Semantic role of one parent-conversation transcript entry. /// Semantic role of one parent-conversation transcript entry.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
@@ -45,7 +45,7 @@ impl ConversationTranscriptEntryKind {
pub struct ConversationTranscriptEntry { pub struct ConversationTranscriptEntry {
/// Semantic role used for consumer-specific retention and truncation. /// Semantic role used for consumer-specific retention and truncation.
pub kind: ConversationTranscriptEntryKind, pub kind: ConversationTranscriptEntryKind,
/// Text bounded by the current request's per-entry limits. /// Complete authorization evidence, or other text bounded by per-entry limits.
pub text: String, pub text: String,
/// Size before truncation, retained for omission and truncation accounting. /// Size before truncation, retained for omission and truncation accounting.
pub original_bytes: usize, pub original_bytes: usize,
+1 -4
View File
@@ -29,9 +29,6 @@ pub use section::ContextSection;
pub use entry::ConversationTranscriptEntry; pub use entry::ConversationTranscriptEntry;
pub use entry::ConversationTranscriptEntryKind; pub use entry::ConversationTranscriptEntryKind;
pub use history::TranscriptHistory; pub use history::TranscriptHistory;
pub use retention::UserMessageCost;
pub use retention::UserMessageSelection;
pub use retention::select_user_messages;
pub use transcript::ConversationTranscriptConfig; pub use transcript::ConversationTranscriptConfig;
pub use transcript::ConversationTranscriptOptions; pub use transcript::ConversationTranscriptOptions;
pub use transcript::MANUAL_APPROVAL_DEVELOPER_PREFIX; pub use transcript::MANUAL_APPROVAL_DEVELOPER_PREFIX;
@@ -51,6 +48,7 @@ mod action;
mod enforcement; mod enforcement;
pub(crate) use enforcement::BudgetPriority; pub(crate) use enforcement::BudgetPriority;
pub use enforcement::Budgeted; pub use enforcement::Budgeted;
pub use enforcement::HistoryTruncation;
pub(crate) use enforcement::Retention; pub(crate) use enforcement::Retention;
mod budget; mod budget;
mod composition; mod composition;
@@ -92,7 +90,6 @@ pub use reviews::RenderedReviewEvidence;
pub use reviews::ReviewEvidence; pub use reviews::ReviewEvidence;
pub use reviews::render_review_evidence; pub use reviews::render_review_evidence;
pub use truncation::TruncationObservation; pub use truncation::TruncationObservation;
mod retention;
mod section; mod section;
pub use permissions::PermissionContext; pub use permissions::PermissionContext;
mod transcript; mod transcript;
+28 -27
View File
@@ -1,6 +1,8 @@
//! Resolved Guardian evidence profiles and pure transcript retention. //! Resolved Guardian evidence profiles and pure transcript retention.
//! Sync keeps recent entries; async protects approvals/final answers and evicts //! Sync keeps recent entries; async protects approvals/final answers and evicts
//! in cacheable chunks. Hosts still own history snapshots and delivery cursors. //! in cacheable chunks. Hosts still own history snapshots and delivery cursors.
//! User messages and manual approvals stay complete until whole-request admission.
//! They may be shortened with explicit markers only during budget recovery.
use codex_protocol::protocol::TruncationPolicy; use codex_protocol::protocol::TruncationPolicy;
@@ -16,8 +18,6 @@ use crate::Retention;
use crate::TranscriptEntryLimits; use crate::TranscriptEntryLimits;
use crate::TranscriptRetentionConfig; use crate::TranscriptRetentionConfig;
use crate::TruncationObservation; use crate::TruncationObservation;
use crate::UserMessageCost;
use crate::select_user_messages;
use self::window::TranscriptWindow; use self::window::TranscriptWindow;
mod window; mod window;
@@ -37,6 +37,7 @@ pub struct ContextProfile {
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum TranscriptEntryKind { enum TranscriptEntryKind {
User, User,
ManualApproval,
ProtectedMessage, ProtectedMessage,
Message, Message,
Tool, Tool,
@@ -115,8 +116,10 @@ impl ContextProfile {
.map(|(index, entry)| { .map(|(index, entry)| {
let kind = match &entry.kind { let kind = match &entry.kind {
ConversationTranscriptEntryKind::User => TranscriptEntryKind::User, ConversationTranscriptEntryKind::User => TranscriptEntryKind::User,
ConversationTranscriptEntryKind::Developer ConversationTranscriptEntryKind::Developer => {
| ConversationTranscriptEntryKind::ProtectedAssistant => { TranscriptEntryKind::ManualApproval
}
ConversationTranscriptEntryKind::ProtectedAssistant => {
TranscriptEntryKind::ProtectedMessage TranscriptEntryKind::ProtectedMessage
} }
ConversationTranscriptEntryKind::Assistant ConversationTranscriptEntryKind::Assistant
@@ -143,30 +146,27 @@ impl ContextProfile {
} }
}) })
.collect::<Vec<_>>(); .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()]; let mut included = vec![false; entries.len()];
for index in selection.indices { let mut authorization_tokens = 0usize;
included[index] = true; for (index, entry) in entries.iter().enumerate() {
if matches!(
entry.kind,
TranscriptEntryKind::User | TranscriptEntryKind::ManualApproval
) {
included[index] = true;
authorization_tokens = authorization_tokens.saturating_add(entry.tokens);
}
} }
match self.target { match self.target {
ContextTarget::Sync => { ContextTarget::Sync => {
let mut message_tokens = selection.tokens; let mut message_tokens = authorization_tokens;
let mut tool_tokens = 0usize; let mut tool_tokens = 0usize;
let mut retained_non_user_entries = 0; let mut retained_non_user_entries = 0;
for (index, entry) in entries.iter().enumerate().rev() { for (index, entry) in entries.iter().enumerate().rev() {
if entry.kind == TranscriptEntryKind::User if matches!(
|| retained_non_user_entries >= self.retention.max_recent_non_user_entries entry.kind,
TranscriptEntryKind::User | TranscriptEntryKind::ManualApproval
) || retained_non_user_entries >= self.retention.max_recent_non_user_entries
{ {
continue; continue;
} }
@@ -189,7 +189,7 @@ impl ContextProfile {
let available = self let available = self
.retention .retention
.max_message_transcript_tokens .max_message_transcript_tokens
.saturating_sub(selection.tokens); .saturating_sub(authorization_tokens);
let mut window = TranscriptWindow::new(&entries, &self.retention, available); let mut window = TranscriptWindow::new(&entries, &self.retention, available);
for index in 0..entries.len() { for index in 0..entries.len() {
window.insert(index); window.insert(index);
@@ -209,9 +209,9 @@ impl ContextProfile {
.filter_map(|(index, entry)| { .filter_map(|(index, entry)| {
let component = match entry.kind { let component = match entry.kind {
TranscriptEntryKind::User => "transcript_user", TranscriptEntryKind::User => "transcript_user",
TranscriptEntryKind::ProtectedMessage | TranscriptEntryKind::Message => { TranscriptEntryKind::ManualApproval
"transcript_message" | TranscriptEntryKind::ProtectedMessage
} | TranscriptEntryKind::Message => "transcript_message",
TranscriptEntryKind::Tool => "transcript_tool", TranscriptEntryKind::Tool => "transcript_tool",
}; };
let retained_bytes = if included[index] { let retained_bytes = if included[index] {
@@ -228,9 +228,10 @@ impl ContextProfile {
} }
if included[index] { if included[index] {
Some(match entry.kind { Some(match entry.kind {
TranscriptEntryKind::User | TranscriptEntryKind::ProtectedMessage => { TranscriptEntryKind::User | TranscriptEntryKind::ManualApproval => {
Budgeted::required(entry.text) Budgeted::historical(entry.text)
} }
TranscriptEntryKind::ProtectedMessage => Budgeted::required(entry.text),
TranscriptEntryKind::Message => { TranscriptEntryKind::Message => {
Budgeted::optional(entry.text, BudgetPriority::Commentary) Budgeted::optional(entry.text, BudgetPriority::Commentary)
} }
@@ -66,8 +66,10 @@ impl<'a> TranscriptWindow<'a> {
let kind = self.entries[index].kind; let kind = self.entries[index].kind;
let tokens = self.entries[index].tokens; let tokens = self.entries[index].tokens;
if kind == TranscriptEntryKind::User if matches!(
|| self.max_entries == 0 kind,
TranscriptEntryKind::User | TranscriptEntryKind::ManualApproval
) || self.max_entries == 0
|| !self.make_room_for_token_budget(kind, tokens) || !self.make_room_for_token_budget(kind, tokens)
|| !self.make_room_for_entry_limit(kind) || !self.make_room_for_entry_limit(kind)
{ {
@@ -91,7 +93,9 @@ impl<'a> TranscriptWindow<'a> {
self.message_token_budget self.message_token_budget
} }
TranscriptEntryKind::Tool => self.tool_token_budget, TranscriptEntryKind::Tool => self.tool_token_budget,
TranscriptEntryKind::User => unreachable!("user entries were selected separately"), TranscriptEntryKind::User | TranscriptEntryKind::ManualApproval => {
unreachable!("user entries were selected separately")
}
}; };
let protected_tokens = if kind == TranscriptEntryKind::Message { let protected_tokens = if kind == TranscriptEntryKind::Message {
self.protected_messages.tokens self.protected_messages.tokens
@@ -111,7 +115,9 @@ impl<'a> TranscriptWindow<'a> {
], ],
TranscriptEntryKind::Message => &[TranscriptEntryKind::Message], TranscriptEntryKind::Message => &[TranscriptEntryKind::Message],
TranscriptEntryKind::Tool => &[TranscriptEntryKind::Tool], TranscriptEntryKind::Tool => &[TranscriptEntryKind::Tool],
TranscriptEntryKind::User => unreachable!("user entries were selected separately"), TranscriptEntryKind::User | TranscriptEntryKind::ManualApproval => {
unreachable!("user entries were selected separately")
}
}; };
let entries = self.entries; let entries = self.entries;
@@ -201,7 +207,9 @@ impl<'a> TranscriptWindow<'a> {
.tokens .tokens
.saturating_add(self.ordinary_messages.tokens), .saturating_add(self.ordinary_messages.tokens),
TranscriptEntryKind::Tool => self.tools.tokens, TranscriptEntryKind::Tool => self.tools.tokens,
TranscriptEntryKind::User => unreachable!("user entries were selected separately"), TranscriptEntryKind::User | TranscriptEntryKind::ManualApproval => {
unreachable!("user entries were selected separately")
}
}; };
retained_tokens.saturating_add(tokens) <= token_budget retained_tokens.saturating_add(tokens) <= token_budget
@@ -212,7 +220,9 @@ impl<'a> TranscriptWindow<'a> {
TranscriptEntryKind::ProtectedMessage => &self.protected_messages, TranscriptEntryKind::ProtectedMessage => &self.protected_messages,
TranscriptEntryKind::Message => &self.ordinary_messages, TranscriptEntryKind::Message => &self.ordinary_messages,
TranscriptEntryKind::Tool => &self.tools, TranscriptEntryKind::Tool => &self.tools,
TranscriptEntryKind::User => unreachable!("user entries were selected separately"), TranscriptEntryKind::User | TranscriptEntryKind::ManualApproval => {
unreachable!("user entries were selected separately")
}
} }
} }
@@ -221,7 +231,9 @@ impl<'a> TranscriptWindow<'a> {
TranscriptEntryKind::ProtectedMessage => &mut self.protected_messages, TranscriptEntryKind::ProtectedMessage => &mut self.protected_messages,
TranscriptEntryKind::Message => &mut self.ordinary_messages, TranscriptEntryKind::Message => &mut self.ordinary_messages,
TranscriptEntryKind::Tool => &mut self.tools, TranscriptEntryKind::Tool => &mut self.tools,
TranscriptEntryKind::User => unreachable!("user entries were selected separately"), TranscriptEntryKind::User | TranscriptEntryKind::ManualApproval => {
unreachable!("user entries were selected separately")
}
} }
} }
} }
@@ -8,8 +8,8 @@ fn profiles_preserve_distinct_retention_and_original_numbering() {
let entries = [ let entries = [
(ConversationTranscriptEntryKind::User, "inspect only"), (ConversationTranscriptEntryKind::User, "inspect only"),
( (
ConversationTranscriptEntryKind::Developer, ConversationTranscriptEntryKind::ProtectedAssistant,
"approved action", "proposed action",
), ),
(ConversationTranscriptEntryKind::Assistant, "working"), (ConversationTranscriptEntryKind::Assistant, "working"),
] ]
@@ -30,7 +30,7 @@ fn profiles_preserve_distinct_retention_and_original_numbering() {
(sync.items, sync.omission_note), (sync.items, sync.omission_note),
( (
vec![ vec![
Budgeted::required("[8] user: inspect only".to_owned()), Budgeted::historical("[8] user: inspect only".to_owned()),
Budgeted::optional( Budgeted::optional(
"[10] assistant: working".to_owned(), "[10] assistant: working".to_owned(),
BudgetPriority::Commentary BudgetPriority::Commentary
@@ -43,8 +43,8 @@ fn profiles_preserve_distinct_retention_and_original_numbering() {
(asynchronous.items, asynchronous.omission_note), (asynchronous.items, asynchronous.omission_note),
( (
vec![ vec![
Budgeted::required("[1] user: inspect only\n".to_owned()), Budgeted::historical("[1] user: inspect only\n".to_owned()),
Budgeted::required("[2] developer: approved action\n".to_owned()) Budgeted::required("[2] assistant: proposed action\n".to_owned())
], ],
None, None,
), ),
@@ -1,48 +0,0 @@
//! Shared user-message selection, independent of rendering and non-user eviction.
//!
//! Callers supply costs including their own formatting. The first user message
//! remains an anchor even when it exceeds the budget, preserving existing behavior.
/// Rendered cost and original transcript position of a genuine user message.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct UserMessageCost {
pub index: usize,
pub tokens: usize,
}
/// Selected original indices and their total rendered token cost.
#[derive(Debug, Default, Eq, PartialEq)]
pub struct UserMessageSelection {
pub indices: Vec<usize>,
pub tokens: usize,
}
/// Keeps the first and latest user messages, then fills from newest to oldest.
///
/// Supply messages in conversation order with distinct original indices. The
/// returned indices are in selection order; callers render in conversation order.
/// Non-user evidence uses the remaining budget under the caller's own policy.
pub fn select_user_messages(
messages: &[UserMessageCost],
max_message_tokens: usize,
) -> UserMessageSelection {
let Some((first, remaining)) = messages.split_first() else {
return UserMessageSelection::default();
};
let mut selection = UserMessageSelection {
indices: vec![first.index],
tokens: first.tokens,
};
// The latest message is considered first, including when it cannot fit.
for message in remaining.iter().rev() {
if selection.tokens.saturating_add(message.tokens) <= max_message_tokens {
selection.indices.push(message.index);
selection.tokens += message.tokens;
}
}
selection
}
#[cfg(test)]
#[path = "retention_tests.rs"]
mod tests;
@@ -1,48 +0,0 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn user_selection_preserves_anchors_and_fills_from_newest() {
let messages = [
UserMessageCost {
index: 2,
tokens: 4,
},
UserMessageCost {
index: 5,
tokens: 3,
},
UserMessageCost {
index: 8,
tokens: 6,
},
UserMessageCost {
index: 10,
tokens: 2,
},
];
for (budget, indices, tokens) in [
(0, vec![2], 4),
(4, vec![2], 4),
(6, vec![2, 10], 6),
(9, vec![2, 10, 5], 9),
(12, vec![2, 10, 8], 12),
(15, vec![2, 10, 8, 5], 15),
] {
assert_eq!(
select_user_messages(&messages, budget),
UserMessageSelection { indices, tokens },
);
}
assert_eq!(
select_user_messages(&[], /*max_message_tokens*/ 10),
UserMessageSelection::default(),
);
assert_eq!(
select_user_messages(&messages[..1], /*max_message_tokens*/ 10),
UserMessageSelection {
indices: vec![2],
tokens: 4
},
);
}
+21 -14
View File
@@ -1,7 +1,7 @@
//! Collects bounded conversation evidence before consumer-specific rendering. //! Collects bounded conversation evidence before consumer-specific rendering.
//! //!
//! Both Guardian consumers receive the same role and tool-source attribution, //! Both Guardian consumers receive the same role and tool-source attribution,
//! with per-entry caps applied before accumulation. Resolved context profiles //! with complete user messages and capped non-user entries. Resolved context profiles
//! apply aggregate retention after the host selects its full/delta slice. Tool outputs with a //! 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 //! call ID retain their generic label when the call is unavailable. Outputs
//! without a call ID require an explicit name. //! without a call ID require an explicit name.
@@ -55,7 +55,7 @@ impl Default for ConversationTranscriptOptions {
/// Per-entry caps resolved by the caller for the current review. /// Per-entry caps resolved by the caller for the current review.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TranscriptEntryLimits { pub struct TranscriptEntryLimits {
/// Cap for user, developer, assistant, and plaintext reasoning entries. /// Cap for assistant and plaintext reasoning entries; user and manual approvals stay complete.
pub message_tokens: usize, pub message_tokens: usize,
/// Cap for tool calls and ordinary tool outputs. /// Cap for tool calls and ordinary tool outputs.
pub tool_tokens: usize, pub tool_tokens: usize,
@@ -66,14 +66,15 @@ pub struct TranscriptEntryLimits {
/// Aggregate limits for retaining rendered transcript entries. /// Aggregate limits for retaining rendered transcript entries.
/// ///
/// Context profiles apply the sync or async selection rules using these limits. /// Context profiles apply the sync or async selection rules using these limits.
/// Collection applies per-entry caps before profile retention. /// User messages and manual approvals survive these soft limits; the complete
/// request budget can shorten them with markers after other recovery is exhausted.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TranscriptRetentionConfig { pub struct TranscriptRetentionConfig {
/// Budget for rendered user, developer, assistant, and reasoning entries. /// Budget for rendered user, developer, assistant, and reasoning entries.
pub max_message_transcript_tokens: usize, pub max_message_transcript_tokens: usize,
/// Separate budget for rendered tool calls and results. /// Separate budget for rendered tool calls and results.
pub max_tool_transcript_tokens: usize, pub max_tool_transcript_tokens: usize,
/// Maximum retained entries other than user messages. /// Maximum retained entries other than user messages and manual approvals.
pub max_recent_non_user_entries: usize, pub max_recent_non_user_entries: usize,
} }
@@ -104,7 +105,7 @@ impl SectionContributor for ConversationTranscriptSection {
/// Extracts bounded transcript entries without composing other context sections. /// Extracts bounded transcript entries without composing other context sections.
/// ///
/// Entries preserve conversation order and role/tool attribution. Per-entry /// Entries preserve conversation order and role/tool attribution. Non-user
/// limits apply during collection; context profiles own aggregate retention. /// limits apply during collection; context profiles own aggregate retention.
pub fn collect_transcript( pub fn collect_transcript(
history: &dyn SectionHistory, history: &dyn SectionHistory,
@@ -302,22 +303,28 @@ pub fn collect_transcript(
if text.trim().is_empty() { if text.trim().is_empty() {
continue; continue;
} }
let token_cap = match &kind { let original_bytes = text.len();
ConversationTranscriptEntryKind::User let text = match &kind {
| ConversationTranscriptEntryKind::Developer ConversationTranscriptEntryKind::User | ConversationTranscriptEntryKind::Developer => {
| ConversationTranscriptEntryKind::Assistant text
}
ConversationTranscriptEntryKind::Assistant
| ConversationTranscriptEntryKind::ProtectedAssistant | ConversationTranscriptEntryKind::ProtectedAssistant
| ConversationTranscriptEntryKind::Reasoning => config.entry_limits.message_tokens, | ConversationTranscriptEntryKind::Reasoning => {
truncate_text(&text, config.entry_limits.message_tokens)
}
ConversationTranscriptEntryKind::ToolCall(_) ConversationTranscriptEntryKind::ToolCall(_)
| ConversationTranscriptEntryKind::ToolOutput(_) => config.entry_limits.tool_tokens, | ConversationTranscriptEntryKind::ToolOutput(_) => {
truncate_text(&text, config.entry_limits.tool_tokens)
}
ConversationTranscriptEntryKind::NodeReplToolOutput(_) => { ConversationTranscriptEntryKind::NodeReplToolOutput(_) => {
config.entry_limits.node_repl_output_tokens truncate_text(&text, config.entry_limits.node_repl_output_tokens)
} }
}; };
entries.push(ConversationTranscriptEntry { entries.push(ConversationTranscriptEntry {
kind, kind,
text: truncate_text(&text, token_cap), text,
original_bytes: text.len(), original_bytes,
}); });
} }
@@ -41,7 +41,10 @@ fn entry(kind: ConversationTranscriptEntryKind, text: &str) -> ConversationTrans
#[test] #[test]
fn registered_transcript_preserves_shared_roles_and_node_repl_tool_attribution() { fn registered_transcript_preserves_shared_roles_and_node_repl_tool_attribution() {
let approved_action = format!("{MANUAL_APPROVAL_DEVELOPER_PREFIX}\nApproved action: {{}}"); let approved_action = format!(
"{MANUAL_APPROVAL_DEVELOPER_PREFIX}\nApproved action: {}",
"exact action ".repeat(/*n*/ 1_000)
);
let history = vec![ let history = vec![
ResponseItem::Message { ResponseItem::Message {
id: None, id: None,
@@ -340,7 +343,7 @@ fn reused_registry_applies_current_history_sources_and_entry_limits() {
transcript_items(&sections[0]), transcript_items(&sections[0]),
vec![ConversationTranscriptEntry { vec![ConversationTranscriptEntry {
kind: ConversationTranscriptEntryKind::User, kind: ConversationTranscriptEntryKind::User,
text: truncate_text(&text, message_tokens), text: text.clone(),
original_bytes: text.len(), original_bytes: text.len(),
}] }]
); );
@@ -377,7 +380,7 @@ fn reused_registry_applies_current_history_sources_and_entry_limits() {
.expect("transcript collection should succeed"); .expect("transcript collection should succeed");
let mut expected = vec![ConversationTranscriptEntry { let mut expected = vec![ConversationTranscriptEntry {
kind: ConversationTranscriptEntryKind::User, kind: ConversationTranscriptEntryKind::User,
text: truncate_text(&text, /*max_tokens*/ 60), text: text.clone(),
original_bytes: text.len(), original_bytes: text.len(),
}]; }];
if include_tool_calls { if include_tool_calls {