Keep verified question/answer records whole under elastic budgets (#59902)

## Why

Truncating verified answers can obscure the association between questions and responses. Elastic entry caps must preserve that association.

## What changed

Mark verified answer records in root conversation and trusted user answer sections so elastic preparation keeps them intact when they fit. Replace oversized records with the protected incomplete-answer notice and record zero retained evidence bytes.

## Testing

Add a regression test with two questions and opposite answers, checking that both evidence sections omit oversized records whole and preserve complete records when the entry cap allows them.

GitOrigin-RevId: 0fe9e8c0955cb09845a929274e3d52b816119624
This commit is contained in:
jif
2026-11-06 12:49:54 +00:00
committed by copyberry
parent f49ae52079
commit b8725c1896
5 changed files with 180 additions and 8 deletions
@@ -121,8 +121,11 @@ pub(crate) fn root_conversation_items(
| GuardianRootMessage::RetainedContextScope
| GuardianRootMessage::LegacyContextScope => true,
};
let verified_answer = matches!(message, GuardianRootMessage::UserInput(_));
let text = message.render();
let item = if protected {
let item = if verified_answer {
crate::Budgeted::verified_answer(text)
} else if protected {
crate::Budgeted::protected(text)
} else {
crate::Budgeted::required(text)
+11 -4
View File
@@ -336,6 +336,7 @@ impl CollectedContext {
items
.into_iter()
.map(|item| Budgeted {
verified_answer: false,
protected: matches!(
item.kind,
RetainedInstructionKind::Framing
@@ -364,10 +365,12 @@ impl CollectedContext {
items
.into_iter()
.map(|item| {
let mut output =
Budgeted::required(ContentItem::InputText { text: item.text });
output.protected = item.protected;
output
let content = ContentItem::InputText { text: item.text };
if item.protected {
Budgeted::protected(content)
} else {
Budgeted::verified_answer(content)
}
})
.collect(),
),
@@ -396,6 +399,7 @@ impl CollectedContext {
id: "conversation_transcript",
delivery: SectionDelivery::Message(Budgeted {
protected: entry.protected,
verified_answer: entry.verified_answer,
content: Box::new(ResponseItemEnvelope::new(*message)),
retention: entry.retention,
source: entry.source,
@@ -426,6 +430,7 @@ impl CollectedContext {
};
items.push(Budgeted {
protected: entry.protected,
verified_answer: entry.verified_answer,
content,
retention: entry.retention,
source: entry.source,
@@ -499,6 +504,7 @@ impl CollectedContext {
let Budgeted {
content,
protected,
verified_answer,
source,
retention,
} = item;
@@ -524,6 +530,7 @@ impl CollectedContext {
Some(Ok(Budgeted {
content,
protected,
verified_answer,
source,
retention,
}))
@@ -45,6 +45,8 @@ pub struct Budgeted<T> {
pub content: T,
/// Host framing and exact actions use global space, outside section quotas.
pub(crate) protected: bool,
/// Host-verified question/answer records must be kept or omitted whole.
pub(crate) verified_answer: bool,
pub(crate) source: Option<codex_history::RetainedSource>,
pub(crate) retention: Retention,
}
@@ -54,6 +56,7 @@ impl<T> Budgeted<T> {
Self {
content,
protected: false,
verified_answer: false,
source: None,
retention: Retention::Required,
}
@@ -66,10 +69,18 @@ impl<T> Budgeted<T> {
}
}
pub(crate) fn verified_answer(content: T) -> Self {
Self {
verified_answer: true,
..Self::required(content)
}
}
pub(crate) fn map<U>(self, transform: impl FnOnce(T) -> U) -> Budgeted<U> {
Budgeted {
content: transform(self.content),
protected: self.protected,
verified_answer: self.verified_answer,
source: self.source,
retention: self.retention,
}
@@ -79,6 +90,7 @@ impl<T> Budgeted<T> {
Self {
content,
protected: false,
verified_answer: false,
source: None,
retention: Retention::Historical,
}
@@ -88,6 +100,7 @@ impl<T> Budgeted<T> {
Self {
content,
protected: false,
verified_answer: false,
source: None,
retention: Retention::Optional(priority),
}
@@ -1,6 +1,7 @@
//! Applies configured section text caps exactly once before allocation.
//! Supporting records remain candidates after truncation. Framing, exact actions,
//! images and opaque native payloads stay whole. Selection cannot change prepared bytes.
//! images and opaque native payloads stay whole. Verified answers that exceed their
//! entry cap are omitted whole. Selection cannot change prepared bytes.
use crate::ComposedContext;
use crate::ElasticBudgetConfig;
@@ -22,7 +23,7 @@ impl ComposedContext {
/// can only select whole entries, so later grants cannot change their text.
/// Omitted groups inherit the same defaults used by allocation.
/// The existing minimum marker/framing is retained when the cap cannot hold it.
/// Entry preparation never selects items; allocation selects whole supporting records.
/// Oversized verified answers leave an omission notice; allocation selects the remaining records.
pub fn prepare_elastic_entries(
mut self,
config: &ElasticBudgetConfig,
@@ -67,6 +68,18 @@ impl ComposedContext {
continue;
}
let original_bytes = item.content.evidence_bytes();
if item.verified_answer {
self.truncations.push(TruncationObservation {
source: item.source.clone(),
component: section.id,
original_bytes,
retained_bytes: 0,
});
*item = crate::Budgeted::protected(SectionContent::from(
crate::GuardianRootMessage::IncompleteVerifiedAnswers.render(),
));
continue;
}
truncate_content_to_fit(
&mut item.content,
maximum.saturating_sub(content_framing_tokens(/*item_count*/ 1)),
+137 -1
View File
@@ -1,23 +1,35 @@
//! Exercises cache-prefix stability through the public collection and composition API.
//! Exercises cache-prefix and evidence integrity through public collection and composition.
use codex_context_fragments::ContextualUserFragment;
use codex_guardian_context::ActionPresentation;
use codex_guardian_context::ContextBudgeting;
use codex_guardian_context::ContextPresentation;
use codex_guardian_context::ContextProfile;
use codex_guardian_context::ContextTarget;
use codex_guardian_context::ElasticBudgetConfig;
use codex_guardian_context::ElasticSection;
use codex_guardian_context::ElasticSectionLimits;
use codex_guardian_context::GuardianRootMessage;
use codex_guardian_context::PlannedAction;
use codex_guardian_context::PlannedActionKind;
use codex_guardian_context::PreviousReviews;
use codex_guardian_context::RequestBudget;
use codex_guardian_context::SectionHistory;
use codex_guardian_context::SectionInput;
use codex_guardian_context::TranscriptFormat;
use codex_guardian_context::TrustedSkills;
use codex_guardian_context::TrustedTool;
use codex_guardian_context::TrustedToolSource;
use codex_guardian_context::collect_verified_answer_candidates;
use codex_guardian_context::default_registry;
use codex_guardian_context::prepare_sync_context;
use codex_guardian_context::render_original_verified_answer;
use codex_history::RetainedContext;
use codex_history::RetainedContextEvent;
use codex_history::RetainedInputSource;
use codex_history::RetainedUserMessage;
use codex_history::VerifiedAnswer;
use codex_history::VerifiedQuestionAnswer;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::user_input::UserInput;
@@ -202,3 +214,127 @@ fn changing_retained_context_and_attestations_preserves_history_before_the_curre
}
}
}
#[test]
fn elastic_verified_answers_preserve_question_answer_identity() {
let answer = VerifiedAnswer {
turn_id: "turn".to_owned(),
call_id: "confirm".to_owned(),
questions: vec![
VerifiedQuestionAnswer {
question: format!(
"May I delete the production database? {}",
"A".repeat(2_500)
),
answer: "No.".to_owned(),
},
VerifiedQuestionAnswer {
question: format!("May I list local files instead? {}", "B".repeat(2_500)),
answer: "Yes.".to_owned(),
},
],
};
let original = render_original_verified_answer(&answer);
let mut retained = RetainedContext::default();
retained.record(
&RetainedContextEvent::VerifiedAnswer {
answer,
acceptance_order: Some(7),
},
&uuid_v7_item_id_generator_for_test(),
);
let trusted_answers = collect_verified_answer_candidates(&retained);
let root = [GuardianRootMessage::UserInput(original.clone())];
// The retained record must stand on its own after the raw transcript is compacted.
let history = History {
items: Vec::new(),
retained: Some(retained),
};
let profile = ContextProfile::synchronous();
for entry_tokens in [900, 1_800] {
let collected = default_registry()
.prepare(&SectionInput {
target: ContextTarget::Sync,
history: &history,
transcript: &profile.transcript,
root_conversation: &root,
root_conversation_candidates: None,
trusted_user_answers: &trusted_answers,
planned_action: None,
permissions: None,
previous_reviews: None,
trusted_tool: None,
trusted_skill_paths: &[],
images: None,
node_repl: None,
})
.unwrap();
let limits = ElasticSectionLimits::new(
/*min_total_section_tokens*/ entry_tokens,
/*max_section_entry_tokens*/ entry_tokens,
)
.unwrap();
let config = ElasticBudgetConfig {
sections: [
(ElasticSection::RootConversationMessages, limits),
(ElasticSection::TrustedUserAnswers, limits),
(
ElasticSection::TranscriptMessages,
// The other section defaults total 9,100 tokens; minimums must sum to 128K.
ElasticSectionLimits::new(
/*min_total_section_tokens*/ 118_900 - 2 * entry_tokens,
/*max_section_entry_tokens*/ 5_000,
)
.unwrap(),
),
]
.into(),
};
let messages = prepare_sync_context(
collected,
&profile,
/*entry_number_offset*/ 0,
ContextPresentation::SyncFull {
session_id: "worker",
},
ContextBudgeting::Elastic,
)
.unwrap()
.prepare_elastic_entries(&config)
.unwrap()
.enforce_budget(
RequestBudget {
max_input_tokens: 128_000,
existing_context_tokens: 0,
},
"Evidence omitted.".to_owned(),
)
.unwrap()
.into_messages();
let omission = GuardianRootMessage::IncompleteVerifiedAnswers.render();
let evidence = messages
.into_iter()
.flat_map(|message| match message {
ResponseItem::Message { content, .. } => content,
_ => panic!("text evidence"),
})
.filter_map(|item| match item {
ContentItem::InputText { text }
if text.contains("assistant:") || text == omission =>
{
Some(text)
}
_ => None,
})
.collect::<Vec<_>>();
let expected = if entry_tokens == 900 {
vec![omission.clone(), omission]
} else {
vec![
original.clone(),
format!("Retained source order: 7\n{original}"),
]
};
assert_eq!(evidence, expected);
}
}