Bound Guardian v2 parent compaction context (#38980)

## What changed

- Add `max_parent_compaction_tokens`, defaulting to 25,000 tokens, to the Guardian v2 feature configuration.
- Reuse the latest encrypted parent compaction only when its complete serialized item fits within the configured limit.
- Fail closed with the maximum action-risk score when the latest encrypted compaction is oversized, instead of sampling with older context.

## Testing

- Cover exact-boundary and oversized compaction items, including oversized passthrough metadata.
- Verify that an oversized latest compaction bypasses sampling and prevents approval.

GitOrigin-RevId: 0b308e7bec0f41d46433dfdf656948cee5cba667
This commit is contained in:
felixxia-oai authored and copyberry committed 2026-08-17 10:18:24 +00:00
1 parent 632e35ce8d
commit 53aa7bb1aa
6 files changed
+247 -9

No files matched your search

+6
View File
@@ -1251,6 +1251,12 @@
"minimum": 100.0, "minimum": 100.0,
"type": "integer" "type": "integer"
}, },
"max_parent_compaction_tokens": {
"format": "uint",
"maximum": 100000.0,
"minimum": 100.0,
"type": "integer"
},
"reasoning_effort": { "reasoning_effort": {
"$ref": "#/definitions/ReasoningEffort" "$ref": "#/definitions/ReasoningEffort"
}, },
+8
View File
@@ -13,6 +13,7 @@ use crate::transcript::TranscriptSource;
use crate::transcript::truncate_entry; use crate::transcript::truncate_entry;
pub(crate) const DEFAULT_MODEL_CONTEXT_ITEM_TOKENS: usize = 10_000; 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 MIN_MODEL_CONTEXT_ITEM_TOKENS: usize = 100;
const MAX_MODEL_CONTEXT_ITEM_TOKENS: usize = 100_000; const MAX_MODEL_CONTEXT_ITEM_TOKENS: usize = 100_000;
const DEFAULT_REVIEW_THRESHOLD: f64 = 0.8; const DEFAULT_REVIEW_THRESHOLD: f64 = 0.8;
@@ -28,6 +29,7 @@ pub(crate) struct GuardianV2Config {
pub(crate) reasoning_effort: ReasoningEffort, pub(crate) reasoning_effort: ReasoningEffort,
pub(crate) max_action_tokens: usize, pub(crate) max_action_tokens: usize,
pub(crate) max_classifier_instruction_tokens: usize, pub(crate) max_classifier_instruction_tokens: usize,
pub(crate) max_parent_compaction_tokens: usize,
pub(crate) transcript: TranscriptConfig, pub(crate) transcript: TranscriptConfig,
} }
@@ -68,6 +70,11 @@ impl GuardianV2Config {
DEFAULT_MODEL_CONTEXT_ITEM_TOKENS, DEFAULT_MODEL_CONTEXT_ITEM_TOKENS,
"max_classifier_instruction_tokens", "max_classifier_instruction_tokens",
)?; )?;
let max_parent_compaction_tokens = bounded_tokens(
configured.max_parent_compaction_tokens,
DEFAULT_PARENT_COMPACTION_TOKENS,
"max_parent_compaction_tokens",
)?;
let transcript_config = configured.transcript.as_ref(); let transcript_config = configured.transcript.as_ref();
let max_message_entry_tokens = bounded_tokens( let max_message_entry_tokens = bounded_tokens(
transcript_config.and_then(|transcript| transcript.max_message_entry_tokens), transcript_config.and_then(|transcript| transcript.max_message_entry_tokens),
@@ -130,6 +137,7 @@ impl GuardianV2Config {
reasoning_effort: configured.reasoning_effort.unwrap_or(ReasoningEffort::Low), reasoning_effort: configured.reasoning_effort.unwrap_or(ReasoningEffort::Low),
max_action_tokens, max_action_tokens,
max_classifier_instruction_tokens, max_classifier_instruction_tokens,
max_parent_compaction_tokens,
transcript: TranscriptConfig { transcript: TranscriptConfig {
sources: transcript_config sources: transcript_config
.and_then(|transcript| transcript.sources.clone()) .and_then(|transcript| transcript.sources.clone())
+51 -4
View File
@@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::sync::Arc; use std::sync::Arc;
use std::sync::Weak; use std::sync::Weak;
use std::time::SystemTime; use std::time::SystemTime;
@@ -288,6 +289,46 @@ impl ToolLifecycleContributor for GuardianV2Extension {
return Box::pin(std::future::ready(())); return Box::pin(std::future::ready(()));
}; };
let sampled_at = SystemTime::now(); let sampled_at = SystemTime::now();
let latest_parent_compaction = input
.conversation_history
.items()
.filter(|item| {
matches!(
item,
ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. }
)
})
.last();
let parent_compaction = latest_parent_compaction.and_then(|item| {
encrypted_parent_compaction(
std::iter::once(item),
guardian_config.max_parent_compaction_tokens,
)
});
if parent_compaction.is_none()
&& latest_parent_compaction.is_some_and(|item| match item {
ResponseItem::Compaction {
id: Some(_),
encrypted_content,
..
}
| ResponseItem::ContextCompaction {
id: Some(_),
encrypted_content: Some(encrypted_content),
..
} => !encrypted_content.is_empty(),
_ => false,
})
{
let score = SecurityRiskScore {
scores: BTreeMap::from([("action_risk".to_owned(), 1.0)]),
sampled_at: Some(sampled_at.into()),
};
input.thread_store.insert_if(score.clone(), |previous| {
previous.is_none_or(|previous| previous.sampled_at <= score.sampled_at)
});
return Box::pin(std::future::ready(()));
}
let event_sink = Arc::clone(&self.event_sink); let event_sink = Arc::clone(&self.event_sink);
let thread_manager = self.thread_manager.clone(); let thread_manager = self.thread_manager.clone();
let thread_id = input.thread_store.level_id().to_owned(); let thread_id = input.thread_store.level_id().to_owned();
@@ -306,7 +347,6 @@ impl ToolLifecycleContributor for GuardianV2Extension {
let conversation_history = Arc::clone(&input.conversation_history); let conversation_history = Arc::clone(&input.conversation_history);
tokio::spawn(async move { tokio::spawn(async move {
let parent_compaction = encrypted_parent_compaction(conversation_history.items());
let transcript = guardian_config let transcript = guardian_config
.transcript .transcript
.build(conversation_history.items()); .build(conversation_history.items());
@@ -460,7 +500,9 @@ impl ToolLifecycleContributor for GuardianV2Extension {
fn encrypted_parent_compaction<'a>( fn encrypted_parent_compaction<'a>(
items: impl Iterator<Item = &'a ResponseItem>, items: impl Iterator<Item = &'a ResponseItem>,
max_parent_compaction_tokens: usize,
) -> Option<ResponseItem> { ) -> Option<ResponseItem> {
let max_compaction_bytes = TruncationPolicy::Tokens(max_parent_compaction_tokens).byte_budget();
let item = items let item = items
.filter(|item| { .filter(|item| {
matches!( matches!(
@@ -475,12 +517,17 @@ fn encrypted_parent_compaction<'a>(
id: Some(_), id: Some(_),
encrypted_content, encrypted_content,
.. ..
} if !encrypted_content.is_empty() => Some(item.clone()), }
ResponseItem::ContextCompaction { | ResponseItem::ContextCompaction {
id: Some(_), id: Some(_),
encrypted_content: Some(encrypted_content), encrypted_content: Some(encrypted_content),
.. ..
} if !encrypted_content.is_empty() => Some(item.clone()), } if !encrypted_content.is_empty()
&& serde_json::to_vec(item)
.is_ok_and(|serialized| serialized.len() <= max_compaction_bytes) =>
{
Some(item.clone())
}
_ => None, _ => None,
} }
} }
+171 -5
View File
@@ -22,6 +22,7 @@ use codex_model_provider_info::ModelProviderInfo;
use codex_protocol::ResponseItemId; use codex_protocol::ResponseItemId;
use codex_protocol::models::ContentItem; use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::InternalChatMessageMetadataPassthrough;
use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::ReasoningItemReasoningSummary;
use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SessionSource;
@@ -38,6 +39,7 @@ use serde_json::json;
use super::encrypted_parent_compaction; use super::encrypted_parent_compaction;
use crate::config::DEFAULT_MODEL_CONTEXT_ITEM_TOKENS; use crate::config::DEFAULT_MODEL_CONTEXT_ITEM_TOKENS;
use crate::config::DEFAULT_PARENT_COMPACTION_TOKENS;
use crate::sampler::MODEL; use crate::sampler::MODEL;
const TEST_GUARDIAN_POLICY: &str = const TEST_GUARDIAN_POLICY: &str =
@@ -67,11 +69,17 @@ fn encrypted_parent_compaction_preserves_the_latest_valid_item() {
}; };
assert_eq!( assert_eq!(
encrypted_parent_compaction([&older, &latest].into_iter()), encrypted_parent_compaction(
[&older, &latest].into_iter(),
DEFAULT_PARENT_COMPACTION_TOKENS,
),
Some(latest.clone()) Some(latest.clone())
); );
assert_eq!( assert_eq!(
encrypted_parent_compaction([&latest, &older].into_iter()), encrypted_parent_compaction(
[&latest, &older].into_iter(),
DEFAULT_PARENT_COMPACTION_TOKENS,
),
Some(older) Some(older)
); );
} }
@@ -113,13 +121,100 @@ fn encrypted_parent_compaction_rejects_invalid_latest_item() {
for latest in &invalid { for latest in &invalid {
assert_eq!( assert_eq!(
encrypted_parent_compaction([&older, latest].into_iter()), encrypted_parent_compaction(
[&older, latest].into_iter(),
DEFAULT_PARENT_COMPACTION_TOKENS,
),
None, None,
"an unusable latest summary must not resurrect older context" "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,),
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,
),
None,
"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,
),
None,
"oversized passthrough metadata must not bypass the complete-item limit"
);
Ok(())
}
async fn sample_conversation_history( async fn sample_conversation_history(
conversation_history: Vec<ResponseItem>, conversation_history: Vec<ResponseItem>,
arguments: &str, arguments: &str,
@@ -241,6 +336,7 @@ review_threshold = 0.60
reasoning_effort = "minimal" reasoning_effort = "minimal"
max_action_tokens = 128 max_action_tokens = 128
max_classifier_instruction_tokens = 100000 max_classifier_instruction_tokens = 100000
max_parent_compaction_tokens = 256
[features.guardianv2.transcript] [features.guardianv2.transcript]
sources = ["tool_outputs", "reasoning"] sources = ["tool_outputs", "reasoning"]
@@ -688,7 +784,16 @@ async fn contributor_reuses_the_latest_compatible_parent_compaction() -> Result<
skip_if_no_network!(Ok(())); skip_if_no_network!(Ok(()));
let thread_server = responses::start_mock_server().await; let thread_server = responses::start_mock_server().await;
let test = test_codex().build_with_auto_env(&thread_server).await?; let test = test_codex()
.with_pre_build_hook(|home| {
std::fs::write(
home.join("config.toml"),
"[features.guardianv2]\nenabled = true\nmax_parent_compaction_tokens = 256\n",
)
.expect("Guardian v2 parent compaction configuration should be written");
})
.build_with_auto_env(&thread_server)
.await?;
let events = vec![ let events = vec![
ev_assistant_message("sample", r#"{"scores":{"action_risk":0.25}}"#), ev_assistant_message("sample", r#"{"scores":{"action_risk":0.25}}"#),
ev_completed("response-1"), ev_completed("response-1"),
@@ -792,10 +897,71 @@ async fn contributor_reuses_the_latest_compatible_parent_compaction() -> Result<
); );
assert_eq!( assert_eq!(
request["input"][2], request["input"][2],
serde_json::to_value(latest_compaction)? serde_json::to_value(&latest_compaction)?
); );
assert_eq!(request["input"][3]["role"], "user"); assert_eq!(request["input"][3]["role"], "user");
let previous_score = tokio::time::timeout(Duration::from_secs(5), async {
loop {
if let Some(score) = thread_store.get::<SecurityRiskScore>() {
return score;
}
tokio::task::yield_now().await;
}
})
.await?;
assert_eq!(previous_score.scores.get("action_risk"), Some(&0.25));
assert_eq!(
registry
.approval_review(&session_store, thread_store, "review action")
.await,
Some(ReviewDecision::Approved)
);
let oversized_compaction = ResponseItem::Compaction {
id: Some(ResponseItemId::from_server("cmp_oversized".to_owned())),
encrypted_content: "a".repeat(TruncationPolicy::Tokens(/*limit*/ 256).byte_budget()),
internal_chat_message_metadata_passthrough: None,
};
registry.tool_lifecycle_contributors()[0]
.on_tool_start(ToolStartInput {
session_store: &session_store,
thread_store,
turn_store: &turn_store,
turn_id: "turn-1",
call_id: "call-2",
tool_name: &tool_name,
payload: &tool_payload,
conversation_history: Arc::new(TestConversationHistory(vec![
latest_compaction,
oversized_compaction,
])),
source: ToolCallSource::Direct,
})
.await;
let fail_closed_score = thread_store
.get::<SecurityRiskScore>()
.expect("an oversized compaction should immediately receive the maximum risk score");
assert_eq!(
fail_closed_score.as_ref(),
&SecurityRiskScore {
scores: BTreeMap::from([("action_risk".to_owned(), 1.0)]),
sampled_at: fail_closed_score.sampled_at,
}
);
assert_eq!(
registry
.approval_review(&session_store, thread_store, "review action")
.await,
None
);
assert_eq!(
server.connections().iter().map(Vec::len).sum::<usize>(),
1,
"an oversized latest compaction must bypass Luna rather than reuse stale context"
);
Ok(()) Ok(())
} }
+7
View File
@@ -125,6 +125,9 @@ pub struct GuardianV2ConfigToml {
#[schemars(range(min = 100, max = 100000))] #[schemars(range(min = 100, max = 100000))]
pub max_classifier_instruction_tokens: Option<usize>, pub max_classifier_instruction_tokens: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 100, max = 100000))]
pub max_parent_compaction_tokens: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub transcript: Option<GuardianV2TranscriptConfigToml>, pub transcript: Option<GuardianV2TranscriptConfigToml>,
} }
@@ -167,6 +170,10 @@ where
"max_classifier_instruction_tokens", "max_classifier_instruction_tokens",
config.max_classifier_instruction_tokens, config.max_classifier_instruction_tokens,
), ),
(
"max_parent_compaction_tokens",
config.max_parent_compaction_tokens,
),
("transcript max_message_entry_tokens", message_entry), ("transcript max_message_entry_tokens", message_entry),
("transcript max_tool_entry_tokens", tool_entry), ("transcript max_tool_entry_tokens", tool_entry),
("transcript max_message_transcript_tokens", message_total), ("transcript max_message_transcript_tokens", message_total),
+4
View File
@@ -139,6 +139,7 @@ review_threshold = 0.65
reasoning_effort = "minimal" reasoning_effort = "minimal"
max_action_tokens = 512 max_action_tokens = 512
max_classifier_instruction_tokens = 256 max_classifier_instruction_tokens = 256
max_parent_compaction_tokens = 384
[guardianv2.transcript] [guardianv2.transcript]
sources = ["tool_outputs", "reasoning"] sources = ["tool_outputs", "reasoning"]
@@ -160,6 +161,7 @@ max_recent_non_user_entries = 12
reasoning_effort: Some(codex_protocol::openai_models::ReasoningEffort::Minimal), reasoning_effort: Some(codex_protocol::openai_models::ReasoningEffort::Minimal),
max_action_tokens: Some(512), max_action_tokens: Some(512),
max_classifier_instruction_tokens: Some(256), max_classifier_instruction_tokens: Some(256),
max_parent_compaction_tokens: Some(384),
transcript: Some(crate::GuardianV2TranscriptConfigToml { transcript: Some(crate::GuardianV2TranscriptConfigToml {
sources: Some(vec![ sources: Some(vec![
crate::GuardianV2TranscriptSource::ToolOutputs, crate::GuardianV2TranscriptSource::ToolOutputs,
@@ -184,6 +186,7 @@ fn guardian_v2_feature_config_rejects_invalid_settings() {
for setting in [ for setting in [
"max_action_tokens", "max_action_tokens",
"max_classifier_instruction_tokens", "max_classifier_instruction_tokens",
"max_parent_compaction_tokens",
"transcript.max_message_entry_tokens", "transcript.max_message_entry_tokens",
"transcript.max_tool_entry_tokens", "transcript.max_tool_entry_tokens",
"transcript.max_message_transcript_tokens", "transcript.max_message_transcript_tokens",
@@ -221,6 +224,7 @@ fn guardian_v2_feature_config_accepts_boundary_values() {
review_threshold = {threshold:.1}\n\ review_threshold = {threshold:.1}\n\
max_action_tokens = {tokens}\n\ max_action_tokens = {tokens}\n\
max_classifier_instruction_tokens = {tokens}\n\ max_classifier_instruction_tokens = {tokens}\n\
max_parent_compaction_tokens = {tokens}\n\
[guardianv2.transcript]\n\ [guardianv2.transcript]\n\
max_message_entry_tokens = {tokens}\n\ max_message_entry_tokens = {tokens}\n\
max_tool_entry_tokens = {tokens}\n\ max_tool_entry_tokens = {tokens}\n\