Account for replay caches in encrypted compaction budgets (#53691)

## Why

Encrypted compaction payloads can include sampled-token replay caches that increase their size roughly threefold. Budgeting them as ordinary text can reject checkpoints that fit the model context budget.

## What changed

Use a shared estimate of one token per twelve ciphertext bytes, rounded up, for encrypted `Compaction` and `ContextCompaction` items in history and Guardian context budgeting. Guardian continues to count framing and metadata separately.

Check parent compactions against the configured token budget and a separate hard wire-size limit. Preserve the complete encrypted checkpoint when it fits. The token estimate remains a heuristic and can underestimate legacy checkpoints without replay caches.

## Testing

Add coverage for token-estimate rounding, parent compaction token and wire-size limits, and reviewer continuation with a cached compaction preserved intact.

GitOrigin-RevId: cc18ca29802491865167eef992830efcf93d97da
This commit is contained in:
ioanbudea-OAI
2026-10-13 15:05:16 +00:00
committed by copyberry
parent c03bbff483
commit 979771476a
10 changed files with 138 additions and 15 deletions
+6 -3
View File
@@ -1132,15 +1132,18 @@ fn estimate_response_item_model_visible_bytes(item: &ResponseItem) -> i64 {
ResponseItem::Reasoning {
encrypted_content: Some(content),
..
}
| ResponseItem::Compaction {
} => i64::try_from(estimate_reasoning_length(content.len())).unwrap_or(i64::MAX),
ResponseItem::Compaction {
encrypted_content: content,
..
}
| ResponseItem::ContextCompaction {
encrypted_content: Some(content),
..
} => i64::try_from(estimate_reasoning_length(content.len())).unwrap_or(i64::MAX),
} => i64::try_from(
codex_protocol::protocol::estimate_compaction_content_length(content.len()),
)
.unwrap_or(i64::MAX),
ResponseItem::FunctionCall {
name,
namespace,
@@ -3259,6 +3259,27 @@ fn passthrough_metadata_does_not_change_context_estimates() {
}
}
#[test]
fn encrypted_compactions_use_replay_cache_estimate() {
for (encoded_len, expected_tokens) in [(0, 0), (1, 1), (11, 1), (12, 1), (13, 2), (8_000, 667)]
{
for item in [
ResponseItem::Compaction {
id: None,
encrypted_content: "a".repeat(encoded_len),
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::ContextCompaction {
id: None,
encrypted_content: Some("a".repeat(encoded_len)),
internal_chat_message_metadata_passthrough: None,
},
] {
assert_eq!(estimate_item_token_count(&item), expected_tokens);
}
}
}
#[test]
fn encrypted_function_output_uses_plaintext_byte_estimate() {
let encrypted_content = "A".repeat(1_868);
@@ -208,6 +208,7 @@ async fn independent_review_preserves_oversized_instruction_order() -> Result<()
enum ReviewerResponse {
Decision,
ToolContinuation,
CachedCompactionContinuation,
FileImageContinuation,
UncompactableContinuation,
CompactionError,
@@ -217,6 +218,7 @@ enum ReviewerResponse {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(1, ReviewerResponse::Decision; "required_context_fails_closed")]
#[test_case(4_500, ReviewerResponse::ToolContinuation; "oversized_tool_continuation_compacts")]
#[test_case(7_000, ReviewerResponse::CachedCompactionContinuation; "cached_compaction_fits_reviewer_budget")]
#[test_case(4_500, ReviewerResponse::FileImageContinuation; "uploaded_original_image_history_compacts")]
#[test_case(5_000, ReviewerResponse::UncompactableContinuation; "ineffective_compaction_fails_closed")]
#[test_case(5_000, ReviewerResponse::CompactionError; "compaction_service_error_does_not_request_user_approval")]
@@ -284,6 +286,7 @@ async fn review_respects_complete_context_budget(
if matches!(
reviewer_response,
ReviewerResponse::ToolContinuation
| ReviewerResponse::CachedCompactionContinuation
| ReviewerResponse::FileImageContinuation
| ReviewerResponse::UncompactableContinuation
| ReviewerResponse::CompactionError
@@ -335,6 +338,11 @@ async fn review_respects_complete_context_budget(
})
.to_string(),
),
ReviewerResponse::CachedCompactionContinuation => ev_custom_tool_call(
"reviewer-inspect",
"exec",
"text('inspection-output'.repeat(1000));",
),
ReviewerResponse::ToolContinuation
| ReviewerResponse::UncompactableContinuation
| ReviewerResponse::CompactionError => ev_custom_tool_call(
@@ -356,7 +364,9 @@ async fn review_respects_complete_context_budget(
}
if matches!(
reviewer_response,
ReviewerResponse::ToolContinuation | ReviewerResponse::FileImageContinuation
ReviewerResponse::ToolContinuation
| ReviewerResponse::CachedCompactionContinuation
| ReviewerResponse::FileImageContinuation
) {
events.insert(
/*index*/ 2,
@@ -402,6 +412,13 @@ async fn review_respects_complete_context_budget(
ReviewerResponse::UncompactableContinuation
) {
"still oversized ".repeat(/*n*/ 4_000)
} else if matches!(
reviewer_response,
ReviewerResponse::CachedCompactionContinuation
) {
// The opaque wire payload also carries cached token IDs. It must fit
// after compaction without being shortened or forcing another review.
format!("Previous review evidence.{}", "a".repeat(/*n*/ 18_000))
} else {
"Previous review evidence and inspection results.".to_owned()
};
@@ -457,7 +474,9 @@ async fn review_respects_complete_context_budget(
} else {
let recovered = matches!(
reviewer_response,
ReviewerResponse::ToolContinuation | ReviewerResponse::FileImageContinuation
ReviewerResponse::ToolContinuation
| ReviewerResponse::CachedCompactionContinuation
| ReviewerResponse::FileImageContinuation
);
assert_eq!(requests.len(), if recovered { 4 } else { 3 });
assert_eq!(guardian_requests.len(), if recovered { 2 } else { 1 });
@@ -496,6 +515,17 @@ async fn review_respects_complete_context_budget(
.to_string()
.contains("Previous review evidence")
);
if matches!(
reviewer_response,
ReviewerResponse::CachedCompactionContinuation
) {
let checkpoints = guardian_requests[1].inputs_of_type("compaction");
assert_eq!(
checkpoints[0]["encrypted_content"],
format!("Previous review evidence.{}", "a".repeat(/*n*/ 18_000)),
"budgeting must preserve the complete encrypted checkpoint"
);
}
assert_eq!(
guardian_requests[0].body_json()["client_metadata"]["thread_id"],
recovered["client_metadata"]["thread_id"]
@@ -24,7 +24,7 @@ use super::transcript::TranscriptSource;
pub(crate) const DEFAULT_MODEL_CONTEXT_ITEM_TOKENS: usize = 10_000;
pub(crate) const DEFAULT_PARENT_COMPACTION_TOKENS: usize = 25_000;
const MIN_MODEL_CONTEXT_ITEM_TOKENS: usize = 100;
const MAX_MODEL_CONTEXT_ITEM_TOKENS: usize = 100_000;
pub(super) const MAX_MODEL_CONTEXT_ITEM_TOKENS: usize = 100_000;
const DEFAULT_REVIEW_THRESHOLD: f64 = 0.5;
const LEGACY_REVIEW_THRESHOLD: f64 = 0.8;
const DEFAULT_MAX_TOOL_CALL_LAG: usize = 2;
@@ -2477,7 +2477,7 @@ async fn contributor_skips_required_models_in_standard_scope() -> Result<()> {
let oversized_compaction = ResponseItem::Compaction {
id: Some(ResponseItemId::from_server("cmp_oversized".to_owned())),
encrypted_content: "a"
.repeat(TruncationPolicy::Tokens(DEFAULT_PARENT_COMPACTION_TOKENS).byte_budget()),
.repeat(3 * TruncationPolicy::Tokens(DEFAULT_PARENT_COMPACTION_TOKENS).byte_budget()),
internal_chat_message_metadata_passthrough: None,
};
registry.tool_lifecycle_contributors()[0]
@@ -3386,7 +3386,7 @@ async fn assert_parent_compaction_reuse(parent_context_for_review: bool) -> Resu
let oversized_compaction = ResponseItem::Compaction {
id: Some(ResponseItemId::from_server("cmp_oversized".to_owned())),
encrypted_content: "a".repeat(TruncationPolicy::Tokens(/*limit*/ 256).byte_budget()),
encrypted_content: "a".repeat(3 * TruncationPolicy::Tokens(/*limit*/ 256).byte_budget()),
internal_chat_message_metadata_passthrough: None,
};
let mut rejected_compactions = vec![(oversized_compaction, "parent_compaction_oversized")];
@@ -7,6 +7,7 @@ use codex_extension_api::ResponseItem;
use codex_protocol::protocol::TruncationPolicy;
use super::config::GuardianV2Config;
use super::config::MAX_MODEL_CONTEXT_ITEM_TOKENS;
use super::sampler::LunaSampler;
#[derive(Debug, Eq, PartialEq)]
@@ -72,7 +73,10 @@ fn encrypted_parent_compaction(
checkpoint: Option<codex_history::CompactionCheckpoint<'_>>,
max_parent_compaction_tokens: usize,
) -> Result<Option<ResponseItem>, ParentCompactionError> {
let max_compaction_bytes = TruncationPolicy::Tokens(max_parent_compaction_tokens).byte_budget();
// Keep the previous maximum configured wire allowance as a separate hard
// bound; the configured token budget measures model cost, not ciphertext.
let max_compaction_bytes =
TruncationPolicy::Tokens(MAX_MODEL_CONTEXT_ITEM_TOKENS).byte_budget();
let Some(checkpoint) = checkpoint else {
return Ok(None);
};
@@ -81,7 +85,10 @@ fn encrypted_parent_compaction(
}
let item = checkpoint.item;
let serialized = serde_json::to_vec(item).map_err(|_| ParentCompactionError::Serialization)?;
if serialized.len() > max_compaction_bytes {
if serialized.len() > max_compaction_bytes
|| codex_guardian_context::estimate_input_tokens_from_serialized_len(item, serialized.len())
> max_parent_compaction_tokens
{
return Err(ParentCompactionError::Oversized);
}
@@ -104,6 +104,8 @@ fn encrypted_parent_compaction_rejects_oversized_latest_item() -> Result<()> {
for item in &mut bounded {
let envelope_bytes = serde_json::to_vec(&*item)?.len();
let framing_tokens = TruncationPolicy::Bytes(envelope_bytes).token_budget();
let payload_bytes = (DEFAULT_PARENT_COMPACTION_TOKENS - framing_tokens) * 12;
let encrypted_content = match item {
ResponseItem::Compaction {
encrypted_content, ..
@@ -114,8 +116,8 @@ fn encrypted_parent_compaction_rejects_oversized_latest_item() -> Result<()> {
} => encrypted_content,
_ => unreachable!("test fixtures are encrypted compaction items"),
};
*encrypted_content = "a".repeat(max_compaction_bytes - envelope_bytes);
assert_eq!(serde_json::to_vec(&*item)?.len(), max_compaction_bytes);
*encrypted_content = "a".repeat(payload_bytes);
assert!(serde_json::to_vec(&*item)?.len() > max_compaction_bytes);
assert_eq!(
encrypted_checkpoint(std::iter::once(&*item), DEFAULT_PARENT_COMPACTION_TOKENS,),
Ok(Some(item.clone()))
@@ -134,7 +136,7 @@ fn encrypted_parent_compaction_rejects_oversized_latest_item() -> Result<()> {
}
assert_eq!(
serde_json::to_vec(&oversized)?.len(),
max_compaction_bytes + 1
envelope_bytes + payload_bytes + 1
);
assert_eq!(
encrypted_checkpoint(
@@ -166,6 +168,19 @@ fn encrypted_parent_compaction_rejects_oversized_latest_item() -> Result<()> {
"oversized passthrough metadata must not bypass the complete-item limit"
);
let item = ResponseItem::Compaction {
id: Some(ResponseItemId::from_server("cmp_wire_limit".to_owned())),
encrypted_content: "a"
.repeat(TruncationPolicy::Tokens(MAX_MODEL_CONTEXT_ITEM_TOKENS).byte_budget()),
internal_chat_message_metadata_passthrough: None,
};
assert!(codex_guardian_context::estimate_input_tokens(&item) < MAX_MODEL_CONTEXT_ITEM_TOKENS);
assert_eq!(
encrypted_checkpoint(std::iter::once(&item), MAX_MODEL_CONTEXT_ITEM_TOKENS),
Err(ParentCompactionError::Oversized),
"even a checkpoint below the token limit must fit the wire-size limit"
);
Ok(())
}
+35 -2
View File
@@ -186,15 +186,48 @@ impl ComposedContext {
}
}
/// Conservative estimate including roles, framing, metadata and opaque checkpoints.
/// Coarse estimate including roles, framing, metadata and opaque checkpoints.
/// Images reserve tokens separately from their wire bytes. This is a budgeting
/// heuristic, not the model's tokenizer or a measurement of server-side usage.
pub fn estimate_input_tokens(item: &ResponseItem) -> usize {
estimate_input_tokens_from_serialized_len(item, ByteCount::item(item))
}
/// Estimate input tokens while reusing the length of an existing serialization.
/// `serialized_len` must be the byte length of the item's `serde_json` encoding.
pub fn estimate_input_tokens_from_serialized_len(
item: &ResponseItem,
serialized_len: usize,
) -> usize {
if serialized_len == usize::MAX {
return usize::MAX;
}
if let Some(encrypted_content) = compaction_ciphertext(item) {
let framing =
TruncationPolicy::Bytes(serialized_len.saturating_sub(encrypted_content.len()))
.token_budget();
let content =
codex_protocol::protocol::estimate_compaction_content_length(encrypted_content.len());
return framing.saturating_add(TruncationPolicy::Bytes(content).token_budget());
}
let content = match item {
ResponseItem::Message { content, .. } => content.as_slice(),
_ => &[],
};
adjusted_tokens(ByteCount::item(item), content)
adjusted_tokens(serialized_len, content)
}
fn compaction_ciphertext(item: &ResponseItem) -> Option<&str> {
match item {
ResponseItem::Compaction {
encrypted_content, ..
}
| ResponseItem::ContextCompaction {
encrypted_content: Some(encrypted_content),
..
} => Some(encrypted_content),
_ => None,
}
}
pub(super) fn section_content_tokens(item: &SectionContent) -> usize {
+1
View File
@@ -72,6 +72,7 @@ pub use budget::SECTION_COST_METRIC;
pub use budget::SectionCost;
pub use budget::effective_input_token_limit;
pub use budget::estimate_input_tokens;
pub use budget::estimate_input_tokens_from_serialized_len;
pub use cursor::TranscriptCursor;
pub use cursor::TranscriptMode;
pub use cursor::TranscriptSelection;
+13
View File
@@ -3480,6 +3480,19 @@ impl TurnContextItem {
}
}
/// Estimate the model-visible content length of an encrypted compaction.
///
/// The payload can contain summary text, sampled-token replay caches, and
/// encryption/base64 overhead. Normalize the roughly threefold payload growth
/// observed with replay caches before applying the usual four-byte text
/// heuristic: twelve ciphertext bytes per estimated token, rounded up.
/// This restores the earlier budgeting scale, not exact tokenizer counts, and
/// can underestimate legacy checkpoints without caches. Callers must enforce
/// transport-size limits separately.
pub fn estimate_compaction_content_length(encoded_len: usize) -> usize {
encoded_len.div_ceil(3)
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(tag = "mode", content = "limit", rename_all = "snake_case")]
pub enum TruncationPolicy {