use crate::error::ValidationError;
use crate::region::{RegionKind, RegionSchema};
use serde::{Deserialize, Serialize};
pub const STAGE_INSTRUCTIONS_REGION: &str = "stage_instructions";
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BudgetSpec {
Absolute(usize),
Percent {
percent: f64,
min: Option<usize>,
max: Option<usize>,
},
}
impl Default for BudgetSpec {
fn default() -> Self {
BudgetSpec::Absolute(0)
}
}
impl BudgetSpec {
pub fn parse_budget(s: &str) -> std::result::Result<f64, String> {
let trimmed = s.trim();
let Some(num) = trimmed.strip_suffix('%') else {
return Err(format!("budget '{s}' must end with '%' (e.g. \"35%\")"));
};
let value: f64 = num
.trim()
.parse()
.map_err(|_| format!("budget '{s}' is not a valid number"))?;
if !(value > 0.0 && value <= 100.0) {
return Err(format!(
"budget '{s}' must be greater than 0% and at most 100%"
));
}
Ok(value / 100.0)
}
pub fn resolve(&self, window: usize) -> usize {
match self {
BudgetSpec::Absolute(n) => *n,
BudgetSpec::Percent { percent, min, max } => {
let mut v = (window as f64 * percent).round() as usize;
if let Some(max) = max {
v = v.min(*max);
}
if let Some(min) = min {
v = v.max(*min);
}
v
}
}
}
pub fn is_percent(&self) -> bool {
matches!(self, BudgetSpec::Percent { .. })
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextLayout {
pub regions: Vec<RegionDefinition>,
pub total_budget_tokens: usize,
pub eviction_order: Vec<String>,
}
impl ContextLayout {
pub fn new(regions: Vec<RegionDefinition>, total_budget_tokens: usize) -> Self {
Self {
regions,
total_budget_tokens,
eviction_order: Vec::new(),
}
}
pub fn with_eviction_order(mut self, order: Vec<String>) -> Self {
self.eviction_order = order;
self
}
pub fn validate(&self) -> std::result::Result<(), ValidationError> {
let mut names = std::collections::HashSet::new();
for region in &self.regions {
if !names.insert(region.name.as_str()) {
return Err(ValidationError::Region {
region: region.name.clone(),
message: "duplicate region name".to_string(),
});
}
}
for name in &self.eviction_order {
if !names.contains(name.as_str()) {
return Err(ValidationError::Layout(format!(
"eviction order references unknown region: {}",
name
)));
}
}
for region in &self.regions {
if let RegionKind::Custom { script, .. } = ®ion.kind
&& script.trim().is_empty()
{
return Err(ValidationError::Region {
region: region.name.clone(),
message: "custom region requires a non-empty script path".to_string(),
});
}
}
let has_message_region = self.regions.iter().any(|r| {
matches!(
r.kind,
RegionKind::SlidingWindow { .. } | RegionKind::Custom { .. }
)
});
if !has_message_region {
tracing::warn!(
"Layout has no SlidingWindow (or custom scripted) region - typed \
conversation entries require one"
);
}
if self.has_percent_budgets() {
return Ok(());
}
let total_max: usize = self.regions.iter().map(|r| r.max_tokens).sum();
if total_max > self.total_budget_tokens {
tracing::warn!(
"Sum of region max tokens ({}) exceeds total budget ({})",
total_max,
self.total_budget_tokens
);
}
let fixed_tokens: usize = self
.regions
.iter()
.filter(|r| {
matches!(
r.kind,
RegionKind::Pinned
| RegionKind::HashMap { .. }
| RegionKind::CompactHistory { .. }
| RegionKind::Custom {
persistent: true,
..
}
)
})
.map(|r| r.max_tokens)
.sum();
let working_tokens = self.total_budget_tokens.saturating_sub(fixed_tokens);
if self.total_budget_tokens >= Self::BUDGET_CHECK_MIN_TOTAL
&& working_tokens < Self::MIN_WORKING_TOKENS
{
return Err(ValidationError::Layout(format!(
"context layout leaves only {working_tokens} working tokens after fixed \
regions (pinned/hashmap/compact_history/persistent custom) consume \
{fixed_tokens} of the {} \
total budget; at least {} are needed for the agent to operate. Reduce the \
fixed regions' max_tokens or increase the total budget.",
self.total_budget_tokens,
Self::MIN_WORKING_TOKENS
)));
}
Ok(())
}
const MIN_WORKING_TOKENS: usize = 8000;
const BUDGET_CHECK_MIN_TOTAL: usize = 20_000;
pub fn get_region(&self, name: &str) -> Option<&RegionDefinition> {
self.regions.iter().find(|r| r.name == name)
}
pub fn has_percent_budgets(&self) -> bool {
self.regions.iter().any(|r| r.budget.is_percent())
}
pub fn resolved(&self, window: usize) -> ContextLayout {
let regions = self
.regions
.iter()
.map(|r| {
let max_tokens = r.budget.resolve(window);
let kind = match &r.kind {
RegionKind::Compacting { threshold_tokens } => RegionKind::Compacting {
threshold_tokens: Self::resolve_compacting_threshold(
r.compact_at,
*threshold_tokens,
max_tokens,
),
},
other => other.clone(),
};
RegionDefinition {
kind,
max_tokens,
budget: BudgetSpec::Absolute(max_tokens),
compact_at: None,
..r.clone()
}
})
.collect();
let total_budget_tokens = if self.has_percent_budgets() {
window
} else {
self.total_budget_tokens
};
ContextLayout {
regions,
total_budget_tokens,
eviction_order: self.eviction_order.clone(),
}
}
fn resolve_compacting_threshold(
compact_at: Option<f64>,
threshold_tokens: usize,
resolved_budget: usize,
) -> usize {
match compact_at {
Some(fraction) => {
let pct = (resolved_budget as f64 * fraction).round() as usize;
pct.min(threshold_tokens)
}
None => threshold_tokens,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RegionSeed {
CallerInput {
name: String,
},
Glob {
pattern: String,
},
Files {
paths: Vec<String>,
},
Literal {
text: String,
},
Rhai {
script: String,
},
Command {
command: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegionDefinition {
pub name: String,
pub kind: RegionKind,
pub max_tokens: usize,
#[serde(default)]
pub budget: BudgetSpec,
#[serde(default)]
pub compact_at: Option<f64>,
pub schema: Option<RegionSchema>,
pub description: Option<String>,
#[serde(default)]
pub required: bool,
#[serde(default = "default_true")]
pub summarizable: bool,
#[serde(default)]
pub required_message: Option<String>,
#[serde(default)]
pub seed: Option<RegionSeed>,
}
impl RegionDefinition {
pub fn new(name: String, kind: RegionKind, max_tokens: usize) -> Self {
Self {
name,
kind,
max_tokens,
budget: BudgetSpec::Absolute(max_tokens),
compact_at: None,
schema: None,
description: None,
required: false,
required_message: None,
summarizable: true,
seed: None,
}
}
pub fn with_budget(mut self, budget: BudgetSpec) -> Self {
self.budget = budget;
self
}
pub fn with_compact_at(mut self, fraction: f64) -> Self {
self.compact_at = Some(fraction);
self
}
pub fn with_seed(mut self, seed: RegionSeed) -> Self {
self.seed = Some(seed);
self
}
pub fn with_required(mut self, required: bool, message: Option<String>) -> Self {
self.required = required;
self.required_message = message;
self
}
pub fn with_schema(mut self, schema: RegionSchema) -> Self {
self.schema = Some(schema);
self
}
pub fn with_description(mut self, description: String) -> Self {
self.description = Some(description);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use leviath_testkit::with_tracing;
#[test]
fn test_layout_creation() {
let regions = vec![
RegionDefinition::new("pinned".to_string(), RegionKind::Pinned, 5000),
RegionDefinition::new("temp".to_string(), RegionKind::Temporary, 10000),
];
let layout = ContextLayout::new(regions, 20000);
assert_eq!(layout.regions.len(), 2);
assert_eq!(layout.total_budget_tokens, 20000);
}
#[test]
fn test_layout_validation() {
let regions = vec![RegionDefinition::new(
"test".to_string(),
RegionKind::Pinned,
5000,
)];
let layout =
ContextLayout::new(regions, 10000).with_eviction_order(vec!["test".to_string()]);
assert!(layout.validate().is_ok());
}
#[test]
fn test_duplicate_region_names() {
let regions = vec![
RegionDefinition::new("test".to_string(), RegionKind::Pinned, 5000),
RegionDefinition::new("test".to_string(), RegionKind::Temporary, 3000),
];
let layout = ContextLayout::new(regions, 10000);
assert!(layout.validate().is_err());
}
#[test]
fn test_eviction_order_unknown_region_is_error() {
let regions = vec![RegionDefinition::new(
"test".to_string(),
RegionKind::Pinned,
5000,
)];
let layout =
ContextLayout::new(regions, 10000).with_eviction_order(vec!["nonexistent".to_string()]);
let err = layout.validate().unwrap_err();
assert_eq!(
err,
ValidationError::Layout(
"eviction order references unknown region: nonexistent".to_string()
)
);
}
#[test]
fn test_validate_warns_but_does_not_error_when_max_tokens_exceed_budget() {
let regions = vec![
RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000),
RegionDefinition::new("b".to_string(), RegionKind::Temporary, 10000),
];
let layout = ContextLayout::new(regions, 10000);
with_tracing(|| {
assert!(layout.validate().is_ok());
});
}
#[test]
fn validate_errors_when_fixed_regions_starve_working_budget() {
let regions = vec![
RegionDefinition::new("big_pinned".to_string(), RegionKind::Pinned, 95_000),
RegionDefinition::new("work".to_string(), RegionKind::Temporary, 5_000),
];
let layout = ContextLayout::new(regions, 100_000);
with_tracing(|| {
let err = layout.validate().unwrap_err();
assert!(
err.to_string().contains("working tokens"),
"actionable budget error: {err}"
);
});
}
#[test]
fn validate_ok_for_realistic_layout_with_working_room() {
let regions = vec![
RegionDefinition::new("task".to_string(), RegionKind::Pinned, 4_000),
RegionDefinition::new("conversation".to_string(), RegionKind::Temporary, 40_000),
];
let layout = ContextLayout::new(regions, 44_000);
with_tracing(|| {
assert!(layout.validate().is_ok());
});
}
fn custom_kind(script: &str, persistent: bool) -> RegionKind {
RegionKind::Custom {
script: script.to_string(),
persistent,
}
}
#[test]
fn validate_rejects_custom_region_with_empty_script() {
let regions = vec![RegionDefinition::new(
"brain".to_string(),
custom_kind(" ", false),
5000,
)];
let layout = ContextLayout::new(regions, 10_000);
let err = with_tracing(|| layout.validate().unwrap_err());
assert!(
err.to_string().contains("non-empty script path"),
"actionable error: {err}"
);
}
#[test]
fn validate_counts_persistent_custom_as_fixed_budget() {
let regions = vec![
RegionDefinition::new("vault".to_string(), custom_kind("v.rhai", true), 95_000),
RegionDefinition::new("work".to_string(), RegionKind::Temporary, 5_000),
];
let layout = ContextLayout::new(regions, 100_000);
let err = with_tracing(|| layout.validate().unwrap_err());
assert!(err.to_string().contains("working tokens"), "{err}");
}
#[test]
fn validate_counts_non_persistent_custom_as_working_budget() {
let regions = vec![
RegionDefinition::new("brain".to_string(), custom_kind("b.rhai", false), 95_000),
RegionDefinition::new("task".to_string(), RegionKind::Pinned, 4_000),
];
let layout = ContextLayout::new(regions, 100_000);
with_tracing(|| {
assert!(layout.validate().is_ok());
});
}
#[test]
fn custom_region_satisfies_the_message_region_check() {
let regions = vec![RegionDefinition::new(
"everything".to_string(),
custom_kind("all.rhai", false),
9_000,
)];
let layout = ContextLayout::new(regions, 10_000);
with_tracing(|| {
assert!(layout.validate().is_ok());
});
}
#[test]
fn resolved_percent_budget_applies_to_custom_region() {
let def = RegionDefinition::new("brain".to_string(), custom_kind("b.rhai", false), 0)
.with_budget(BudgetSpec::Percent {
percent: 0.40,
min: Some(10_000),
max: None,
});
let layout = ContextLayout::new(vec![def], 0);
let resolved = layout.resolved(200_000);
assert_eq!(resolved.regions[0].max_tokens, 80_000);
assert!(matches!(
resolved.regions[0].kind,
RegionKind::Custom { ref script, persistent: false } if script == "b.rhai"
));
let small = layout.resolved(8_192);
assert_eq!(small.regions[0].max_tokens, 10_000);
}
#[test]
fn test_get_region_found() {
let regions = vec![
RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000),
RegionDefinition::new("b".to_string(), RegionKind::Temporary, 3000),
];
let layout = ContextLayout::new(regions, 10000);
let found = layout.get_region("b").unwrap();
assert_eq!(found.name, "b");
assert_eq!(found.max_tokens, 3000);
}
#[test]
fn test_get_region_not_found() {
let regions = vec![RegionDefinition::new(
"a".to_string(),
RegionKind::Pinned,
5000,
)];
let layout = ContextLayout::new(regions, 10000);
assert!(layout.get_region("missing").is_none());
}
#[test]
fn test_region_definition_with_schema() {
let schema = crate::region::RegionSchema::new(crate::region::ContentFormat::Json);
let def =
RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000).with_schema(schema);
assert_eq!(
def.schema.as_ref().unwrap().format,
crate::region::ContentFormat::Json
);
}
#[test]
fn test_region_definition_with_description() {
let def = RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000)
.with_description("holds architecture notes".to_string());
assert_eq!(def.description.as_deref(), Some("holds architecture notes"));
}
#[test]
fn parse_budget_accepts_plain_and_decimal_percentages() {
assert_eq!(BudgetSpec::parse_budget("35%").unwrap(), 0.35);
assert_eq!(BudgetSpec::parse_budget("100%").unwrap(), 1.0);
assert!((BudgetSpec::parse_budget("0.6%").unwrap() - 0.006).abs() < 1e-9);
}
#[test]
fn parse_budget_trims_surrounding_and_inner_whitespace() {
assert_eq!(BudgetSpec::parse_budget(" 35 % ").unwrap(), 0.35);
}
#[test]
fn parse_budget_rejects_missing_percent_sign() {
let err = BudgetSpec::parse_budget("35").unwrap_err();
assert!(err.contains("must end with '%'"), "{err}");
}
#[test]
fn parse_budget_rejects_non_numeric() {
let err = BudgetSpec::parse_budget("abc%").unwrap_err();
assert!(err.contains("not a valid number"), "{err}");
}
#[test]
fn parse_budget_rejects_zero_and_negative() {
let zero = BudgetSpec::parse_budget("0%").unwrap_err();
assert!(zero.contains("greater than 0%"), "{zero}");
let neg = BudgetSpec::parse_budget("-10%").unwrap_err();
assert!(neg.contains("greater than 0%"), "{neg}");
}
#[test]
fn parse_budget_rejects_over_one_hundred() {
let err = BudgetSpec::parse_budget("150%").unwrap_err();
assert!(err.contains("at most 100%"), "{err}");
}
#[test]
fn resolve_absolute_ignores_window() {
assert_eq!(BudgetSpec::Absolute(4000).resolve(1_000_000), 4000);
assert!(!BudgetSpec::Absolute(4000).is_percent());
}
#[test]
fn resolve_percent_of_window() {
let spec = BudgetSpec::Percent {
percent: 0.35,
min: None,
max: None,
};
assert_eq!(spec.resolve(1_000_000), 350_000);
assert!(spec.is_percent());
}
#[test]
fn resolve_percent_applies_max_cap() {
let spec = BudgetSpec::Percent {
percent: 0.02,
min: None,
max: Some(4000),
};
assert_eq!(spec.resolve(1_000_000), 4000);
}
#[test]
fn resolve_percent_applies_min_floor() {
let spec = BudgetSpec::Percent {
percent: 0.02,
min: Some(2000),
max: None,
};
assert_eq!(spec.resolve(8000), 2000);
}
#[test]
fn resolve_percent_within_bounds_takes_neither_clamp() {
let spec = BudgetSpec::Percent {
percent: 0.10,
min: Some(1000),
max: Some(50_000),
};
assert_eq!(spec.resolve(200_000), 20_000);
}
#[test]
fn resolve_percent_floor_wins_when_min_exceeds_max() {
let spec = BudgetSpec::Percent {
percent: 0.10,
min: Some(9000),
max: Some(4000),
};
assert_eq!(spec.resolve(200_000), 9000);
}
#[test]
fn has_percent_budgets_detects_percentage_regions() {
let absolute = ContextLayout::new(
vec![RegionDefinition::new(
"a".to_string(),
RegionKind::Pinned,
5000,
)],
5000,
);
assert!(!absolute.has_percent_budgets());
let percent = ContextLayout::new(
vec![
RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000).with_budget(
BudgetSpec::Percent {
percent: 0.05,
min: None,
max: None,
},
),
],
5000,
);
assert!(percent.has_percent_budgets());
}
#[test]
fn resolved_is_noop_for_absolute_layout() {
let layout = ContextLayout::new(
vec![RegionDefinition::new(
"a".to_string(),
RegionKind::Pinned,
5000,
)],
5000,
);
let resolved = layout.resolved(1_000_000);
assert_eq!(resolved.regions[0].max_tokens, 5000);
assert_eq!(resolved.total_budget_tokens, 5000);
}
#[test]
fn resolved_percent_layout_uses_window_as_total() {
let layout = ContextLayout::new(
vec![
RegionDefinition::new("a".to_string(), RegionKind::Pinned, 0).with_budget(
BudgetSpec::Percent {
percent: 0.10,
min: None,
max: None,
},
),
],
0,
)
.with_eviction_order(vec!["a".to_string()]);
let resolved = layout.resolved(1_000_000);
assert_eq!(resolved.regions[0].max_tokens, 100_000);
assert_eq!(resolved.total_budget_tokens, 1_000_000);
assert_eq!(resolved.eviction_order, vec!["a".to_string()]);
}
#[test]
fn resolved_compacting_threshold_all_cases() {
let both = RegionDefinition::new(
"c".to_string(),
RegionKind::Compacting {
threshold_tokens: 25_000,
},
0,
)
.with_budget(BudgetSpec::Percent {
percent: 0.20,
min: None,
max: None,
})
.with_compact_at(0.80);
let r = ContextLayout::new(vec![both], 0).resolved(200_000);
assert_eq!(
r.regions[0].kind,
RegionKind::Compacting {
threshold_tokens: 25_000
}
);
let pct_only = RegionDefinition::new(
"c".to_string(),
RegionKind::Compacting {
threshold_tokens: usize::MAX,
},
0,
)
.with_budget(BudgetSpec::Percent {
percent: 0.20,
min: None,
max: None,
})
.with_compact_at(0.80);
let r = ContextLayout::new(vec![pct_only], 0).resolved(200_000);
assert_eq!(
r.regions[0].kind,
RegionKind::Compacting {
threshold_tokens: 32_000
}
);
let absolute = RegionDefinition::new(
"c".to_string(),
RegionKind::Compacting {
threshold_tokens: 8000,
},
10_000,
);
let r = ContextLayout::new(vec![absolute], 10_000).resolved(1_000_000);
assert_eq!(
r.regions[0].kind,
RegionKind::Compacting {
threshold_tokens: 8000
}
);
}
#[test]
fn validate_skips_token_checks_for_percent_layouts() {
let regions = vec![
RegionDefinition::new("big_pinned".to_string(), RegionKind::Pinned, 0).with_budget(
BudgetSpec::Percent {
percent: 0.95,
min: None,
max: None,
},
),
];
let layout = ContextLayout::new(regions, 100_000);
with_tracing(|| {
assert!(layout.validate().is_ok());
});
}
#[test]
fn region_definition_default_budget_matches_max_tokens() {
let def = RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000);
assert_eq!(def.budget, BudgetSpec::Absolute(5000));
assert_eq!(def.compact_at, None);
}
#[test]
fn budget_spec_default_is_absolute_zero() {
assert_eq!(BudgetSpec::default(), BudgetSpec::Absolute(0));
}
#[test]
fn test_validate_with_sliding_window_present() {
let regions = vec![
RegionDefinition::new("pinned".to_string(), RegionKind::Pinned, 5000),
RegionDefinition::new(
"conv".to_string(),
RegionKind::SlidingWindow {
max_items: 50,
eviction_strategy: crate::region::EvictionStrategy::PerItem,
},
5000,
),
];
let layout = ContextLayout::new(regions, 20000);
with_tracing(|| {
assert!(layout.validate().is_ok());
});
}
}