leviath_core/layout.rs
1//! Context window layouts and memory maps.
2//!
3//! A layout defines the complete memory structure for an agent's context window,
4//! including all regions, their sizes, and eviction priorities. This is analogous
5//! to a hardware memory map that defines where different types of data live and
6//! how they're managed.
7
8use crate::error::ValidationError;
9use crate::region::{RegionKind, RegionSchema};
10use serde::{Deserialize, Serialize};
11
12/// The region a stage's `system_prompt` is written into, when a blueprint
13/// declares one by this name.
14///
15/// Stage instructions have always been pinned context - that is why they read
16/// as instruction rather than history - but the region holding them was chosen
17/// by accident: whichever pinned region happened to be declared first. That
18/// region then carried the prompt's tokens in the stage ledger under its own
19/// name, could not be sized or scoped, and sat wherever it sat in the cached
20/// prefix (#366).
21///
22/// Declaring a region by this name gives the prompt a handle:
23///
24/// ```toml
25/// [context.regions]
26/// stage_instructions = { kind = "pinned", budget = "3%" }
27/// ```
28///
29/// A blueprint that declares nothing by this name keeps the old behaviour
30/// exactly, so this costs no existing agent anything.
31pub const STAGE_INSTRUCTIONS_REGION: &str = "stage_instructions";
32
33/// Serde default for a flag that is on unless a blueprint turns it off.
34fn default_true() -> bool {
35 true
36}
37
38/// How a region's token ceiling is expressed before it is resolved against a
39/// concrete model context window.
40///
41/// Blueprint authors think in **proportions** (`budget = "35%"`) so their intent
42/// stays correct regardless of the model's context size, while power users can
43/// still pin an exact count. The percentage denominator - the model's context
44/// window - is not known at parse time, so the spec is stored unresolved here and
45/// turned into a concrete token count at window-build time (see
46/// [`BudgetSpec::resolve`] and [`ContextLayout::resolved`]).
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum BudgetSpec {
50 /// A fixed token ceiling, independent of the model. Resolving is a no-op.
51 Absolute(usize),
52
53 /// A ceiling expressed as a fraction of the model's context window, with
54 /// optional absolute guard-rails. `percent` is a fraction (`0.35` for
55 /// `"35%"`). `max` caps the resolved value (so e.g. 2% of a 1M window can't
56 /// balloon a task region to 20K tokens); `min` floors it (so a small-context
57 /// model doesn't starve the region below a usable size).
58 Percent {
59 /// Fraction of the model context window (0.35 == "35%").
60 percent: f64,
61 /// Absolute floor for the resolved value, if any.
62 min: Option<usize>,
63 /// Absolute cap for the resolved value, if any.
64 max: Option<usize>,
65 },
66}
67
68impl Default for BudgetSpec {
69 /// Only a serde-deserialize fallback for older persisted blueprints; live
70 /// code always sets the budget explicitly via [`RegionDefinition::new`].
71 fn default() -> Self {
72 BudgetSpec::Absolute(0)
73 }
74}
75
76impl BudgetSpec {
77 /// Parse a percentage string like `"35%"` into its fraction (`0.35`).
78 ///
79 /// Surrounding whitespace is trimmed and decimals are allowed (`"0.6%"`).
80 /// Rejects a missing `%`, a non-numeric value, and anything outside the
81 /// `(0, 100]` range - a single region can't sensibly claim ≤0% or more than
82 /// the whole window (region budgets may *sum* past 100%, but each is a
83 /// fraction of one window). Returns the human-readable reason on failure so
84 /// the caller can surface it at load time.
85 pub fn parse_budget(s: &str) -> std::result::Result<f64, String> {
86 let trimmed = s.trim();
87 let Some(num) = trimmed.strip_suffix('%') else {
88 return Err(format!("budget '{s}' must end with '%' (e.g. \"35%\")"));
89 };
90 let value: f64 = num
91 .trim()
92 .parse()
93 .map_err(|_| format!("budget '{s}' is not a valid number"))?;
94 if !(value > 0.0 && value <= 100.0) {
95 return Err(format!(
96 "budget '{s}' must be greater than 0% and at most 100%"
97 ));
98 }
99 Ok(value / 100.0)
100 }
101
102 /// Resolve this spec to a concrete token count against a model context
103 /// `window`.
104 ///
105 /// [`Absolute`](BudgetSpec::Absolute) ignores the window (idempotent - a
106 /// fully-absolute layout resolves to itself). [`Percent`](BudgetSpec::Percent)
107 /// rounds `window * percent`, then applies the `max` cap, then the `min`
108 /// floor. The floor is applied **last** so that when `min > max` the floor
109 /// wins: a region starved below a usable size is worse than one slightly over
110 /// its cap.
111 pub fn resolve(&self, window: usize) -> usize {
112 match self {
113 BudgetSpec::Absolute(n) => *n,
114 BudgetSpec::Percent { percent, min, max } => {
115 let mut v = (window as f64 * percent).round() as usize;
116 if let Some(max) = max {
117 v = v.min(*max);
118 }
119 if let Some(min) = min {
120 v = v.max(*min);
121 }
122 v
123 }
124 }
125 }
126
127 /// Whether this is a percentage budget (needs a model window to resolve).
128 pub fn is_percent(&self) -> bool {
129 matches!(self, BudgetSpec::Percent { .. })
130 }
131}
132
133/// A ContextLayout defines the complete memory map for an agent.
134///
135/// Like SNES VRAM layout - every region has a defined purpose, size, and policy.
136/// The layout specifies:
137/// - Which regions exist and their configurations
138/// - Total token budget across all regions
139/// - Eviction order when space is needed
140///
141/// Layouts are typically defined in an agent's blueprint and remain constant
142/// throughout the agent's lifecycle, though the content within regions changes.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct ContextLayout {
145 /// All regions in this layout
146 pub regions: Vec<RegionDefinition>,
147
148 /// Total token budget across all regions
149 pub total_budget_tokens: usize,
150
151 /// Region names in eviction priority order (first = evicted first)
152 ///
153 /// When the context window fills up, regions are processed in this order:
154 /// 1. Temporary regions: evict oldest entries
155 /// 2. Compacting regions: trigger summarization
156 /// 3. SlidingWindow regions: reduce window size
157 /// 4. Pinned regions: NEVER touched (if these fill up, it's a config error)
158 pub eviction_order: Vec<String>,
159}
160
161impl ContextLayout {
162 /// Create a new layout with the specified configuration.
163 pub fn new(regions: Vec<RegionDefinition>, total_budget_tokens: usize) -> Self {
164 Self {
165 regions,
166 total_budget_tokens,
167 eviction_order: Vec::new(),
168 }
169 }
170
171 /// Set the eviction order for this layout.
172 pub fn with_eviction_order(mut self, order: Vec<String>) -> Self {
173 self.eviction_order = order;
174 self
175 }
176
177 /// Validate that the layout is well-formed.
178 ///
179 /// Checks:
180 /// - Sum of max_tokens doesn't exceed total_budget_tokens
181 /// - All region names in eviction_order exist
182 /// - No duplicate region names
183 pub fn validate(&self) -> std::result::Result<(), ValidationError> {
184 // Check for duplicate region names
185 let mut names = std::collections::HashSet::new();
186 for region in &self.regions {
187 if !names.insert(region.name.as_str()) {
188 return Err(ValidationError::Region {
189 region: region.name.clone(),
190 message: "duplicate region name".to_string(),
191 });
192 }
193 }
194
195 // Check that eviction_order regions exist
196 for name in &self.eviction_order {
197 if !names.contains(name.as_str()) {
198 return Err(ValidationError::Layout(format!(
199 "eviction order references unknown region: {}",
200 name
201 )));
202 }
203 }
204
205 // Reject a Custom region whose script path is empty - it could never
206 // resolve to a file, and the runtime would silently fall back to
207 // Temporary-style rendering on every inference.
208 for region in &self.regions {
209 if let RegionKind::Custom { script, .. } = ®ion.kind
210 && script.trim().is_empty()
211 {
212 return Err(ValidationError::Region {
213 region: region.name.clone(),
214 message: "custom region requires a non-empty script path".to_string(),
215 });
216 }
217 }
218
219 // Warn if sum of max tokens exceeds budget (not necessarily an error,
220 // since not all regions will be full simultaneously)
221 // Warn if no SlidingWindow region exists - agents should have a
222 // conversation region for typed message entries, but some agents
223 // (e.g., deep-researcher) use other region kinds exclusively. A Custom
224 // region counts: its script can render typed entries as messages.
225 let has_message_region = self.regions.iter().any(|r| {
226 matches!(
227 r.kind,
228 RegionKind::SlidingWindow { .. } | RegionKind::Custom { .. }
229 )
230 });
231 if !has_message_region {
232 tracing::warn!(
233 "Layout has no SlidingWindow (or custom scripted) region - typed \
234 conversation entries require one"
235 );
236 }
237
238 // The token-sum warning and the fixed-working-budget hard error below
239 // operate on concrete `max_tokens` values. When percentage budgets are
240 // present those values are provisional placeholders until the layout is
241 // resolved against a model window, so the checks are meaningless here -
242 // skip them and rely on the post-resolution `validate()` call at spawn.
243 if self.has_percent_budgets() {
244 return Ok(());
245 }
246
247 let total_max: usize = self.regions.iter().map(|r| r.max_tokens).sum();
248 if total_max > self.total_budget_tokens {
249 tracing::warn!(
250 "Sum of region max tokens ({}) exceeds total budget ({})",
251 total_max,
252 self.total_budget_tokens
253 );
254 }
255
256 // Ensure the layout leaves a minimum working budget once the fixed,
257 // non-evictable regions are full. Pinned / HashMap / CompactHistory
258 // regions persist for the whole run and consume budget; if they leave
259 // too little room, the conversation/tool-result (evictable) regions have
260 // almost no space and the agent operates "blind". Fail loudly at load
261 // instead of degrading silently at runtime.
262 let fixed_tokens: usize = self
263 .regions
264 .iter()
265 .filter(|r| {
266 matches!(
267 r.kind,
268 RegionKind::Pinned
269 | RegionKind::HashMap { .. }
270 | RegionKind::CompactHistory { .. }
271 | RegionKind::Custom {
272 persistent: true,
273 ..
274 }
275 )
276 })
277 .map(|r| r.max_tokens)
278 .sum();
279 // Only enforce the absolute working-budget floor on realistically-sized
280 // layouts. Tiny illustrative layouts (toy examples, unit-test fixtures)
281 // have small budgets by design and are not real agent runs; applying an
282 // absolute floor to them would be nonsensical.
283 let working_tokens = self.total_budget_tokens.saturating_sub(fixed_tokens);
284 if self.total_budget_tokens >= Self::BUDGET_CHECK_MIN_TOTAL
285 && working_tokens < Self::MIN_WORKING_TOKENS
286 {
287 return Err(ValidationError::Layout(format!(
288 "context layout leaves only {working_tokens} working tokens after fixed \
289 regions (pinned/hashmap/compact_history/persistent custom) consume \
290 {fixed_tokens} of the {} \
291 total budget; at least {} are needed for the agent to operate. Reduce the \
292 fixed regions' max_tokens or increase the total budget.",
293 self.total_budget_tokens,
294 Self::MIN_WORKING_TOKENS
295 )));
296 }
297
298 Ok(())
299 }
300
301 /// Minimum token budget that must remain for evictable/working regions
302 /// (conversation, tool results, scratch) after the fixed regions are full,
303 /// so the agent has room to hold recent context and generate. Below this a
304 /// run would operate with almost no working space.
305 const MIN_WORKING_TOKENS: usize = 8000;
306
307 /// The working-budget floor is only enforced when the layout's total budget
308 /// is at least this large - i.e. it's a realistically-sized agent, not a
309 /// toy/illustrative layout where an absolute floor wouldn't make sense.
310 const BUDGET_CHECK_MIN_TOTAL: usize = 20_000;
311
312 /// Get a region definition by name.
313 pub fn get_region(&self, name: &str) -> Option<&RegionDefinition> {
314 self.regions.iter().find(|r| r.name == name)
315 }
316
317 /// Whether any region uses a percentage budget (and therefore needs a model
318 /// context window to resolve to concrete token counts).
319 pub fn has_percent_budgets(&self) -> bool {
320 self.regions.iter().any(|r| r.budget.is_percent())
321 }
322
323 /// Resolve every region's percentage budget against a concrete model context
324 /// `window`, returning a fully-absolute layout.
325 ///
326 /// Each region's `max_tokens` becomes `budget.resolve(window)`, and each
327 /// [`RegionKind::Compacting`] region's `threshold_tokens` is recomputed from
328 /// its [`compact_at`](RegionDefinition::compact_at) fraction (via the private
329 /// `resolve_compacting_threshold` helper). `eviction_order` is preserved. The
330 /// total budget becomes the model `window` when any percentage budget is
331 /// present (percentage ceilings are relative to the whole window and may sum
332 /// past 100%); a pure-absolute layout keeps its legacy summed total unchanged.
333 ///
334 /// Resolving an already-absolute layout is a no-op, so this is safe to call
335 /// unconditionally at window-build time.
336 pub fn resolved(&self, window: usize) -> ContextLayout {
337 let regions = self
338 .regions
339 .iter()
340 .map(|r| {
341 let max_tokens = r.budget.resolve(window);
342 let kind = match &r.kind {
343 RegionKind::Compacting { threshold_tokens } => RegionKind::Compacting {
344 threshold_tokens: Self::resolve_compacting_threshold(
345 r.compact_at,
346 *threshold_tokens,
347 max_tokens,
348 ),
349 },
350 other => other.clone(),
351 };
352 // Emit a fully-absolute region: the percentage has been baked
353 // into `max_tokens` and the compaction threshold into `kind`, so
354 // the resolved layout carries no `Percent` budgets. This makes
355 // `has_percent_budgets()` false on the result, so a post-resolution
356 // `validate()` runs the real token/working-budget checks.
357 RegionDefinition {
358 kind,
359 max_tokens,
360 budget: BudgetSpec::Absolute(max_tokens),
361 compact_at: None,
362 ..r.clone()
363 }
364 })
365 .collect();
366
367 let total_budget_tokens = if self.has_percent_budgets() {
368 window
369 } else {
370 self.total_budget_tokens
371 };
372
373 ContextLayout {
374 regions,
375 total_budget_tokens,
376 eviction_order: self.eviction_order.clone(),
377 }
378 }
379
380 /// Compute a Compacting region's concrete compaction threshold from its
381 /// `compact_at` fraction, the absolute `threshold_tokens` guard carried on
382 /// the kind, and the region's resolved budget.
383 ///
384 /// - `compact_at = Some(f)` with an explicit `threshold_tokens` cap (any
385 /// value below the [`usize::MAX`] sentinel) → `min(round(budget * f), cap)`:
386 /// compact at the percentage, but never later than the absolute guard-rail.
387 /// - `compact_at = Some(f)` with no cap (`threshold_tokens == usize::MAX`
388 /// sentinel) → `round(budget * f)`.
389 /// - `compact_at = None` → the absolute `threshold_tokens` as-is (back-compat,
390 /// including the parser's `max_tokens * 8 / 10` default).
391 ///
392 /// The `usize::MAX` sentinel is safe: a layout is always resolved before any
393 /// [`Region::needs_compaction`](crate::region::Region::needs_compaction) check.
394 fn resolve_compacting_threshold(
395 compact_at: Option<f64>,
396 threshold_tokens: usize,
397 resolved_budget: usize,
398 ) -> usize {
399 match compact_at {
400 Some(fraction) => {
401 let pct = (resolved_budget as f64 * fraction).round() as usize;
402 pct.min(threshold_tokens)
403 }
404 None => threshold_tokens,
405 }
406 }
407}
408
409/// Where a region's initial content comes from at run start.
410///
411/// A region without a seed starts empty and is populated by the agent. A seeded
412/// region is filled before the first inference: `CallerInput` regions are filled
413/// by the run's caller (a CLI `--<name>` flag, an ACP `---region:<name>---`
414/// marker, or the API `regions` map); the remaining variants are resolved by the
415/// daemon from the run's workdir (which is why this type only *declares* the
416/// source - `leviath-core` stays filesystem-agnostic; resolution lives in the
417/// CLI daemon's spawner).
418#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
419#[serde(rename_all = "snake_case")]
420pub enum RegionSeed {
421 /// Filled at run time by the caller, keyed by `name` (defaults to the
422 /// region's own name; the sentinel `task` maps to the `--task`/prompt text).
423 /// When the owning region is `required`, a missing value is a hard error
424 /// before any inference runs.
425 CallerInput {
426 /// The caller-input key this region is filled from.
427 name: String,
428 },
429 /// Concatenated contents of the workdir files matching a glob pattern.
430 Glob {
431 /// Glob pattern, resolved relative to the run's workdir.
432 pattern: String,
433 },
434 /// Concatenated contents of an explicit list of workdir-relative files.
435 Files {
436 /// File paths, resolved relative to the run's workdir.
437 paths: Vec<String>,
438 },
439 /// A static literal string baked into the blueprint.
440 Literal {
441 /// The verbatim seed text.
442 text: String,
443 },
444 /// The `String` returned by running a Rhai script from the workdir.
445 Rhai {
446 /// Script path, resolved relative to the run's workdir.
447 script: String,
448 },
449 /// The combined stdout/stderr of a shell command run in the workdir at spawn.
450 ///
451 /// Unlike every other variant this *executes* something, and it does so
452 /// before the first inference - so before any tool-approval prompt. The
453 /// daemon runs it inside the entry stage's sandbox when one is configured,
454 /// caps its runtime and output, and honours the `[security]
455 /// allow_seed_commands` kill switch. A failure is non-fatal unless the
456 /// owning region is `required`.
457 Command {
458 /// The shell command line, run with the platform shell in the workdir.
459 command: String,
460 },
461}
462
463/// Definition of a region in a layout.
464///
465/// This is the blueprint for creating a Region instance. It specifies the
466/// region's configuration but doesn't contain actual content.
467#[derive(Debug, Clone, Serialize, Deserialize)]
468pub struct RegionDefinition {
469 /// Unique name for this region
470 pub name: String,
471
472 /// Region lifecycle policy
473 pub kind: RegionKind,
474
475 /// **Resolved** maximum tokens for this region. This is the concrete ceiling
476 /// every downstream consumer reads; for a percentage budget it is populated
477 /// when the layout is resolved against a model window (see
478 /// [`ContextLayout::resolved`]). [`Self::budget`] is the source of truth for
479 /// how this value is derived.
480 pub max_tokens: usize,
481
482 /// How this region's ceiling is expressed. Defaults (via [`Self::new`]) to
483 /// [`BudgetSpec::Absolute`] holding `max_tokens`, so a region built the old
484 /// way behaves exactly as before. A percentage budget is resolved against the
485 /// model context window at window-build time.
486 #[serde(default)]
487 pub budget: BudgetSpec,
488
489 /// For [`RegionKind::Compacting`] regions only: compact when the region
490 /// reaches this fraction of its resolved budget (`0.80` for `compact_at =
491 /// "80%"`). `None` keeps the absolute `threshold_tokens` carried on the kind.
492 /// See [`ContextLayout::resolved`] for how this becomes a concrete threshold.
493 #[serde(default)]
494 pub compact_at: Option<f64>,
495
496 /// Optional validation schema
497 pub schema: Option<RegionSchema>,
498
499 /// Human-readable description of this region's purpose
500 pub description: Option<String>,
501
502 /// When true, this region must be non-empty before a stage that can write
503 /// to it is allowed to complete. Guards against an agent skipping a
504 /// context-population step (e.g. never writing the `plan` region). Enforced
505 /// in the run loop, which re-runs the stage with [`Self::required_message`]
506 /// until the region is populated.
507 #[serde(default)]
508 pub required: bool,
509
510 /// Whether an edge transform may hand this region to the summarizer.
511 ///
512 /// `transform = "compact"` reads as "summarize the transcript on the way
513 /// out" and means "summarize every region that is not pinned", which
514 /// includes the ones holding the run's results. Figures that survive a
515 /// paraphrase are no longer figures: a `results` region carrying computed
516 /// values was rewritten into prose before the stage that reports them saw
517 /// it (#369).
518 ///
519 /// Setting this false protects the region wherever it is used, rather than
520 /// at each of the N edges that might touch it. `clear` still applies - this
521 /// says "do not paraphrase my content", not "keep it forever".
522 #[serde(default = "default_true")]
523 pub summarizable: bool,
524
525 /// What this region does when a write does not fit.
526 ///
527 /// Declared per region rather than per stage: whether losing the oldest
528 /// entry is acceptable is a property of what the region holds, and does not
529 /// change depending on which stage is writing to it.
530 #[serde(default)]
531 pub admission: crate::region::Admission,
532
533 /// Optional custom message shown to the agent when this region is required
534 /// but empty. Falls back to a generated default when `None`.
535 #[serde(default)]
536 pub required_message: Option<String>,
537
538 /// Where this region's initial content comes from at run start. `None`
539 /// means the region starts empty (the agent populates it). See
540 /// [`RegionSeed`].
541 #[serde(default)]
542 pub seed: Option<RegionSeed>,
543}
544
545impl RegionDefinition {
546 /// Create a new region definition with an absolute token ceiling.
547 ///
548 /// The `budget` is set to [`BudgetSpec::Absolute`] holding `max_tokens` and
549 /// `compact_at` to `None`, so every existing caller (and every region without
550 /// a percentage budget) is unaffected - resolving such a layout is a no-op.
551 pub fn new(name: String, kind: RegionKind, max_tokens: usize) -> Self {
552 Self {
553 name,
554 kind,
555 max_tokens,
556 budget: BudgetSpec::Absolute(max_tokens),
557 compact_at: None,
558 schema: None,
559 description: None,
560 required: false,
561 required_message: None,
562 summarizable: true,
563 admission: crate::region::Admission::default(),
564 seed: None,
565 }
566 }
567
568 /// Set this region's budget spec (e.g. a percentage of the model window).
569 /// `max_tokens` is left as the provisional/resolved value; it is (re)computed
570 /// from the budget when the owning layout is resolved.
571 pub fn with_budget(mut self, budget: BudgetSpec) -> Self {
572 self.budget = budget;
573 self
574 }
575
576 /// Set the compaction trigger fraction for a [`RegionKind::Compacting`]
577 /// region (`0.80` == compact at 80% of the resolved budget).
578 pub fn with_compact_at(mut self, fraction: f64) -> Self {
579 self.compact_at = Some(fraction);
580 self
581 }
582
583 /// Set this region's seed source.
584 pub fn with_seed(mut self, seed: RegionSeed) -> Self {
585 self.seed = Some(seed);
586 self
587 }
588
589 /// Mark this region as required, with an optional custom nudge message.
590 pub fn with_required(mut self, required: bool, message: Option<String>) -> Self {
591 self.required = required;
592 self.required_message = message;
593 self
594 }
595
596 /// Add a schema to this region definition.
597 pub fn with_schema(mut self, schema: RegionSchema) -> Self {
598 self.schema = Some(schema);
599 self
600 }
601
602 /// Add a description to this region definition.
603 pub fn with_description(mut self, description: String) -> Self {
604 self.description = Some(description);
605 self
606 }
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612 use leviath_testkit::with_tracing;
613
614 #[test]
615 fn test_layout_creation() {
616 let regions = vec![
617 RegionDefinition::new("pinned".to_string(), RegionKind::Pinned, 5000),
618 RegionDefinition::new("temp".to_string(), RegionKind::Temporary, 10000),
619 ];
620 let layout = ContextLayout::new(regions, 20000);
621 assert_eq!(layout.regions.len(), 2);
622 assert_eq!(layout.total_budget_tokens, 20000);
623 }
624
625 #[test]
626 fn test_layout_validation() {
627 let regions = vec![RegionDefinition::new(
628 "test".to_string(),
629 RegionKind::Pinned,
630 5000,
631 )];
632 let layout =
633 ContextLayout::new(regions, 10000).with_eviction_order(vec!["test".to_string()]);
634
635 assert!(layout.validate().is_ok());
636 }
637
638 #[test]
639 fn test_duplicate_region_names() {
640 let regions = vec![
641 RegionDefinition::new("test".to_string(), RegionKind::Pinned, 5000),
642 RegionDefinition::new("test".to_string(), RegionKind::Temporary, 3000),
643 ];
644 let layout = ContextLayout::new(regions, 10000);
645
646 assert!(layout.validate().is_err());
647 }
648
649 #[test]
650 fn test_eviction_order_unknown_region_is_error() {
651 let regions = vec![RegionDefinition::new(
652 "test".to_string(),
653 RegionKind::Pinned,
654 5000,
655 )];
656 let layout =
657 ContextLayout::new(regions, 10000).with_eviction_order(vec!["nonexistent".to_string()]);
658
659 let err = layout.validate().unwrap_err();
660 assert_eq!(
661 err,
662 ValidationError::Layout(
663 "eviction order references unknown region: nonexistent".to_string()
664 )
665 );
666 }
667
668 #[test]
669 fn test_validate_warns_but_does_not_error_when_max_tokens_exceed_budget() {
670 // Sum of region max_tokens (5000 + 10000 = 15000) exceeds the total
671 // budget (10000) - this should only warn, not fail validation, since
672 // not all regions are full simultaneously.
673 let regions = vec![
674 RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000),
675 RegionDefinition::new("b".to_string(), RegionKind::Temporary, 10000),
676 ];
677 let layout = ContextLayout::new(regions, 10000);
678 with_tracing(|| {
679 assert!(layout.validate().is_ok());
680 });
681 }
682
683 #[test]
684 fn validate_errors_when_fixed_regions_starve_working_budget() {
685 // Realistically-sized layout (>= 20k) where a huge fixed (pinned) region
686 // leaves < 8000 working tokens for conversation/tool-results → hard error.
687 let regions = vec![
688 RegionDefinition::new("big_pinned".to_string(), RegionKind::Pinned, 95_000),
689 RegionDefinition::new("work".to_string(), RegionKind::Temporary, 5_000),
690 ];
691 let layout = ContextLayout::new(regions, 100_000);
692 with_tracing(|| {
693 let err = layout.validate().unwrap_err();
694 assert!(
695 err.to_string().contains("working tokens"),
696 "actionable budget error: {err}"
697 );
698 });
699 }
700
701 #[test]
702 fn validate_ok_for_realistic_layout_with_working_room() {
703 let regions = vec![
704 RegionDefinition::new("task".to_string(), RegionKind::Pinned, 4_000),
705 RegionDefinition::new("conversation".to_string(), RegionKind::Temporary, 40_000),
706 ];
707 let layout = ContextLayout::new(regions, 44_000);
708 with_tracing(|| {
709 assert!(layout.validate().is_ok());
710 });
711 }
712
713 fn custom_kind(script: &str, persistent: bool) -> RegionKind {
714 RegionKind::Custom {
715 script: script.to_string(),
716 persistent,
717 }
718 }
719
720 #[test]
721 fn validate_rejects_custom_region_with_empty_script() {
722 // Whitespace-only counts as empty: it could never resolve to a file
723 // and the runtime would silently fall back on every inference.
724 let regions = vec![RegionDefinition::new(
725 "brain".to_string(),
726 custom_kind(" ", false),
727 5000,
728 )];
729 let layout = ContextLayout::new(regions, 10_000);
730 let err = with_tracing(|| layout.validate().unwrap_err());
731 assert!(
732 err.to_string().contains("non-empty script path"),
733 "actionable error: {err}"
734 );
735 }
736
737 #[test]
738 fn validate_counts_persistent_custom_as_fixed_budget() {
739 // A persistent custom region is Pinned-like: protected from eviction,
740 // so it must count toward the fixed budget that can starve the
741 // working room.
742 let regions = vec![
743 RegionDefinition::new("vault".to_string(), custom_kind("v.rhai", true), 95_000),
744 RegionDefinition::new("work".to_string(), RegionKind::Temporary, 5_000),
745 ];
746 let layout = ContextLayout::new(regions, 100_000);
747 let err = with_tracing(|| layout.validate().unwrap_err());
748 assert!(err.to_string().contains("working tokens"), "{err}");
749 }
750
751 #[test]
752 fn validate_counts_non_persistent_custom_as_working_budget() {
753 // Same shape, but the custom region is evictable - it IS the working
754 // room, so validation passes.
755 let regions = vec![
756 RegionDefinition::new("brain".to_string(), custom_kind("b.rhai", false), 95_000),
757 RegionDefinition::new("task".to_string(), RegionKind::Pinned, 4_000),
758 ];
759 let layout = ContextLayout::new(regions, 100_000);
760 with_tracing(|| {
761 assert!(layout.validate().is_ok());
762 });
763 }
764
765 #[test]
766 fn custom_region_satisfies_the_message_region_check() {
767 // A layout whose only region is custom must not trip the "no
768 // SlidingWindow region" warning path - its script can render typed
769 // entries as messages. (Mirrors the sliding-window-present test: the
770 // skip branch is exercised, validation succeeds.)
771 let regions = vec![RegionDefinition::new(
772 "everything".to_string(),
773 custom_kind("all.rhai", false),
774 9_000,
775 )];
776 let layout = ContextLayout::new(regions, 10_000);
777 with_tracing(|| {
778 assert!(layout.validate().is_ok());
779 });
780 }
781
782 #[test]
783 fn resolved_percent_budget_applies_to_custom_region() {
784 // The "recreate built-ins in Rhai" guarantee: percentage budgets work
785 // on custom regions exactly as on built-in kinds, resolved against
786 // the stage model's context window at spawn.
787 let def = RegionDefinition::new("brain".to_string(), custom_kind("b.rhai", false), 0)
788 .with_budget(BudgetSpec::Percent {
789 percent: 0.40,
790 min: Some(10_000),
791 max: None,
792 });
793 let layout = ContextLayout::new(vec![def], 0);
794 let resolved = layout.resolved(200_000);
795 assert_eq!(resolved.regions[0].max_tokens, 80_000);
796 assert!(matches!(
797 resolved.regions[0].kind,
798 RegionKind::Custom { ref script, persistent: false } if script == "b.rhai"
799 ));
800 // The min floor wins on a small window.
801 let small = layout.resolved(8_192);
802 assert_eq!(small.regions[0].max_tokens, 10_000);
803 }
804
805 #[test]
806 fn test_get_region_found() {
807 let regions = vec![
808 RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000),
809 RegionDefinition::new("b".to_string(), RegionKind::Temporary, 3000),
810 ];
811 let layout = ContextLayout::new(regions, 10000);
812
813 let found = layout.get_region("b").unwrap();
814 assert_eq!(found.name, "b");
815 assert_eq!(found.max_tokens, 3000);
816 }
817
818 #[test]
819 fn test_get_region_not_found() {
820 let regions = vec![RegionDefinition::new(
821 "a".to_string(),
822 RegionKind::Pinned,
823 5000,
824 )];
825 let layout = ContextLayout::new(regions, 10000);
826 assert!(layout.get_region("missing").is_none());
827 }
828
829 #[test]
830 fn test_region_definition_with_schema() {
831 let schema = crate::region::RegionSchema::new(crate::region::ContentFormat::Json);
832 let def =
833 RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000).with_schema(schema);
834 assert_eq!(
835 def.schema.as_ref().unwrap().format,
836 crate::region::ContentFormat::Json
837 );
838 }
839
840 #[test]
841 fn test_region_definition_with_description() {
842 let def = RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000)
843 .with_description("holds architecture notes".to_string());
844 assert_eq!(def.description.as_deref(), Some("holds architecture notes"));
845 }
846
847 #[test]
848 fn parse_budget_accepts_plain_and_decimal_percentages() {
849 assert_eq!(BudgetSpec::parse_budget("35%").unwrap(), 0.35);
850 assert_eq!(BudgetSpec::parse_budget("100%").unwrap(), 1.0);
851 assert!((BudgetSpec::parse_budget("0.6%").unwrap() - 0.006).abs() < 1e-9);
852 }
853
854 #[test]
855 fn parse_budget_trims_surrounding_and_inner_whitespace() {
856 assert_eq!(BudgetSpec::parse_budget(" 35 % ").unwrap(), 0.35);
857 }
858
859 #[test]
860 fn parse_budget_rejects_missing_percent_sign() {
861 let err = BudgetSpec::parse_budget("35").unwrap_err();
862 assert!(err.contains("must end with '%'"), "{err}");
863 }
864
865 #[test]
866 fn parse_budget_rejects_non_numeric() {
867 let err = BudgetSpec::parse_budget("abc%").unwrap_err();
868 assert!(err.contains("not a valid number"), "{err}");
869 }
870
871 #[test]
872 fn parse_budget_rejects_zero_and_negative() {
873 let zero = BudgetSpec::parse_budget("0%").unwrap_err();
874 assert!(zero.contains("greater than 0%"), "{zero}");
875 let neg = BudgetSpec::parse_budget("-10%").unwrap_err();
876 assert!(neg.contains("greater than 0%"), "{neg}");
877 }
878
879 #[test]
880 fn parse_budget_rejects_over_one_hundred() {
881 let err = BudgetSpec::parse_budget("150%").unwrap_err();
882 assert!(err.contains("at most 100%"), "{err}");
883 }
884
885 #[test]
886 fn resolve_absolute_ignores_window() {
887 assert_eq!(BudgetSpec::Absolute(4000).resolve(1_000_000), 4000);
888 assert!(!BudgetSpec::Absolute(4000).is_percent());
889 }
890
891 #[test]
892 fn resolve_percent_of_window() {
893 let spec = BudgetSpec::Percent {
894 percent: 0.35,
895 min: None,
896 max: None,
897 };
898 assert_eq!(spec.resolve(1_000_000), 350_000);
899 assert!(spec.is_percent());
900 }
901
902 #[test]
903 fn resolve_percent_applies_max_cap() {
904 let spec = BudgetSpec::Percent {
905 percent: 0.02,
906 min: None,
907 max: Some(4000),
908 };
909 // 2% of 1M = 20_000, capped to 4000.
910 assert_eq!(spec.resolve(1_000_000), 4000);
911 }
912
913 #[test]
914 fn resolve_percent_applies_min_floor() {
915 let spec = BudgetSpec::Percent {
916 percent: 0.02,
917 min: Some(2000),
918 max: None,
919 };
920 // 2% of 8000 = 160, floored to 2000.
921 assert_eq!(spec.resolve(8000), 2000);
922 }
923
924 #[test]
925 fn resolve_percent_within_bounds_takes_neither_clamp() {
926 let spec = BudgetSpec::Percent {
927 percent: 0.10,
928 min: Some(1000),
929 max: Some(50_000),
930 };
931 // 10% of 200k = 20_000, between the floor and cap.
932 assert_eq!(spec.resolve(200_000), 20_000);
933 }
934
935 #[test]
936 fn resolve_percent_floor_wins_when_min_exceeds_max() {
937 let spec = BudgetSpec::Percent {
938 percent: 0.10,
939 min: Some(9000),
940 max: Some(4000),
941 };
942 // 10% of 200k = 20_000 → capped to 4000 → floored up to 9000 (floor wins).
943 assert_eq!(spec.resolve(200_000), 9000);
944 }
945
946 #[test]
947 fn has_percent_budgets_detects_percentage_regions() {
948 let absolute = ContextLayout::new(
949 vec![RegionDefinition::new(
950 "a".to_string(),
951 RegionKind::Pinned,
952 5000,
953 )],
954 5000,
955 );
956 assert!(!absolute.has_percent_budgets());
957
958 let percent = ContextLayout::new(
959 vec![
960 RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000).with_budget(
961 BudgetSpec::Percent {
962 percent: 0.05,
963 min: None,
964 max: None,
965 },
966 ),
967 ],
968 5000,
969 );
970 assert!(percent.has_percent_budgets());
971 }
972
973 #[test]
974 fn resolved_is_noop_for_absolute_layout() {
975 let layout = ContextLayout::new(
976 vec![RegionDefinition::new(
977 "a".to_string(),
978 RegionKind::Pinned,
979 5000,
980 )],
981 5000,
982 );
983 let resolved = layout.resolved(1_000_000);
984 assert_eq!(resolved.regions[0].max_tokens, 5000);
985 // Absolute layout keeps its legacy summed total, not the window.
986 assert_eq!(resolved.total_budget_tokens, 5000);
987 }
988
989 #[test]
990 fn resolved_percent_layout_uses_window_as_total() {
991 let layout = ContextLayout::new(
992 vec![
993 RegionDefinition::new("a".to_string(), RegionKind::Pinned, 0).with_budget(
994 BudgetSpec::Percent {
995 percent: 0.10,
996 min: None,
997 max: None,
998 },
999 ),
1000 ],
1001 0,
1002 )
1003 .with_eviction_order(vec!["a".to_string()]);
1004 let resolved = layout.resolved(1_000_000);
1005 assert_eq!(resolved.regions[0].max_tokens, 100_000);
1006 assert_eq!(resolved.total_budget_tokens, 1_000_000);
1007 // eviction order carried through.
1008 assert_eq!(resolved.eviction_order, vec!["a".to_string()]);
1009 }
1010
1011 #[test]
1012 fn resolved_compacting_threshold_all_cases() {
1013 // compact_at + explicit threshold cap → min(pct, cap).
1014 let both = RegionDefinition::new(
1015 "c".to_string(),
1016 RegionKind::Compacting {
1017 threshold_tokens: 25_000,
1018 },
1019 0,
1020 )
1021 .with_budget(BudgetSpec::Percent {
1022 percent: 0.20,
1023 min: None,
1024 max: None,
1025 })
1026 .with_compact_at(0.80);
1027 let r = ContextLayout::new(vec![both], 0).resolved(200_000);
1028 // budget = 40_000; 80% = 32_000; capped to 25_000.
1029 assert_eq!(
1030 r.regions[0].kind,
1031 RegionKind::Compacting {
1032 threshold_tokens: 25_000
1033 }
1034 );
1035
1036 // compact_at with no cap (usize::MAX sentinel) → pct only.
1037 let pct_only = RegionDefinition::new(
1038 "c".to_string(),
1039 RegionKind::Compacting {
1040 threshold_tokens: usize::MAX,
1041 },
1042 0,
1043 )
1044 .with_budget(BudgetSpec::Percent {
1045 percent: 0.20,
1046 min: None,
1047 max: None,
1048 })
1049 .with_compact_at(0.80);
1050 let r = ContextLayout::new(vec![pct_only], 0).resolved(200_000);
1051 assert_eq!(
1052 r.regions[0].kind,
1053 RegionKind::Compacting {
1054 threshold_tokens: 32_000
1055 }
1056 );
1057
1058 // compact_at = None → absolute threshold passes through unchanged.
1059 let absolute = RegionDefinition::new(
1060 "c".to_string(),
1061 RegionKind::Compacting {
1062 threshold_tokens: 8000,
1063 },
1064 10_000,
1065 );
1066 let r = ContextLayout::new(vec![absolute], 10_000).resolved(1_000_000);
1067 assert_eq!(
1068 r.regions[0].kind,
1069 RegionKind::Compacting {
1070 threshold_tokens: 8000
1071 }
1072 );
1073 }
1074
1075 #[test]
1076 fn validate_skips_token_checks_for_percent_layouts() {
1077 // A percentage layout whose provisional max_tokens are tiny/zero must not
1078 // trip the fixed-working-budget hard error - that check is deferred to
1079 // post-resolution. Wrap in tracing so no warn-arg lines read uncovered.
1080 let regions = vec![
1081 RegionDefinition::new("big_pinned".to_string(), RegionKind::Pinned, 0).with_budget(
1082 BudgetSpec::Percent {
1083 percent: 0.95,
1084 min: None,
1085 max: None,
1086 },
1087 ),
1088 ];
1089 let layout = ContextLayout::new(regions, 100_000);
1090 with_tracing(|| {
1091 assert!(layout.validate().is_ok());
1092 });
1093 }
1094
1095 #[test]
1096 fn region_definition_default_budget_matches_max_tokens() {
1097 let def = RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000);
1098 assert_eq!(def.budget, BudgetSpec::Absolute(5000));
1099 assert_eq!(def.compact_at, None);
1100 }
1101
1102 #[test]
1103 fn budget_spec_default_is_absolute_zero() {
1104 assert_eq!(BudgetSpec::default(), BudgetSpec::Absolute(0));
1105 }
1106
1107 #[test]
1108 fn test_validate_with_sliding_window_present() {
1109 // A layout that DOES contain a SlidingWindow region exercises the
1110 // has_sliding_window detection returning true, so the "no sliding
1111 // window" warning branch is skipped.
1112 let regions = vec![
1113 RegionDefinition::new("pinned".to_string(), RegionKind::Pinned, 5000),
1114 RegionDefinition::new(
1115 "conv".to_string(),
1116 RegionKind::SlidingWindow {
1117 max_items: 50,
1118 eviction_strategy: crate::region::EvictionStrategy::PerItem,
1119 },
1120 5000,
1121 ),
1122 ];
1123 let layout = ContextLayout::new(regions, 20000);
1124 with_tracing(|| {
1125 assert!(layout.validate().is_ok());
1126 });
1127 }
1128}