Add opt-in elastic budgeting for asynchronous Guardian requests (#61345)

## What changed

- Add `async_elastic_budgeting` and `async_elastic_sections` under `[features.guardianv2]`, independently of synchronous budgeting and disabled by default.
- Apply section limits and elastic allocation to new classifier evidence after accounting for setup and retained conversation history. Preserve the configured elastic policy when applying model defaults.
- Keep permission context whole during elastic allocation and record truncation observations when image preparation rejects an image.

## Testing

Add coverage for configuration validation, fixed and elastic conversation budgeting, retained history and cursor preservation, and complete classifier requests that retain evidence beyond the fixed entry limit.

GitOrigin-RevId: 2955f0482f7c3e18325e296fc2ccb6592ffcb73a
This commit is contained in:
ioanbudea-OAI
2026-11-13 18:46:45 +00:00
committed by copyberry
parent c33002259e
commit a655b50de9
29 changed files with 448 additions and 50 deletions
+17 -2
View File
@@ -8960,8 +8960,10 @@ async fn load_config_uses_auto_review_guardian_policy_config(
Ok(())
}
#[test_case::test_case("sync")]
#[test_case::test_case("async")]
#[tokio::test]
async fn sync_elastic_budget_is_opt_in_and_validated() {
async fn elastic_budget_is_opt_in_and_validated(target: &str) {
let excessive_minimum = codex_guardian_context::DEFAULT_MAX_INPUT_TOKENS + 1;
let minimum_mismatch = format!(
r#"[features.guardianv2.sync_elastic_sections.root_conversation_messages]
@@ -8980,6 +8982,7 @@ max_total_section_tokens = 0
min_total_section_tokens = 1
max_section_entry_tokens = 0
"#,
"",
maximum_below_minimum,
minimum_mismatch.as_str(),
r#"[features.guardianv2.sync_elastic_sections.root_messages]
@@ -8990,10 +8993,11 @@ min_total_section_tokens = 1
max_section_entry_tokens = 1
"#,
] {
let sections = sections.replace("sync_", &format!("{target}_"));
for enabled in [false, true] {
let result = Config::load_from_base_config_with_overrides(
toml::from_str(&format!(
"[features.guardianv2]\nenabled = false\nsync_elastic_budgeting = {enabled}\n{sections}"
"[features.guardianv2]\nenabled = false\n{target}_elastic_budgeting = {enabled}\n{sections}"
))
.unwrap(),
ConfigOverrides {
@@ -9008,6 +9012,17 @@ max_section_entry_tokens = 1
}
}
#[tokio::test]
async fn async_elastic_opt_in_preserves_fixed_capture() {
let mut config = super::test_config().await;
config.guardian_async_elastic_budget =
Some(core_test_support::guardian::elastic_budget_config());
assert_eq!(
config.guardian_context_budgeting(),
codex_guardian_context::ContextBudgeting::Fixed,
);
}
#[tokio::test]
async fn requirements_guardian_policy_beats_auto_review() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
+10
View File
@@ -748,6 +748,8 @@ pub struct Config {
pub guardian_transcript_mode: codex_protocol::TranscriptFormat,
/// Frozen section policy for sync Guardian; absence keeps fixed budgeting.
pub guardian_sync_elastic_budget: Option<codex_guardian_context::ElasticBudgetConfig>,
/// Frozen section policy for async Guardian; absence keeps fixed budgeting.
pub guardian_async_elastic_budget: Option<codex_guardian_context::ElasticBudgetConfig>,
/// Optional replacement for the gated history-retrieval instructions.
/// Blank config values are treated as unset, like other Guardian policy overrides.
@@ -4298,6 +4300,9 @@ impl Config {
let section = codex_guardian_context::ElasticSection::deserialize(name.as_str().into_deserializer())
.map_err(|error: serde::de::value::Error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
if section == codex_guardian_context::ElasticSection::TranscriptSelectedExecResults {
if prefix == "async" {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "transcript_selected_exec_results is only supported for sync elastic budgeting"));
}
if limits.nested_tool_names.is_empty() || limits.nested_tool_names.iter().any(|name| name.trim().is_empty()) {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData,
"transcript_selected_exec_results requires nonempty nested_tool_names"));
@@ -4333,6 +4338,10 @@ impl Config {
.filter(|guardian| guardian.sync_elastic_budgeting == Some(true))
.map(|guardian| resolve_elastic_budget(guardian.sync_elastic_sections.as_ref(), "sync"))
.transpose()?;
let guardian_async_elastic_budget = guardian
.filter(|guardian| guardian.async_elastic_budgeting == Some(true))
.map(|guardian| resolve_elastic_budget(guardian.async_elastic_sections.as_ref(), "async"))
.transpose()?;
let guardian_conversation_history_prompt = cfg.auto_review.as_ref().and_then(|auto_review| {
normalize_guardian_policy_config(
auto_review.experimental_conversation_history_prompt.as_deref(),
@@ -4712,6 +4721,7 @@ impl Config {
guardian_policy_template: None,
guardian_transcript_mode,
guardian_sync_elastic_budget,
guardian_async_elastic_budget,
guardian_conversation_history_prompt,
guardian_conversation_history_max_output_tokens,
guardian_review_timeout: cfg
+1 -1
View File
@@ -55,7 +55,7 @@ impl<'a> PreparedReviewContext<'a> {
Some(config) => Ok(Self::Elastic(
context
.prepare_elastic_entries(config)?
.prepare_sync_delivery(history)?,
.prepare_delivery(history)?,
)),
None => {
codex_guardian_context::prepare_sync_delivery(&mut context, history)?;