mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
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:
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user