mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
Centralize Guardian context mode and checkpoint policy (#43458)
## What changed Resolve `GuardianContextMode` once at session construction and share it across history retention, replay, evidence capture, compaction, and review consumers. Extract synchronous reviewer context policy and consolidate Guardian V2 parent checkpoint selection so sampling and fast approval use the same eligibility checks. Preserve legacy checkpoint omission and strict compatibility requirements for thread-owned context. ## Testing Adapt existing review and history tests to the explicit context mode. Move checkpoint tests alongside the extracted selector, retaining coverage for latest-item identity, unusable checkpoints, and complete-item size limits. GitOrigin-RevId: c73fb6fea556921acfffe758a5f709d7494019dd
This commit is contained in:
1 parent
f3f53ee949
commit
8260619cb6
26 files changed
+599
-482
No files matched your search
@@ -5,13 +5,13 @@ use super::authorization::ScoreAuthorization;
|
||||
use super::config::GuardianV2Config;
|
||||
use super::coverage::GuardianPolicy;
|
||||
use super::extension::GuardianV2ScoreProgress;
|
||||
use super::extension::encrypted_parent_compaction;
|
||||
use super::extension::requires_sync_for_compaction;
|
||||
use super::metrics::TOOL_CALL_LAG_METRIC;
|
||||
use super::metrics::record_fast_decision;
|
||||
use super::parent_compaction::select_parent_compaction;
|
||||
use super::sampler::LunaSampler;
|
||||
use codex_core::CodexThread;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_core::context::GuardianContextMode;
|
||||
use codex_core::context::GuardianReviewEvidence;
|
||||
use codex_extension_api::ApprovalDecision;
|
||||
use codex_extension_api::ApprovalDecisionInput;
|
||||
@@ -132,17 +132,22 @@ async fn cached_evidence(
|
||||
record_fast_decision(metrics, "deferred", "missing_score");
|
||||
return Err(GuardianReviewReason::MissingScore);
|
||||
};
|
||||
if store
|
||||
let context_mode = store
|
||||
.get_or_init(GuardianReviewEvidence::default)
|
||||
.uses_thread_owned_context()
|
||||
{
|
||||
.context_mode();
|
||||
if context_mode == GuardianContextMode::ThreadOwned {
|
||||
let sampler = store
|
||||
.get::<LunaSampler>()
|
||||
.ok_or(GuardianReviewReason::MissingScore)?;
|
||||
let history = thread.conversation_history_snapshot().await;
|
||||
if requires_sync_for_compaction(config, history.as_ref(), &sampler)
|
||||
|| encrypted_parent_compaction(history.items(), config.max_parent_compaction_tokens)
|
||||
.is_err()
|
||||
if select_parent_compaction(
|
||||
context_mode,
|
||||
config,
|
||||
history.as_ref(),
|
||||
&sampler,
|
||||
/*legacy_model_hash*/ None,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
record_fast_decision(metrics, "deferred", "incompatible_compaction");
|
||||
return Err(GuardianReviewReason::IncompatibleCompaction);
|
||||
|
||||
@@ -12,10 +12,10 @@ use codex_analytics::GuardianV2Event;
|
||||
use codex_analytics::GuardianV2EventKind;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::context::GuardianContextMode;
|
||||
use codex_core::context::GuardianReviewEvidence;
|
||||
use codex_core::context::NodeReplReviewEvidence;
|
||||
use codex_extension_api::ApprovalReviewContributor;
|
||||
use codex_extension_api::ConversationHistorySnapshot;
|
||||
use codex_extension_api::ExtensionData;
|
||||
use codex_extension_api::ExtensionEventSink;
|
||||
use codex_extension_api::ExtensionFuture;
|
||||
@@ -23,7 +23,6 @@ use codex_extension_api::ExtensionMetrics;
|
||||
use codex_extension_api::ExtensionRegistryBuilder;
|
||||
use codex_extension_api::ExtensionWarning;
|
||||
use codex_extension_api::GuardianV2Enabled;
|
||||
use codex_extension_api::ResponseItem;
|
||||
use codex_extension_api::SkillInvocationContributor;
|
||||
use codex_extension_api::SkillInvocationInput;
|
||||
use codex_extension_api::ThreadLifecycleContributor;
|
||||
@@ -45,7 +44,6 @@ use codex_protocol::openai_models::GuardianReviewMode;
|
||||
use codex_protocol::openai_models::GuardianScope;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::TruncationPolicy;
|
||||
use codex_protocol::protocol::has_full_access;
|
||||
use codex_protocol::security_risk::SecurityRiskScore;
|
||||
|
||||
@@ -61,6 +59,8 @@ use super::metrics::record_classification;
|
||||
use super::metrics::record_classification_risk;
|
||||
use super::metrics::record_fast_decision;
|
||||
use super::metrics::sampler_failure_reason;
|
||||
use super::parent_compaction::ParentCompactionError;
|
||||
use super::parent_compaction::select_parent_compaction;
|
||||
use super::review_evidence::render_review_evidence;
|
||||
use super::sampler::LunaSampler;
|
||||
use super::sampler::LunaSamplerConfig;
|
||||
@@ -263,7 +263,7 @@ impl ApprovalReviewContributor for GuardianV2Extension {
|
||||
return None;
|
||||
}
|
||||
let guardian_evidence = thread_store.get_or_init(GuardianReviewEvidence::default);
|
||||
let thread_context_enabled = guardian_evidence.uses_thread_owned_context();
|
||||
let context_mode = guardian_evidence.context_mode();
|
||||
let mut initial_cua_call = false;
|
||||
if scope == Some(GuardianScope::ComputerUse) && policy.initial_cua_call {
|
||||
// Thread-owned context checks checkpoint compatibility before the first CUA allowance.
|
||||
@@ -278,7 +278,7 @@ impl ApprovalReviewContributor for GuardianV2Extension {
|
||||
.js_executions
|
||||
.load(Ordering::Acquire)
|
||||
== 1;
|
||||
if initial_cua_call && !thread_context_enabled {
|
||||
if initial_cua_call && context_mode == GuardianContextMode::Legacy {
|
||||
record_fast_decision(
|
||||
extension_metrics.as_deref(),
|
||||
"approved",
|
||||
@@ -323,14 +323,16 @@ impl ApprovalReviewContributor for GuardianV2Extension {
|
||||
return None;
|
||||
};
|
||||
let history = thread.conversation_history_snapshot().await;
|
||||
if thread_context_enabled {
|
||||
if context_mode == GuardianContextMode::ThreadOwned {
|
||||
let sampler = thread_store.get::<LunaSampler>()?;
|
||||
if requires_sync_for_compaction(&guardian_config, history.as_ref(), &sampler)
|
||||
|| encrypted_parent_compaction(
|
||||
history.items(),
|
||||
guardian_config.max_parent_compaction_tokens,
|
||||
)
|
||||
.is_err()
|
||||
if select_parent_compaction(
|
||||
context_mode,
|
||||
&guardian_config,
|
||||
history.as_ref(),
|
||||
&sampler,
|
||||
/*legacy_model_hash*/ None,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
thread_store.insert(StrictReviewReason::IncompatibleCompaction);
|
||||
record_fast_decision(
|
||||
@@ -608,64 +610,38 @@ impl GuardianV2Extension {
|
||||
let guardian_evidence = input
|
||||
.thread_store
|
||||
.get_or_init(GuardianReviewEvidence::default);
|
||||
let thread_context_enabled = guardian_evidence.uses_thread_owned_context();
|
||||
if thread_context_enabled
|
||||
&& requires_sync_for_compaction(
|
||||
&guardian_config,
|
||||
input.conversation_history.as_ref(),
|
||||
&sampler,
|
||||
)
|
||||
{
|
||||
score_progress
|
||||
.latest_failed_tool_call
|
||||
.fetch_max(tool_call_index, Ordering::Release);
|
||||
Self::record_fail_closed_score(input.thread_store, sampled_at);
|
||||
record_classification(
|
||||
metrics.as_deref(),
|
||||
classification_started_at.elapsed(),
|
||||
"skipped",
|
||||
/*failure_reason*/ None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let parent_compaction_hash = if thread_context_enabled {
|
||||
input
|
||||
.conversation_history
|
||||
.latest_compaction_model_hash()
|
||||
.map(str::to_owned)
|
||||
} else {
|
||||
let context_mode = guardian_evidence.context_mode();
|
||||
let selected_compaction = match select_parent_compaction(
|
||||
context_mode,
|
||||
&guardian_config,
|
||||
input.conversation_history.as_ref(),
|
||||
&sampler,
|
||||
parent_model
|
||||
.as_ref()
|
||||
.and_then(|model| model.comp_hash.clone())
|
||||
};
|
||||
let parent_compaction = if guardian_config.reuse_parent_compaction {
|
||||
match encrypted_parent_compaction(
|
||||
input.conversation_history.items(),
|
||||
guardian_config.max_parent_compaction_tokens,
|
||||
) {
|
||||
Ok(compaction) => compaction,
|
||||
Err(ParentCompactionError::Unusable) if !thread_context_enabled => None,
|
||||
Err(_) => {
|
||||
Self::record_fail_closed_score(input.thread_store, sampled_at);
|
||||
record_classification(
|
||||
metrics.as_deref(),
|
||||
classification_started_at.elapsed(),
|
||||
"failure",
|
||||
Some("parent_compaction_error"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
.and_then(|model| model.comp_hash.as_deref()),
|
||||
) {
|
||||
Ok(compaction) => compaction,
|
||||
Err(error) => {
|
||||
let (outcome, failure_reason) = if error == ParentCompactionError::RequiresSync {
|
||||
score_progress
|
||||
.latest_failed_tool_call
|
||||
.fetch_max(tool_call_index, Ordering::Release);
|
||||
("skipped", None)
|
||||
} else {
|
||||
("failure", Some("parent_compaction_error"))
|
||||
};
|
||||
Self::record_fail_closed_score(input.thread_store, sampled_at);
|
||||
record_classification(
|
||||
metrics.as_deref(),
|
||||
classification_started_at.elapsed(),
|
||||
outcome,
|
||||
failure_reason,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Legacy requests may omit an incompatible checkpoint because their raw
|
||||
// review transcript is still retained. The sampler rejects supplied
|
||||
// incompatible checkpoints, so preserve that legacy omission here.
|
||||
let parent_compaction = parent_compaction.filter(|_| {
|
||||
thread_context_enabled
|
||||
|| sampler.supports_parent_compaction(parent_compaction_hash.as_deref())
|
||||
});
|
||||
let parent_compaction = selected_compaction.item;
|
||||
let parent_compaction_hash = selected_compaction.model_hash;
|
||||
let call_id = input.call_id.to_owned();
|
||||
let mcp_tool = input.mcp_tool.cloned();
|
||||
let action = GuardianAction {
|
||||
@@ -697,7 +673,7 @@ impl GuardianV2Extension {
|
||||
.images(input.conversation_history.review_items(), node_repl_images);
|
||||
// Capture root evidence before background metadata resolution or model I/O.
|
||||
// Later root changes invalidate this sample through its captured authorization version.
|
||||
let root_snapshot = if thread_context_enabled {
|
||||
let root_snapshot = if context_mode == GuardianContextMode::ThreadOwned {
|
||||
thread.guardian_root_snapshot().await
|
||||
} else {
|
||||
None
|
||||
@@ -712,7 +688,7 @@ impl GuardianV2Extension {
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let root_snapshot = if thread_context_enabled {
|
||||
let root_snapshot = if context_mode == GuardianContextMode::ThreadOwned {
|
||||
root_snapshot
|
||||
} else {
|
||||
thread.guardian_root_snapshot().await
|
||||
@@ -986,71 +962,6 @@ impl GuardianV2Extension {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub(super) enum ParentCompactionError {
|
||||
Serialization,
|
||||
Oversized,
|
||||
Unusable,
|
||||
}
|
||||
|
||||
// Sampling and fast approval must apply the same checkpoint eligibility policy.
|
||||
pub(super) fn requires_sync_for_compaction(
|
||||
config: &GuardianV2Config,
|
||||
history: &dyn ConversationHistorySnapshot,
|
||||
sampler: &LunaSampler,
|
||||
) -> bool {
|
||||
history.items().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. }
|
||||
)
|
||||
}) && (!config.reuse_parent_compaction
|
||||
|| !sampler.supports_parent_compaction(history.latest_compaction_model_hash()))
|
||||
}
|
||||
|
||||
// An unusable latest compaction must never fall back to an older one. Missing
|
||||
// encrypted content is rejected here; only legacy callers may omit that checkpoint.
|
||||
pub(super) fn encrypted_parent_compaction<'a>(
|
||||
items: impl Iterator<Item = &'a ResponseItem>,
|
||||
max_parent_compaction_tokens: usize,
|
||||
) -> Result<Option<ResponseItem>, ParentCompactionError> {
|
||||
let max_compaction_bytes = TruncationPolicy::Tokens(max_parent_compaction_tokens).byte_budget();
|
||||
let Some(item) = items
|
||||
.filter(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. }
|
||||
)
|
||||
})
|
||||
.last()
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let encrypted_content = match item {
|
||||
ResponseItem::Compaction {
|
||||
id: Some(_),
|
||||
encrypted_content,
|
||||
..
|
||||
}
|
||||
| ResponseItem::ContextCompaction {
|
||||
id: Some(_),
|
||||
encrypted_content: Some(encrypted_content),
|
||||
..
|
||||
} => encrypted_content,
|
||||
_ => return Err(ParentCompactionError::Unusable),
|
||||
};
|
||||
if encrypted_content.is_empty() {
|
||||
return Err(ParentCompactionError::Unusable);
|
||||
}
|
||||
let serialized = serde_json::to_vec(item).map_err(|_| ParentCompactionError::Serialization)?;
|
||||
if serialized.len() > max_compaction_bytes {
|
||||
return Err(ParentCompactionError::Oversized);
|
||||
}
|
||||
|
||||
Ok(Some(item.clone()))
|
||||
}
|
||||
|
||||
/// Installs feature-gated Guardian V2 tool classification for each thread.
|
||||
pub fn install(
|
||||
registry: &mut ExtensionRegistryBuilder<Config>,
|
||||
|
||||
@@ -69,9 +69,7 @@ use serde_json::json;
|
||||
|
||||
use super::GuardianV2Extension;
|
||||
use super::GuardianV2ScoreProgress;
|
||||
use super::ParentCompactionError;
|
||||
use super::StrictReviewReason;
|
||||
use super::encrypted_parent_compaction;
|
||||
|
||||
use crate::async_scorer::config::CLASSIFICATION_OUTPUT_INSTRUCTIONS;
|
||||
use crate::async_scorer::config::DEFAULT_MODEL_CONTEXT_ITEM_TOKENS;
|
||||
@@ -791,166 +789,6 @@ async fn computer_use_only_scores_cannot_approve_other_actions() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_parent_compaction_preserves_the_latest_valid_item() {
|
||||
let older = ResponseItem::Compaction {
|
||||
id: Some(ResponseItemId::from_server("cmp_older".to_owned())),
|
||||
encrypted_content: "older encrypted summary".to_owned(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
};
|
||||
let latest = ResponseItem::ContextCompaction {
|
||||
id: Some(ResponseItemId::from_server("cmp_latest".to_owned())),
|
||||
encrypted_content: Some("latest encrypted summary".to_owned()),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
encrypted_parent_compaction(
|
||||
[&older, &latest].into_iter(),
|
||||
DEFAULT_PARENT_COMPACTION_TOKENS,
|
||||
),
|
||||
Ok(Some(latest.clone()))
|
||||
);
|
||||
assert_eq!(
|
||||
encrypted_parent_compaction(
|
||||
[&latest, &older].into_iter(),
|
||||
DEFAULT_PARENT_COMPACTION_TOKENS,
|
||||
),
|
||||
Ok(Some(older))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_parent_compaction_rejects_invalid_latest_item() {
|
||||
let older = ResponseItem::Compaction {
|
||||
id: Some(ResponseItemId::from_server("cmp_older".to_owned())),
|
||||
encrypted_content: "older encrypted summary".to_owned(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
};
|
||||
let invalid = [
|
||||
ResponseItem::Compaction {
|
||||
id: None,
|
||||
encrypted_content: "encrypted summary without an ID".to_owned(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
ResponseItem::Compaction {
|
||||
id: Some(ResponseItemId::from_server("cmp_empty".to_owned())),
|
||||
encrypted_content: String::new(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
ResponseItem::ContextCompaction {
|
||||
id: None,
|
||||
encrypted_content: Some("encrypted context without an ID".to_owned()),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
ResponseItem::ContextCompaction {
|
||||
id: Some(ResponseItemId::from_server("cmp_missing".to_owned())),
|
||||
encrypted_content: None,
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
ResponseItem::ContextCompaction {
|
||||
id: Some(ResponseItemId::from_server("cmp_empty".to_owned())),
|
||||
encrypted_content: Some(String::new()),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
];
|
||||
|
||||
for latest in &invalid {
|
||||
assert_eq!(
|
||||
encrypted_parent_compaction(
|
||||
[&older, latest].into_iter(),
|
||||
DEFAULT_PARENT_COMPACTION_TOKENS,
|
||||
),
|
||||
Err(ParentCompactionError::Unusable),
|
||||
"an unusable latest summary must not resurrect older context"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_parent_compaction_rejects_oversized_latest_item() -> Result<()> {
|
||||
let max_compaction_bytes =
|
||||
TruncationPolicy::Tokens(DEFAULT_PARENT_COMPACTION_TOKENS).byte_budget();
|
||||
let mut bounded = [
|
||||
ResponseItem::Compaction {
|
||||
id: Some(ResponseItemId::from_server("cmp_bounded".to_owned())),
|
||||
encrypted_content: String::new(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
ResponseItem::ContextCompaction {
|
||||
id: Some(ResponseItemId::from_server("ctx_bounded".to_owned())),
|
||||
encrypted_content: Some(String::new()),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
];
|
||||
|
||||
for item in &mut bounded {
|
||||
let envelope_bytes = serde_json::to_vec(&*item)?.len();
|
||||
let encrypted_content = match item {
|
||||
ResponseItem::Compaction {
|
||||
encrypted_content, ..
|
||||
}
|
||||
| ResponseItem::ContextCompaction {
|
||||
encrypted_content: Some(encrypted_content),
|
||||
..
|
||||
} => 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);
|
||||
assert_eq!(
|
||||
encrypted_parent_compaction(std::iter::once(&*item), DEFAULT_PARENT_COMPACTION_TOKENS,),
|
||||
Ok(Some(item.clone()))
|
||||
);
|
||||
|
||||
let mut oversized = item.clone();
|
||||
match &mut oversized {
|
||||
ResponseItem::Compaction {
|
||||
encrypted_content, ..
|
||||
}
|
||||
| ResponseItem::ContextCompaction {
|
||||
encrypted_content: Some(encrypted_content),
|
||||
..
|
||||
} => encrypted_content.push('a'),
|
||||
_ => unreachable!("test fixtures are encrypted compaction items"),
|
||||
}
|
||||
assert_eq!(
|
||||
serde_json::to_vec(&oversized)?.len(),
|
||||
max_compaction_bytes + 1
|
||||
);
|
||||
assert_eq!(
|
||||
encrypted_parent_compaction(
|
||||
[&*item, &oversized].into_iter(),
|
||||
DEFAULT_PARENT_COMPACTION_TOKENS,
|
||||
),
|
||||
Err(ParentCompactionError::Oversized),
|
||||
"an oversized latest summary must not resurrect older context"
|
||||
);
|
||||
}
|
||||
|
||||
let oversized_metadata = ResponseItem::ContextCompaction {
|
||||
id: Some(ResponseItemId::from_server(
|
||||
"ctx_oversized_metadata".to_owned(),
|
||||
)),
|
||||
encrypted_content: Some("bounded encrypted summary".to_owned()),
|
||||
internal_chat_message_metadata_passthrough: Some(InternalChatMessageMetadataPassthrough {
|
||||
turn_id: Some("a".repeat(max_compaction_bytes)),
|
||||
..Default::default()
|
||||
}),
|
||||
};
|
||||
assert!(serde_json::to_vec(&oversized_metadata)?.len() > max_compaction_bytes);
|
||||
assert_eq!(
|
||||
encrypted_parent_compaction(
|
||||
[&bounded[0], &oversized_metadata].into_iter(),
|
||||
DEFAULT_PARENT_COMPACTION_TOKENS,
|
||||
),
|
||||
Err(ParentCompactionError::Oversized),
|
||||
"oversized passthrough metadata must not bypass the complete-item limit"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sample_conversation_history(
|
||||
conversation_history: Vec<ResponseItem>,
|
||||
arguments: &str,
|
||||
|
||||
@@ -5,6 +5,7 @@ mod config;
|
||||
mod coverage;
|
||||
mod extension;
|
||||
mod metrics;
|
||||
mod parent_compaction;
|
||||
mod review_evidence;
|
||||
mod sampler;
|
||||
mod transcript;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Selects the Luna checkpoint once under the session's context mode.
|
||||
//! Sampling and fast approval share eligibility; legacy omission stays distinct from rejection.
|
||||
|
||||
use codex_core::context::GuardianContextMode;
|
||||
use codex_extension_api::ConversationHistorySnapshot;
|
||||
use codex_extension_api::ResponseItem;
|
||||
use codex_protocol::protocol::TruncationPolicy;
|
||||
|
||||
use super::config::GuardianV2Config;
|
||||
use super::sampler::LunaSampler;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub(super) enum ParentCompactionError {
|
||||
RequiresSync,
|
||||
Serialization,
|
||||
Oversized,
|
||||
Unusable,
|
||||
}
|
||||
|
||||
pub(super) struct ParentCompaction {
|
||||
pub(super) item: Option<ResponseItem>,
|
||||
pub(super) model_hash: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) fn select_parent_compaction(
|
||||
mode: GuardianContextMode,
|
||||
config: &GuardianV2Config,
|
||||
history: &dyn ConversationHistorySnapshot,
|
||||
sampler: &LunaSampler,
|
||||
legacy_model_hash: Option<&str>,
|
||||
) -> Result<ParentCompaction, ParentCompactionError> {
|
||||
let model_hash = match mode {
|
||||
GuardianContextMode::Legacy => legacy_model_hash,
|
||||
GuardianContextMode::ThreadOwned => history.latest_compaction_model_hash(),
|
||||
};
|
||||
if mode == GuardianContextMode::ThreadOwned
|
||||
&& history.items().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. }
|
||||
)
|
||||
})
|
||||
&& (!config.reuse_parent_compaction || !sampler.supports_parent_compaction(model_hash))
|
||||
{
|
||||
return Err(ParentCompactionError::RequiresSync);
|
||||
}
|
||||
let item = if config.reuse_parent_compaction {
|
||||
match encrypted_parent_compaction(history.items(), config.max_parent_compaction_tokens) {
|
||||
Ok(item) => item,
|
||||
Err(ParentCompactionError::Unusable) if mode == GuardianContextMode::Legacy => None,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Legacy has raw history, so it can omit an incompatible checkpoint. Validate its size
|
||||
// before omission, preserving the previous failure behavior for oversized items.
|
||||
let item = item.filter(|_| {
|
||||
mode == GuardianContextMode::ThreadOwned || sampler.supports_parent_compaction(model_hash)
|
||||
});
|
||||
Ok(ParentCompaction {
|
||||
item,
|
||||
model_hash: model_hash.map(str::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
// An unusable latest compaction must never fall back to an older one. Missing
|
||||
// encrypted content is rejected here; only legacy callers may omit that checkpoint.
|
||||
fn encrypted_parent_compaction<'a>(
|
||||
items: impl Iterator<Item = &'a ResponseItem>,
|
||||
max_parent_compaction_tokens: usize,
|
||||
) -> Result<Option<ResponseItem>, ParentCompactionError> {
|
||||
let max_compaction_bytes = TruncationPolicy::Tokens(max_parent_compaction_tokens).byte_budget();
|
||||
let Some(item) = items
|
||||
.filter(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. }
|
||||
)
|
||||
})
|
||||
.last()
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let encrypted_content = match item {
|
||||
ResponseItem::Compaction {
|
||||
id: Some(_),
|
||||
encrypted_content,
|
||||
..
|
||||
}
|
||||
| ResponseItem::ContextCompaction {
|
||||
id: Some(_),
|
||||
encrypted_content: Some(encrypted_content),
|
||||
..
|
||||
} => encrypted_content,
|
||||
_ => return Err(ParentCompactionError::Unusable),
|
||||
};
|
||||
if encrypted_content.is_empty() {
|
||||
return Err(ParentCompactionError::Unusable);
|
||||
}
|
||||
let serialized = serde_json::to_vec(item).map_err(|_| ParentCompactionError::Serialization)?;
|
||||
if serialized.len() > max_compaction_bytes {
|
||||
return Err(ParentCompactionError::Oversized);
|
||||
}
|
||||
|
||||
Ok(Some(item.clone()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "parent_compaction_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Checkpoint identity and complete-item bounds remain unchanged after policy extraction.
|
||||
|
||||
use super::super::config::DEFAULT_PARENT_COMPACTION_TOKENS;
|
||||
use super::*;
|
||||
use anyhow::Result;
|
||||
use codex_protocol::ResponseItemId;
|
||||
use codex_protocol::models::InternalChatMessageMetadataPassthrough;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn encrypted_parent_compaction_preserves_the_latest_valid_item() {
|
||||
let older = ResponseItem::Compaction {
|
||||
id: Some(ResponseItemId::from_server("cmp_older".to_owned())),
|
||||
encrypted_content: "older encrypted summary".to_owned(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
};
|
||||
let latest = ResponseItem::ContextCompaction {
|
||||
id: Some(ResponseItemId::from_server("cmp_latest".to_owned())),
|
||||
encrypted_content: Some("latest encrypted summary".to_owned()),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
encrypted_parent_compaction(
|
||||
[&older, &latest].into_iter(),
|
||||
DEFAULT_PARENT_COMPACTION_TOKENS,
|
||||
),
|
||||
Ok(Some(latest.clone()))
|
||||
);
|
||||
assert_eq!(
|
||||
encrypted_parent_compaction(
|
||||
[&latest, &older].into_iter(),
|
||||
DEFAULT_PARENT_COMPACTION_TOKENS,
|
||||
),
|
||||
Ok(Some(older))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_parent_compaction_rejects_invalid_latest_item() {
|
||||
let older = ResponseItem::Compaction {
|
||||
id: Some(ResponseItemId::from_server("cmp_older".to_owned())),
|
||||
encrypted_content: "older encrypted summary".to_owned(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
};
|
||||
let invalid = [
|
||||
ResponseItem::Compaction {
|
||||
id: None,
|
||||
encrypted_content: "encrypted summary without an ID".to_owned(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
ResponseItem::Compaction {
|
||||
id: Some(ResponseItemId::from_server("cmp_empty".to_owned())),
|
||||
encrypted_content: String::new(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
ResponseItem::ContextCompaction {
|
||||
id: None,
|
||||
encrypted_content: Some("encrypted context without an ID".to_owned()),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
ResponseItem::ContextCompaction {
|
||||
id: Some(ResponseItemId::from_server("cmp_missing".to_owned())),
|
||||
encrypted_content: None,
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
ResponseItem::ContextCompaction {
|
||||
id: Some(ResponseItemId::from_server("cmp_empty".to_owned())),
|
||||
encrypted_content: Some(String::new()),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
];
|
||||
|
||||
for latest in &invalid {
|
||||
assert_eq!(
|
||||
encrypted_parent_compaction(
|
||||
[&older, latest].into_iter(),
|
||||
DEFAULT_PARENT_COMPACTION_TOKENS,
|
||||
),
|
||||
Err(ParentCompactionError::Unusable),
|
||||
"an unusable latest summary must not resurrect older context"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_parent_compaction_rejects_oversized_latest_item() -> Result<()> {
|
||||
let max_compaction_bytes =
|
||||
TruncationPolicy::Tokens(DEFAULT_PARENT_COMPACTION_TOKENS).byte_budget();
|
||||
let mut bounded = [
|
||||
ResponseItem::Compaction {
|
||||
id: Some(ResponseItemId::from_server("cmp_bounded".to_owned())),
|
||||
encrypted_content: String::new(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
ResponseItem::ContextCompaction {
|
||||
id: Some(ResponseItemId::from_server("ctx_bounded".to_owned())),
|
||||
encrypted_content: Some(String::new()),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
];
|
||||
|
||||
for item in &mut bounded {
|
||||
let envelope_bytes = serde_json::to_vec(&*item)?.len();
|
||||
let encrypted_content = match item {
|
||||
ResponseItem::Compaction {
|
||||
encrypted_content, ..
|
||||
}
|
||||
| ResponseItem::ContextCompaction {
|
||||
encrypted_content: Some(encrypted_content),
|
||||
..
|
||||
} => 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);
|
||||
assert_eq!(
|
||||
encrypted_parent_compaction(std::iter::once(&*item), DEFAULT_PARENT_COMPACTION_TOKENS,),
|
||||
Ok(Some(item.clone()))
|
||||
);
|
||||
|
||||
let mut oversized = item.clone();
|
||||
match &mut oversized {
|
||||
ResponseItem::Compaction {
|
||||
encrypted_content, ..
|
||||
}
|
||||
| ResponseItem::ContextCompaction {
|
||||
encrypted_content: Some(encrypted_content),
|
||||
..
|
||||
} => encrypted_content.push('a'),
|
||||
_ => unreachable!("test fixtures are encrypted compaction items"),
|
||||
}
|
||||
assert_eq!(
|
||||
serde_json::to_vec(&oversized)?.len(),
|
||||
max_compaction_bytes + 1
|
||||
);
|
||||
assert_eq!(
|
||||
encrypted_parent_compaction(
|
||||
[&*item, &oversized].into_iter(),
|
||||
DEFAULT_PARENT_COMPACTION_TOKENS,
|
||||
),
|
||||
Err(ParentCompactionError::Oversized),
|
||||
"an oversized latest summary must not resurrect older context"
|
||||
);
|
||||
}
|
||||
|
||||
let oversized_metadata = ResponseItem::ContextCompaction {
|
||||
id: Some(ResponseItemId::from_server(
|
||||
"ctx_oversized_metadata".to_owned(),
|
||||
)),
|
||||
encrypted_content: Some("bounded encrypted summary".to_owned()),
|
||||
internal_chat_message_metadata_passthrough: Some(InternalChatMessageMetadataPassthrough {
|
||||
turn_id: Some("a".repeat(max_compaction_bytes)),
|
||||
..Default::default()
|
||||
}),
|
||||
};
|
||||
assert!(serde_json::to_vec(&oversized_metadata)?.len() > max_compaction_bytes);
|
||||
assert_eq!(
|
||||
encrypted_parent_compaction(
|
||||
[&bounded[0], &oversized_metadata].into_iter(),
|
||||
DEFAULT_PARENT_COMPACTION_TOKENS,
|
||||
),
|
||||
Err(ParentCompactionError::Oversized),
|
||||
"oversized passthrough metadata must not bypass the complete-item limit"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in new issue
Block a user