From 3062f7aa4a8c293f4d3ab44c055a779ae5059b98 Mon Sep 17 00:00:00 2001 From: ioanbudea-OAI Date: Thu, 29 Oct 2026 16:31:32 +0000 Subject: [PATCH] Add validated elastic section budget configuration for Guardian (#58123) ## What changed Export `ElasticBudgetConfig`, named `ElasticSection` groups, and validated `ElasticSectionLimits` for optional evidence. Each group can specify a minimum token reservation and a per-entry text cap. Validate limits during construction and deserialization: both values must be positive, and the entry cap must fit within the minimum reservation and the existing text-part delivery allowance. Reject unknown configuration fields. This adds configuration types only; elastic allocation is not activated. ## Testing Add a serialization round-trip test and deserialization checks for zero limits, entry caps above the minimum reservation, and entry caps above the delivery allowance. GitOrigin-RevId: c869018cfc0d96377b1adbfbdd98e1d88bd1b32a --- codex-rs/guardian-context/src/budget.rs | 128 ++++++++++++++++++ codex-rs/guardian-context/src/budget_tests.rs | 19 +++ codex-rs/guardian-context/src/lib.rs | 4 + 3 files changed, 151 insertions(+) diff --git a/codex-rs/guardian-context/src/budget.rs b/codex-rs/guardian-context/src/budget.rs index cb4140c487..60b735fe74 100644 --- a/codex-rs/guardian-context/src/budget.rs +++ b/codex-rs/guardian-context/src/budget.rs @@ -1,4 +1,5 @@ //! Payload-free accounting for composed sections and complete Guardian requests. +//! Elastic section policies are validated without changing fixed request preparation. //! Text and image bytes stay separate. Hosts provide existing-context estimates; //! the shared wire estimate reserves image tokens without counting base64 as text. @@ -112,6 +113,133 @@ pub struct RequestBudget { pub existing_context_tokens: usize, } +/// Named optional allocation groups, independent of prompt placement. +/// The transcript has separate message and tool groups despite sharing one section. +/// Required evidence, including the action and manual approvals, bypasses these limits. +#[derive( + Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum ElasticSection { + TranscriptMessages, + TranscriptTools, + RootMessages, + RetainedInstructions, + UserAnswers, + SenderMessages, + ToolDescriptions, + PreviousReviews, + TrustedTools, + TrustedSkills, + Images, + ReplEvidence, + PermissionContext, +} + +/// Per-group elastic limits, validated when decoded; not an activation switch. +/// Entries configure only optional evidence in the named group. They cannot +/// downgrade required evidence. Omitted groups have no override; the allocator +/// must supply its defaults when it is introduced. +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ElasticBudgetConfig { + #[serde(default)] + pub sections: std::collections::BTreeMap, +} + +/// Demand-capped minimum allocation and per-entry text allowance for optional evidence. +/// The minimum is a reservation, not a maximum: a section may receive spare capacity. +/// Preparation must cap optional entries once, including framing and omission markers, +/// before measuring demand. Entries that fit keep their original representation. +/// Entry caps cannot exceed the existing text-part delivery allowance. +/// Opaque text messages must be kept or omitted whole, never shortened. +/// Images stay whole and use section/global space rather than the text entry cap. +/// Request-level feasibility depends on the active groups and remaining global budget. +/// These settings do not change storage bounds or activate elastic allocation. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)] +pub struct ElasticSectionLimits { + min_total_section_tokens: usize, + max_section_entry_tokens: usize, +} + +impl ElasticSectionLimits { + /// Reject empty quotas, oversized entries and minimums that cannot hold one capped entry. + pub fn new( + min_total_section_tokens: usize, + max_section_entry_tokens: usize, + ) -> Result { + if min_total_section_tokens == 0 || max_section_entry_tokens == 0 { + return Err(ElasticSectionLimitsError::ZeroTokens); + } + if max_section_entry_tokens > min_total_section_tokens { + return Err(ElasticSectionLimitsError::EntryExceedsMinimum); + } + if max_section_entry_tokens > crate::composition::MAX_TEXT_PART_ESTIMATED_TOKENS { + return Err(ElasticSectionLimitsError::EntryExceedsDeliveryLimit); + } + Ok(Self { + min_total_section_tokens, + max_section_entry_tokens, + }) + } + + pub fn min_total_section_tokens(self) -> usize { + self.min_total_section_tokens + } + + pub fn max_section_entry_tokens(self) -> usize { + self.max_section_entry_tokens + } +} + +impl<'de> serde::Deserialize<'de> for ElasticSectionLimits { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct Fields { + min_total_section_tokens: usize, + max_section_entry_tokens: usize, + } + + let fields = Fields::deserialize(deserializer)?; + Self::new( + fields.min_total_section_tokens, + fields.max_section_entry_tokens, + ) + .map_err(serde::de::Error::custom) + } +} + +/// Invalid elastic limits cannot enter preparation through construction or decoding. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ElasticSectionLimitsError { + ZeroTokens, + EntryExceedsMinimum, + EntryExceedsDeliveryLimit, +} + +impl std::fmt::Display for ElasticSectionLimitsError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ZeroTokens => { + formatter.write_str("elastic section token limits must be greater than zero") + } + Self::EntryExceedsMinimum => formatter + .write_str("min_total_section_tokens must be at least max_section_entry_tokens"), + Self::EntryExceedsDeliveryLimit => write!( + formatter, + "max_section_entry_tokens must not exceed {}", + crate::composition::MAX_TEXT_PART_ESTIMATED_TOKENS + ), + } + } +} + +impl std::error::Error for ElasticSectionLimitsError {} + /// Size of section evidence, excluding enclosing message serialization overhead. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct SectionCost { diff --git a/codex-rs/guardian-context/src/budget_tests.rs b/codex-rs/guardian-context/src/budget_tests.rs index e1434db709..9000590b5e 100644 --- a/codex-rs/guardian-context/src/budget_tests.rs +++ b/codex-rs/guardian-context/src/budget_tests.rs @@ -175,3 +175,22 @@ fn section_estimate_bounds_the_delivered_message() { } } } + +#[test] +fn elastic_section_config_validates_entry_limits() { + let cap = crate::composition::MAX_TEXT_PART_ESTIMATED_TOKENS; + let config = serde_json::json!({ + "sections": {"transcript_tools": { + "min_total_section_tokens": cap, + "max_section_entry_tokens": cap, + }}, + }); + let parsed = serde_json::from_value::(config.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), config); + for (minimum, entry_cap) in [(0, cap), (cap, 0), (cap - 1, cap), (cap + 1, cap + 1)] { + let mut invalid = config.clone(); + invalid["sections"]["transcript_tools"]["min_total_section_tokens"] = minimum.into(); + invalid["sections"]["transcript_tools"]["max_section_entry_tokens"] = entry_cap.into(); + assert!(serde_json::from_value::(invalid).is_err()); + } +} diff --git a/codex-rs/guardian-context/src/lib.rs b/codex-rs/guardian-context/src/lib.rs index 50c4a2fdfc..fb4f04f5c9 100644 --- a/codex-rs/guardian-context/src/lib.rs +++ b/codex-rs/guardian-context/src/lib.rs @@ -78,6 +78,10 @@ mod budget; mod composition; mod cursor; pub use budget::DEFAULT_MAX_INPUT_TOKENS; +pub use budget::ElasticBudgetConfig; +pub use budget::ElasticSection; +pub use budget::ElasticSectionLimits; +pub use budget::ElasticSectionLimitsError; pub use budget::REQUEST_TOKENS_BOUNDARIES; pub use budget::REQUEST_TOKENS_METRIC; pub use budget::RequestBudget;