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 are pinned context - that is why they read as
16/// instruction rather than history - and a region of their own is what lets
17/// the stage ledger bill the prompt's tokens under a name that says what they
18/// are, sizes them, and places them in the cached prefix on purpose rather
19/// than wherever the first pinned region happens to sit.
20///
21/// Declaring a region by this name gives the prompt a handle:
22///
23/// ```toml
24/// [context.regions]
25/// stage_instructions = { kind = "pinned", budget = "3%" }
26/// ```
27///
28/// A blueprint that declares nothing by this name still gets one: the runtime
29/// adds it at spawn, sized to the widest stage prompt the blueprint carries.
30pub const STAGE_INSTRUCTIONS_REGION: &str = "stage_instructions";
31
32/// How a region's token ceiling is expressed before it is resolved against a
33/// concrete model context window.
34///
35/// Blueprint authors think in **proportions** (`budget = "35%"`) so their intent
36/// stays correct regardless of the model's context size, while power users can
37/// still pin an exact count. The percentage denominator - the model's context
38/// window - is not known at parse time, so the spec is stored unresolved here and
39/// turned into a concrete token count at window-build time (see
40/// [`BudgetSpec::resolve`] and [`ContextLayout::resolved`]).
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum BudgetSpec {
44 /// A fixed token ceiling, independent of the model. Resolving is a no-op.
45 Absolute(usize),
46
47 /// A ceiling expressed as a fraction of the model's context window, with
48 /// optional absolute guard-rails. `percent` is a fraction (`0.35` for
49 /// `"35%"`). `max` caps the resolved value (so e.g. 2% of a 1M window can't
50 /// balloon a task region to 20K tokens); `min` floors it (so a small-context
51 /// model doesn't starve the region below a usable size).
52 Percent {
53 /// Fraction of the model context window (0.35 == "35%").
54 percent: f64,
55 /// Absolute floor for the resolved value, if any.
56 min: Option<usize>,
57 /// Absolute cap for the resolved value, if any.
58 max: Option<usize>,
59 },
60}
61
62impl Default for BudgetSpec {
63 /// Only a serde-deserialize fallback for older persisted blueprints; live
64 /// code always sets the budget explicitly via [`RegionDefinition::new`].
65 fn default() -> Self {
66 BudgetSpec::Absolute(0)
67 }
68}
69
70impl BudgetSpec {
71 /// Parse a percentage string like `"35%"` into its fraction (`0.35`).
72 ///
73 /// Surrounding whitespace is trimmed and decimals are allowed (`"0.6%"`).
74 /// Rejects a missing `%`, a non-numeric value, and anything outside the
75 /// `(0, 100]` range - a single region can't sensibly claim ≤0% or more than
76 /// the whole window (region budgets may *sum* past 100%, but each is a
77 /// fraction of one window). Returns the human-readable reason on failure so
78 /// the caller can surface it at load time.
79 pub fn parse_budget(s: &str) -> std::result::Result<f64, String> {
80 let trimmed = s.trim();
81 let Some(num) = trimmed.strip_suffix('%') else {
82 return Err(format!("budget '{s}' must end with '%' (e.g. \"35%\")"));
83 };
84 let value: f64 = num
85 .trim()
86 .parse()
87 .map_err(|_| format!("budget '{s}' is not a valid number"))?;
88 if !(value > 0.0 && value <= 100.0) {
89 return Err(format!(
90 "budget '{s}' must be greater than 0% and at most 100%"
91 ));
92 }
93 Ok(value / 100.0)
94 }
95
96 /// Resolve this spec to a concrete token count against a model context
97 /// `window`.
98 ///
99 /// [`Absolute`](BudgetSpec::Absolute) ignores the window (idempotent - a
100 /// fully-absolute layout resolves to itself). [`Percent`](BudgetSpec::Percent)
101 /// rounds `window * percent`, then applies the `max` cap, then the `min`
102 /// floor. The floor is applied **last** so that when `min > max` the floor
103 /// wins: a region starved below a usable size is worse than one slightly over
104 /// its cap.
105 pub fn resolve(&self, window: usize) -> usize {
106 match self {
107 BudgetSpec::Absolute(n) => *n,
108 BudgetSpec::Percent { percent, min, max } => {
109 let mut v = (window as f64 * percent).round() as usize;
110 if let Some(max) = max {
111 v = v.min(*max);
112 }
113 if let Some(min) = min {
114 v = v.max(*min);
115 }
116 v
117 }
118 }
119 }
120
121 /// Whether this is a percentage budget (needs a model window to resolve).
122 pub fn is_percent(&self) -> bool {
123 matches!(self, BudgetSpec::Percent { .. })
124 }
125}
126
127/// A ContextLayout defines the complete memory map for an agent.
128///
129/// Like SNES VRAM layout - every region has a defined purpose, size, and policy.
130/// The layout specifies:
131/// - Which regions exist and their configurations
132/// - Total token budget across all regions
133/// - Eviction order when space is needed
134///
135/// Layouts are typically defined in an agent's blueprint and remain constant
136/// throughout the agent's lifecycle, though the content within regions changes.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct ContextLayout {
139 /// All regions in this layout
140 pub regions: Vec<RegionDefinition>,
141
142 /// Total token budget across all regions
143 pub total_budget_tokens: usize,
144
145 /// Region names in eviction priority order (first = evicted first)
146 ///
147 /// When the context window fills up, regions are processed in this order:
148 /// 1. Temporary regions: evict oldest entries
149 /// 2. Compacting regions: trigger summarization
150 /// 3. SlidingWindow regions: reduce window size
151 /// 4. Pinned regions: NEVER touched (if these fill up, it's a config error)
152 pub eviction_order: Vec<String>,
153}
154
155impl ContextLayout {
156 /// Create a new layout with the specified configuration.
157 pub fn new(regions: Vec<RegionDefinition>, total_budget_tokens: usize) -> Self {
158 Self {
159 regions,
160 total_budget_tokens,
161 eviction_order: Vec::new(),
162 }
163 }
164
165 /// Set the eviction order for this layout.
166 pub fn with_eviction_order(mut self, order: Vec<String>) -> Self {
167 self.eviction_order = order;
168 self
169 }
170
171 /// Validate that the layout is well-formed.
172 ///
173 /// Checks:
174 /// - Sum of max_tokens doesn't exceed total_budget_tokens
175 /// - All region names in eviction_order exist
176 /// - No duplicate region names
177 pub fn validate(&self) -> std::result::Result<(), ValidationError> {
178 // Check for duplicate region names
179 let mut names = std::collections::HashSet::new();
180 for region in &self.regions {
181 if !names.insert(region.name.as_str()) {
182 return Err(ValidationError::Region {
183 region: region.name.clone(),
184 message: "duplicate region name".to_string(),
185 });
186 }
187 }
188
189 // Check that eviction_order regions exist
190 for name in &self.eviction_order {
191 if !names.contains(name.as_str()) {
192 return Err(ValidationError::Layout(format!(
193 "eviction order references unknown region: {}",
194 name
195 )));
196 }
197 }
198
199 // Reject a Custom region whose script path is empty - it could never
200 // resolve to a file, and the runtime would silently fall back to
201 // Temporary-style rendering on every inference.
202 for region in &self.regions {
203 if let RegionKind::Custom { script, .. } = ®ion.kind
204 && script.trim().is_empty()
205 {
206 return Err(ValidationError::Region {
207 region: region.name.clone(),
208 message: "custom region requires a non-empty script path".to_string(),
209 });
210 }
211 }
212
213 // Warn if sum of max tokens exceeds budget (not necessarily an error,
214 // since not all regions will be full simultaneously)
215 // Warn if no SlidingWindow region exists - agents should have a
216 // conversation region for typed message entries, but some agents
217 // (e.g., deep-researcher) use other region kinds exclusively. A Custom
218 // region counts: its script can render typed entries as messages.
219 let has_message_region = self.regions.iter().any(|r| {
220 matches!(
221 r.kind,
222 RegionKind::SlidingWindow { .. } | RegionKind::Custom { .. }
223 )
224 });
225 if !has_message_region {
226 tracing::warn!(
227 "Layout has no SlidingWindow (or custom scripted) region - typed \
228 conversation entries require one"
229 );
230 }
231
232 // The token-sum warning and the fixed-working-budget hard error below
233 // operate on concrete `max_tokens` values. When percentage budgets are
234 // present those values are provisional placeholders until the layout is
235 // resolved against a model window, so the checks are meaningless here -
236 // skip them and rely on the post-resolution `validate()` call at spawn.
237 if self.has_percent_budgets() {
238 return Ok(());
239 }
240
241 let total_max = self
242 .regions
243 .iter()
244 .fold(0usize, |acc, r| acc.saturating_add(r.max_tokens));
245 if total_max > self.total_budget_tokens {
246 tracing::warn!(
247 "Sum of region max tokens ({}) exceeds total budget ({})",
248 total_max,
249 self.total_budget_tokens
250 );
251 }
252
253 // Ensure the fixed, non-evictable regions leave working room, judged
254 // against the whole budget.
255 self.validate_working_room(self.total_budget_tokens)
256 }
257
258 /// Fail when the fixed (non-evictable) regions would leave less than
259 /// `MIN_WORKING_TOKENS` of `window` for the evictable ones.
260 ///
261 /// Split out from [`validate`](Self::validate) so a caller can judge each
262 /// stage against that stage's own context window over just the regions it
263 /// can see, rather than one window for the whole layout: a region budgeted
264 /// against a wide-window stage must not be counted against a narrow-window
265 /// stage that never sees it. Pinned / HashMap / CompactHistory / persistent
266 /// custom regions persist for the whole run and consume budget; if they
267 /// leave too little, the evictable regions operate "blind", so fail loudly
268 /// at load instead of degrading silently at runtime. The floor is only
269 /// enforced on realistically-sized windows (`BUDGET_CHECK_MIN_TOTAL`); a toy
270 /// fixture's tiny window is left alone.
271 pub fn validate_working_room(&self, window: usize) -> std::result::Result<(), ValidationError> {
272 let fixed_tokens: usize = self
273 .regions
274 .iter()
275 .filter(|r| {
276 matches!(
277 r.kind,
278 RegionKind::Pinned
279 | RegionKind::HashMap { .. }
280 | RegionKind::CompactHistory { .. }
281 | RegionKind::Custom {
282 persistent: true,
283 ..
284 }
285 )
286 })
287 .fold(0usize, |acc, r| acc.saturating_add(r.max_tokens));
288 let working_tokens = window.saturating_sub(fixed_tokens);
289 if window >= Self::BUDGET_CHECK_MIN_TOTAL && working_tokens < Self::MIN_WORKING_TOKENS {
290 return Err(ValidationError::Layout(format!(
291 "context layout leaves only {working_tokens} working tokens after fixed \
292 regions (pinned/hashmap/compact_history/persistent custom) consume \
293 {fixed_tokens} of the {window} \
294 window; at least {} are needed for the agent to operate. Reduce the \
295 fixed regions' max_tokens or increase the window.",
296 Self::MIN_WORKING_TOKENS
297 )));
298 }
299 Ok(())
300 }
301
302 /// Minimum token budget that must remain for evictable/working regions
303 /// (conversation, tool results, scratch) after the fixed regions are full,
304 /// so the agent has room to hold recent context and generate. Below this a
305 /// run would operate with almost no working space.
306 const MIN_WORKING_TOKENS: usize = 8000;
307
308 /// The working-budget floor is only enforced when the layout's total budget
309 /// is at least this large - i.e. it's a realistically-sized agent, not a
310 /// toy/illustrative layout where an absolute floor wouldn't make sense.
311 const BUDGET_CHECK_MIN_TOTAL: usize = 20_000;
312
313 /// Get a region definition by name.
314 pub fn get_region(&self, name: &str) -> Option<&RegionDefinition> {
315 self.regions.iter().find(|r| r.name == name)
316 }
317
318 /// Whether any region uses a percentage budget (and therefore needs a model
319 /// context window to resolve to concrete token counts).
320 pub fn has_percent_budgets(&self) -> bool {
321 self.regions.iter().any(|r| r.budget.is_percent())
322 }
323
324 /// Resolve every region's percentage budget against a concrete model context
325 /// `window`, returning a fully-absolute layout.
326 ///
327 /// Each region's `max_tokens` becomes `budget.resolve(window)`, and each
328 /// [`RegionKind::Compacting`] region's `threshold_tokens` is recomputed from
329 /// its [`compact_at`](RegionDefinition::compact_at) fraction (via the private
330 /// `resolve_compacting_threshold` helper). `eviction_order` is preserved. The
331 /// total budget becomes the model `window` when any percentage budget is
332 /// present (percentage ceilings are relative to the whole window and may sum
333 /// past 100%); a pure-absolute layout keeps its summed total unchanged.
334 ///
335 /// Resolving an already-absolute layout is a no-op, so this is safe to call
336 /// unconditionally at window-build time.
337 pub fn resolved(&self, window: usize) -> ContextLayout {
338 let regions = self
339 .regions
340 .iter()
341 .map(|r| {
342 let max_tokens = r.budget.resolve(window);
343 let kind = match &r.kind {
344 RegionKind::Compacting { threshold_tokens } => RegionKind::Compacting {
345 threshold_tokens: Self::resolve_compacting_threshold(
346 r.compact_at,
347 *threshold_tokens,
348 max_tokens,
349 ),
350 },
351 other => other.clone(),
352 };
353 // Emit a fully-absolute region: the percentage has been baked
354 // into `max_tokens` and the compaction threshold into `kind`, so
355 // the resolved layout carries no `Percent` budgets. This makes
356 // `has_percent_budgets()` false on the result, so a post-resolution
357 // `validate()` runs the real token/working-budget checks.
358 RegionDefinition {
359 kind,
360 max_tokens,
361 budget: BudgetSpec::Absolute(max_tokens),
362 compact_at: None,
363 ..r.clone()
364 }
365 })
366 .collect();
367
368 let total_budget_tokens = if self.has_percent_budgets() {
369 window
370 } else {
371 self.total_budget_tokens
372 };
373
374 ContextLayout {
375 regions,
376 total_budget_tokens,
377 eviction_order: self.eviction_order.clone(),
378 }
379 }
380
381 /// Like [`resolved`](Self::resolved), but each region's percentage budget
382 /// is sized against a window chosen per region rather than one window for
383 /// the whole layout.
384 ///
385 /// `window_for(region_name)` gives the window a region is budgeted against:
386 /// the caller passes the smallest context window among the stages that can
387 /// actually see that region, so a region used only in wide-window stages
388 /// keeps a wide budget even when a narrow-window stage exists that never
389 /// sees it. The layout's `total_budget_tokens` becomes the largest of those
390 /// per-region windows; the authoritative fit check is per stage, done by the
391 /// caller against each stage's own window over the regions it sees.
392 ///
393 /// An absolute layout has nothing to resolve, so this is a no-op for it, the
394 /// same as [`resolved`](Self::resolved).
395 pub fn resolved_per_region(&self, window_for: &dyn Fn(&str) -> usize) -> ContextLayout {
396 let regions: Vec<RegionDefinition> = self
397 .regions
398 .iter()
399 .map(|r| {
400 let window = window_for(&r.name);
401 let max_tokens = r.budget.resolve(window);
402 let kind = match &r.kind {
403 RegionKind::Compacting { threshold_tokens } => RegionKind::Compacting {
404 threshold_tokens: Self::resolve_compacting_threshold(
405 r.compact_at,
406 *threshold_tokens,
407 max_tokens,
408 ),
409 },
410 other => other.clone(),
411 };
412 RegionDefinition {
413 kind,
414 max_tokens,
415 budget: BudgetSpec::Absolute(max_tokens),
416 compact_at: None,
417 ..r.clone()
418 }
419 })
420 .collect();
421
422 let total_budget_tokens = if self.has_percent_budgets() {
423 self.regions
424 .iter()
425 .map(|r| window_for(&r.name))
426 .max()
427 .unwrap_or(self.total_budget_tokens)
428 } else {
429 self.total_budget_tokens
430 };
431
432 ContextLayout {
433 regions,
434 total_budget_tokens,
435 eviction_order: self.eviction_order.clone(),
436 }
437 }
438
439 /// A copy holding only the regions (and eviction-order entries) whose name
440 /// satisfies `keep`.
441 ///
442 /// The runtime carries some always-visible regions (conversation, tool
443 /// results) that no layout declares, so a stage's visible-region set may
444 /// name regions absent here; those are simply not present in the result.
445 /// Used to judge a stage's working room over exactly the regions it can see,
446 /// rather than every region the layout declares.
447 pub fn retaining<F: Fn(&str) -> bool>(&self, keep: F) -> ContextLayout {
448 ContextLayout {
449 regions: self
450 .regions
451 .iter()
452 .filter(|r| keep(&r.name))
453 .cloned()
454 .collect(),
455 total_budget_tokens: self.total_budget_tokens,
456 eviction_order: self
457 .eviction_order
458 .iter()
459 .filter(|n| keep(n))
460 .cloned()
461 .collect(),
462 }
463 }
464
465 /// Compute a Compacting region's concrete compaction threshold from its
466 /// `compact_at` fraction, the absolute `threshold_tokens` guard carried on
467 /// the kind, and the region's resolved budget.
468 ///
469 /// - `compact_at = Some(f)` with an explicit `threshold_tokens` cap (any
470 /// value below the [`usize::MAX`] sentinel) → `min(round(budget * f), cap)`:
471 /// compact at the percentage, but never later than the absolute guard-rail.
472 /// - `compact_at = Some(f)` with no cap (`threshold_tokens == usize::MAX`
473 /// sentinel) → `round(budget * f)`.
474 /// - `compact_at = None` → the absolute `threshold_tokens` as-is (back-compat,
475 /// including the parser's `max_tokens * 8 / 10` default).
476 ///
477 /// The `usize::MAX` sentinel is safe: a layout is always resolved before any
478 /// [`Region::needs_compaction`](crate::region::Region::needs_compaction) check.
479 fn resolve_compacting_threshold(
480 compact_at: Option<f64>,
481 threshold_tokens: usize,
482 resolved_budget: usize,
483 ) -> usize {
484 match compact_at {
485 Some(fraction) => {
486 let pct = (resolved_budget as f64 * fraction).round() as usize;
487 pct.min(threshold_tokens)
488 }
489 None => threshold_tokens,
490 }
491 }
492}
493
494/// Where a region's initial content comes from at run start.
495///
496/// A region without a seed starts empty and is populated by the agent. A seeded
497/// region is filled before the first inference: `CallerInput` regions are filled
498/// by the run's caller (a CLI `--<name>` flag, an ACP `---region:<name>---`
499/// marker, or the API `regions` map); the remaining variants are resolved by the
500/// daemon from the run's workdir (which is why this type only *declares* the
501/// source - `leviath-core` stays filesystem-agnostic; resolution lives in the
502/// CLI daemon's spawner).
503#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
504#[serde(rename_all = "snake_case")]
505pub enum RegionSeed {
506 /// Filled at run time by the caller, keyed by `name` (defaults to the
507 /// region's own name; the sentinel `task` maps to the `--task`/prompt text).
508 /// When the owning region is `required`, a missing value is a hard error
509 /// before any inference runs.
510 CallerInput {
511 /// The caller-input key this region is filled from.
512 name: String,
513 },
514 /// Concatenated contents of the workdir files matching a glob pattern.
515 Glob {
516 /// Glob pattern, resolved relative to the run's workdir.
517 pattern: String,
518 },
519 /// Concatenated contents of an explicit list of workdir-relative files.
520 Files {
521 /// File paths, resolved relative to the run's workdir.
522 paths: Vec<String>,
523 },
524 /// A static literal string baked into the blueprint.
525 Literal {
526 /// The verbatim seed text.
527 text: String,
528 },
529 /// The `String` returned by running a Rhai script from the workdir.
530 Rhai {
531 /// Script path, resolved relative to the run's workdir.
532 script: String,
533 },
534 /// The combined stdout/stderr of a shell command run in the workdir at spawn.
535 ///
536 /// Unlike every other variant this *executes* something, and it does so
537 /// before the first inference - so before any tool-approval prompt. The
538 /// daemon runs it inside the entry stage's sandbox when one is configured,
539 /// caps its runtime and output, and honours the `[security]
540 /// allow_seed_commands` kill switch. A failure is non-fatal unless the
541 /// owning region is `required`.
542 Command {
543 /// The shell command line, run with the platform shell in the workdir.
544 command: String,
545 },
546 /// The combined output of one or more tool calls, run at spawn.
547 ///
548 /// Like [`Command`](Self::Command) this *executes* something, but through
549 /// the run's own tool layer rather than a shell: any tool the agent could
550 /// call is callable here - a built-in, an MCP server's, a Rhai script's -
551 /// and each call answers to the same `tool_permissions` and taint rules it
552 /// would answer to mid-run. That is what makes an unrestricted list safe:
553 /// a seed can reach nothing the agent was not already granted.
554 ///
555 /// Several calls write into one region, in the order given, each under its
556 /// own heading. A failed call is skipped with a warning unless the region is
557 /// `required`, so one unavailable tool does not cost the others.
558 Tools {
559 /// The calls to run, in order.
560 calls: Vec<SeedToolCall>,
561 /// Whether the calls run once, or again on every stage entry.
562 refresh: SeedRefresh,
563 },
564}
565
566/// When a [`RegionSeed::Tools`] seed runs again.
567///
568/// Every other seed kind resolves once, at spawn, and this defaults to the
569/// same: a region seeded from the filesystem or a literal has no reason to be
570/// re-read, and re-running a call on every stage entry costs a tool call and
571/// rewrites a region the cache was holding still.
572///
573/// [`EachStage`](Self::EachStage) is for the seeds where the answer moves.
574/// A clock is the clear case: a run that spends an hour in one stage and then
575/// enters another should date the second stage from when it started, not from
576/// when the run did.
577#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
578#[serde(rename_all = "snake_case")]
579pub enum SeedRefresh {
580 /// Resolve once, at spawn. The default, and what every other seed does.
581 #[default]
582 Once,
583 /// Resolve again whenever a stage is entered, replacing the region.
584 EachStage,
585}
586
587impl SeedRefresh {
588 /// Parse the manifest spelling, or `None` for a word that is neither.
589 ///
590 /// A wrong spelling is rejected rather than defaulted, so
591 /// `refresh = "each stage"` is reported instead of quietly meaning `once` -
592 /// which would read as the feature not working.
593 pub fn from_str_loose(value: &str) -> Option<Self> {
594 match value.trim().to_ascii_lowercase().as_str() {
595 "once" | "spawn" => Some(Self::Once),
596 "each_stage" | "stage" => Some(Self::EachStage),
597 _ => None,
598 }
599 }
600}
601
602/// One tool call in a [`RegionSeed::Tools`] seed.
603#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
604pub struct SeedToolCall {
605 /// The tool to call, spelled as the agent would spell it (so an MCP tool
606 /// keeps its `<server>__<tool>` qualification).
607 pub name: String,
608 /// The arguments object. Empty for the many tools that take none.
609 pub args: serde_json::Value,
610}
611
612impl SeedToolCall {
613 /// A call with no arguments.
614 pub fn new(name: impl Into<String>) -> Self {
615 Self {
616 name: name.into(),
617 args: serde_json::Value::Object(serde_json::Map::new()),
618 }
619 }
620
621 /// A call with an arguments object.
622 pub fn with_args(name: impl Into<String>, args: serde_json::Value) -> Self {
623 Self {
624 name: name.into(),
625 args,
626 }
627 }
628}
629
630#[cfg(test)]
631mod seed_refresh_tests {
632 use super::*;
633
634 #[test]
635 fn both_spellings_and_their_aliases_parse() {
636 assert_eq!(SeedRefresh::from_str_loose("once"), Some(SeedRefresh::Once));
637 assert_eq!(
638 SeedRefresh::from_str_loose("spawn"),
639 Some(SeedRefresh::Once)
640 );
641 assert_eq!(
642 SeedRefresh::from_str_loose("each_stage"),
643 Some(SeedRefresh::EachStage)
644 );
645 assert_eq!(
646 SeedRefresh::from_str_loose("stage"),
647 Some(SeedRefresh::EachStage)
648 );
649 // Case and surrounding space are not the author's problem.
650 assert_eq!(
651 SeedRefresh::from_str_loose(" EACH_STAGE "),
652 Some(SeedRefresh::EachStage)
653 );
654 }
655
656 /// A word that is neither is rejected rather than defaulted. Defaulting
657 /// would make `refresh = "each stage"` silently mean `once`, which reads as
658 /// the feature not working rather than as a typo.
659 #[test]
660 fn an_unrecognised_word_is_not_quietly_once() {
661 assert_eq!(SeedRefresh::from_str_loose("each stage"), None);
662 assert_eq!(SeedRefresh::from_str_loose("always"), None);
663 assert_eq!(SeedRefresh::from_str_loose(""), None);
664 }
665
666 /// The default matches every other seed kind: resolve at spawn, once.
667 #[test]
668 fn the_default_is_once() {
669 assert_eq!(SeedRefresh::default(), SeedRefresh::Once);
670 }
671}
672
673/// Definition of a region in a layout.
674///
675/// This is the blueprint for creating a Region instance. It specifies the
676/// region's configuration but doesn't contain actual content.
677#[derive(Debug, Clone, Serialize, Deserialize)]
678pub struct RegionDefinition {
679 /// Unique name for this region
680 pub name: String,
681
682 /// Region lifecycle policy
683 pub kind: RegionKind,
684
685 /// **Resolved** maximum tokens for this region. This is the concrete ceiling
686 /// every downstream consumer reads; for a percentage budget it is populated
687 /// when the layout is resolved against a model window (see
688 /// [`ContextLayout::resolved`]). [`Self::budget`] is the source of truth for
689 /// how this value is derived.
690 pub max_tokens: usize,
691
692 /// How this region's ceiling is expressed. Defaults (via [`Self::new`]) to
693 /// [`BudgetSpec::Absolute`] holding `max_tokens`, so a region that names a
694 /// token count directly gets an absolute ceiling. A percentage budget is
695 /// resolved against the model context window at window-build time.
696 #[serde(default)]
697 pub budget: BudgetSpec,
698
699 /// For [`RegionKind::Compacting`] regions only: compact when the region
700 /// reaches this fraction of its resolved budget (`0.80` for `compact_at =
701 /// "80%"`). `None` keeps the absolute `threshold_tokens` carried on the kind.
702 /// See [`ContextLayout::resolved`] for how this becomes a concrete threshold.
703 #[serde(default)]
704 pub compact_at: Option<f64>,
705
706 /// Optional validation schema
707 pub schema: Option<RegionSchema>,
708
709 /// Human-readable description of this region's purpose
710 pub description: Option<String>,
711
712 /// Whether `description` is also shown to the model, under the region's
713 /// name. Off by default - see [`crate::region::Region::describe_in_prompt`].
714 #[serde(default)]
715 pub describe_in_prompt: bool,
716
717 /// When true, this region must be non-empty before a stage that can write
718 /// to it is allowed to complete. Guards against an agent skipping a
719 /// context-population step (e.g. never writing the `plan` region). Enforced
720 /// in the run loop, which re-runs the stage with [`Self::required_message`]
721 /// until the region is populated.
722 #[serde(default)]
723 pub required: bool,
724
725 /// Whether an edge transform may hand this region to the summarizer.
726 ///
727 /// `transform = "compact"` reads as "summarize the transcript on the way
728 /// out" and means "summarize every region that is not pinned", which
729 /// includes the ones holding the run's results. Figures that survive a
730 /// paraphrase are no longer figures: without this, a `results` region
731 /// carrying computed values is rewritten into prose before the stage that
732 /// reports them ever sees it.
733 ///
734 /// Setting this false protects the region wherever it is used, rather than
735 /// at each of the N edges that might touch it. `clear` still applies - this
736 /// says "do not paraphrase my content", not "keep it forever".
737 #[serde(default = "crate::default_true")]
738 pub summarizable: bool,
739
740 /// What this region does when a write does not fit.
741 ///
742 /// Declared per region rather than per stage: whether losing the oldest
743 /// entry is acceptable is a property of what the region holds, and does not
744 /// change depending on which stage is writing to it.
745 #[serde(default)]
746 pub admission: crate::region::Admission,
747 /// How much this region's contents move between requests. See
748 /// [`crate::region::Volatility`].
749 ///
750 /// Defaulted on the wire so a definition written before this existed still
751 /// loads, and loads as the pessimistic value - which is what an unclassified
752 /// region should be.
753 #[serde(default)]
754 pub volatility: crate::region::Volatility,
755
756 /// Optional custom message shown to the agent when this region is required
757 /// but empty. Falls back to a generated default when `None`.
758 #[serde(default)]
759 pub required_message: Option<String>,
760
761 /// Where this region's initial content comes from at run start. `None`
762 /// means the region starts empty (the agent populates it). See
763 /// [`RegionSeed`].
764 #[serde(default)]
765 pub seed: Option<RegionSeed>,
766
767 /// Mime type patterns this region takes; empty means anything. See
768 /// [`crate::region::Region::accepts`].
769 #[serde(default, skip_serializing_if = "Vec::is_empty")]
770 pub accepts: Vec<String>,
771}
772
773impl RegionDefinition {
774 /// Create a new region definition with an absolute token ceiling.
775 ///
776 /// The `budget` is set to [`BudgetSpec::Absolute`] holding `max_tokens` and
777 /// `compact_at` to `None`, so every existing caller (and every region without
778 /// a percentage budget) is unaffected - resolving such a layout is a no-op.
779 pub fn new(name: String, kind: RegionKind, max_tokens: usize) -> Self {
780 Self {
781 name,
782 kind,
783 max_tokens,
784 budget: BudgetSpec::Absolute(max_tokens),
785 compact_at: None,
786 schema: None,
787 description: None,
788 describe_in_prompt: false,
789 required: false,
790 required_message: None,
791 summarizable: true,
792 admission: crate::region::Admission::default(),
793 volatility: crate::region::Volatility::default(),
794 seed: None,
795 accepts: Vec::new(),
796 }
797 }
798
799 /// Set this region's budget spec (e.g. a percentage of the model window).
800 /// `max_tokens` is left as the provisional/resolved value; it is (re)computed
801 /// from the budget when the owning layout is resolved.
802 pub fn with_budget(mut self, budget: BudgetSpec) -> Self {
803 self.budget = budget;
804 self
805 }
806
807 /// Set the compaction trigger fraction for a [`RegionKind::Compacting`]
808 /// region (`0.80` == compact at 80% of the resolved budget).
809 pub fn with_compact_at(mut self, fraction: f64) -> Self {
810 self.compact_at = Some(fraction);
811 self
812 }
813
814 /// Set this region's seed source.
815 pub fn with_seed(mut self, seed: RegionSeed) -> Self {
816 self.seed = Some(seed);
817 self
818 }
819
820 /// Mark this region as required, with an optional custom nudge message.
821 pub fn with_required(mut self, required: bool, message: Option<String>) -> Self {
822 self.required = required;
823 self.required_message = message;
824 self
825 }
826
827 /// Add a schema to this region definition.
828 pub fn with_schema(mut self, schema: RegionSchema) -> Self {
829 self.schema = Some(schema);
830 self
831 }
832
833 /// Add a description to this region definition.
834 pub fn with_description(mut self, description: String) -> Self {
835 self.description = Some(description);
836 self
837 }
838}
839
840#[cfg(test)]
841mod tests {
842 use super::*;
843 use leviath_testkit::with_tracing;
844
845 #[test]
846 fn test_layout_creation() {
847 let regions = vec![
848 RegionDefinition::new("pinned".to_string(), RegionKind::Pinned, 5000),
849 RegionDefinition::new("temp".to_string(), RegionKind::Temporary, 10000),
850 ];
851 let layout = ContextLayout::new(regions, 20000);
852 assert_eq!(layout.regions.len(), 2);
853 assert_eq!(layout.total_budget_tokens, 20000);
854 }
855
856 #[test]
857 fn test_layout_validation() {
858 let regions = vec![RegionDefinition::new(
859 "test".to_string(),
860 RegionKind::Pinned,
861 5000,
862 )];
863 let layout =
864 ContextLayout::new(regions, 10000).with_eviction_order(vec!["test".to_string()]);
865
866 assert!(layout.validate().is_ok());
867 }
868
869 #[test]
870 fn test_duplicate_region_names() {
871 let regions = vec![
872 RegionDefinition::new("test".to_string(), RegionKind::Pinned, 5000),
873 RegionDefinition::new("test".to_string(), RegionKind::Temporary, 3000),
874 ];
875 let layout = ContextLayout::new(regions, 10000);
876
877 assert!(layout.validate().is_err());
878 }
879
880 #[test]
881 fn test_eviction_order_unknown_region_is_error() {
882 let regions = vec![RegionDefinition::new(
883 "test".to_string(),
884 RegionKind::Pinned,
885 5000,
886 )];
887 let layout =
888 ContextLayout::new(regions, 10000).with_eviction_order(vec!["nonexistent".to_string()]);
889
890 let err = layout.validate().unwrap_err();
891 assert_eq!(
892 err,
893 ValidationError::Layout(
894 "eviction order references unknown region: nonexistent".to_string()
895 )
896 );
897 }
898
899 #[test]
900 fn test_validate_warns_but_does_not_error_when_max_tokens_exceed_budget() {
901 // Sum of region max_tokens (5000 + 10000 = 15000) exceeds the total
902 // budget (10000) - this should only warn, not fail validation, since
903 // not all regions are full simultaneously.
904 let regions = vec![
905 RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000),
906 RegionDefinition::new("b".to_string(), RegionKind::Temporary, 10000),
907 ];
908 let layout = ContextLayout::new(regions, 10000);
909 with_tracing(|| {
910 assert!(layout.validate().is_ok());
911 });
912 }
913
914 #[test]
915 fn validate_errors_when_fixed_regions_starve_working_budget() {
916 // Realistically-sized layout (>= 20k) where a huge fixed (pinned) region
917 // leaves < 8000 working tokens for conversation/tool-results → hard error.
918 let regions = vec![
919 RegionDefinition::new("big_pinned".to_string(), RegionKind::Pinned, 95_000),
920 RegionDefinition::new("work".to_string(), RegionKind::Temporary, 5_000),
921 ];
922 let layout = ContextLayout::new(regions, 100_000);
923 with_tracing(|| {
924 let err = layout.validate().unwrap_err();
925 assert!(
926 err.to_string().contains("working tokens"),
927 "actionable budget error: {err}"
928 );
929 });
930 }
931
932 #[test]
933 fn validate_ok_for_realistic_layout_with_working_room() {
934 let regions = vec![
935 RegionDefinition::new("task".to_string(), RegionKind::Pinned, 4_000),
936 RegionDefinition::new("conversation".to_string(), RegionKind::Temporary, 40_000),
937 ];
938 let layout = ContextLayout::new(regions, 44_000);
939 with_tracing(|| {
940 assert!(layout.validate().is_ok());
941 });
942 }
943
944 #[test]
945 fn validate_working_room_judges_fixed_regions_against_the_passed_window() {
946 // The same layout passes against a wide window and fails against a
947 // narrow one: the fixed region is fine when the stage seeing it has room
948 // and starves the working budget when the stage's window is small. This
949 // is what lets each stage be judged against its own model's window over
950 // just the regions it can see.
951 let regions = vec![
952 RegionDefinition::new("task".to_string(), RegionKind::Pinned, 30_000),
953 RegionDefinition::new("work".to_string(), RegionKind::Temporary, 10_000),
954 ];
955 let layout = ContextLayout::new(regions, 200_000);
956 with_tracing(|| {
957 assert!(layout.validate_working_room(200_000).is_ok());
958 let err = layout.validate_working_room(32_000).unwrap_err();
959 assert!(err.to_string().contains("working tokens"), "{err}");
960 // A tiny (toy-sized) window below the check floor is left alone.
961 assert!(layout.validate_working_room(10_000).is_ok());
962 });
963 }
964
965 fn custom_kind(script: &str, persistent: bool) -> RegionKind {
966 RegionKind::Custom {
967 script: script.to_string(),
968 persistent,
969 }
970 }
971
972 #[test]
973 fn validate_rejects_custom_region_with_empty_script() {
974 // Whitespace-only counts as empty: it could never resolve to a file
975 // and the runtime would silently fall back on every inference.
976 let regions = vec![RegionDefinition::new(
977 "brain".to_string(),
978 custom_kind(" ", false),
979 5000,
980 )];
981 let layout = ContextLayout::new(regions, 10_000);
982 let err = with_tracing(|| layout.validate().unwrap_err());
983 assert!(
984 err.to_string().contains("non-empty script path"),
985 "actionable error: {err}"
986 );
987 }
988
989 #[test]
990 fn validate_counts_persistent_custom_as_fixed_budget() {
991 // A persistent custom region is Pinned-like: protected from eviction,
992 // so it must count toward the fixed budget that can starve the
993 // working room.
994 let regions = vec![
995 RegionDefinition::new("vault".to_string(), custom_kind("v.rhai", true), 95_000),
996 RegionDefinition::new("work".to_string(), RegionKind::Temporary, 5_000),
997 ];
998 let layout = ContextLayout::new(regions, 100_000);
999 let err = with_tracing(|| layout.validate().unwrap_err());
1000 assert!(err.to_string().contains("working tokens"), "{err}");
1001 }
1002
1003 #[test]
1004 fn validate_counts_non_persistent_custom_as_working_budget() {
1005 // Same shape, but the custom region is evictable - it IS the working
1006 // room, so validation passes.
1007 let regions = vec![
1008 RegionDefinition::new("brain".to_string(), custom_kind("b.rhai", false), 95_000),
1009 RegionDefinition::new("task".to_string(), RegionKind::Pinned, 4_000),
1010 ];
1011 let layout = ContextLayout::new(regions, 100_000);
1012 with_tracing(|| {
1013 assert!(layout.validate().is_ok());
1014 });
1015 }
1016
1017 #[test]
1018 fn custom_region_satisfies_the_message_region_check() {
1019 // A layout whose only region is custom must not trip the "no
1020 // SlidingWindow region" warning path - its script can render typed
1021 // entries as messages. (Mirrors the sliding-window-present test: the
1022 // skip branch is exercised, validation succeeds.)
1023 let regions = vec![RegionDefinition::new(
1024 "everything".to_string(),
1025 custom_kind("all.rhai", false),
1026 9_000,
1027 )];
1028 let layout = ContextLayout::new(regions, 10_000);
1029 with_tracing(|| {
1030 assert!(layout.validate().is_ok());
1031 });
1032 }
1033
1034 #[test]
1035 fn resolved_percent_budget_applies_to_custom_region() {
1036 // The "recreate built-ins in Rhai" guarantee: percentage budgets work
1037 // on custom regions exactly as on built-in kinds, resolved against
1038 // the stage model's context window at spawn.
1039 let def = RegionDefinition::new("brain".to_string(), custom_kind("b.rhai", false), 0)
1040 .with_budget(BudgetSpec::Percent {
1041 percent: 0.40,
1042 min: Some(10_000),
1043 max: None,
1044 });
1045 let layout = ContextLayout::new(vec![def], 0);
1046 let resolved = layout.resolved(200_000);
1047 assert_eq!(resolved.regions[0].max_tokens, 80_000);
1048 assert!(matches!(
1049 resolved.regions[0].kind,
1050 RegionKind::Custom { ref script, persistent: false } if script == "b.rhai"
1051 ));
1052 // The min floor wins on a small window.
1053 let small = layout.resolved(8_192);
1054 assert_eq!(small.regions[0].max_tokens, 10_000);
1055 }
1056
1057 #[test]
1058 fn test_get_region_found() {
1059 let regions = vec![
1060 RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000),
1061 RegionDefinition::new("b".to_string(), RegionKind::Temporary, 3000),
1062 ];
1063 let layout = ContextLayout::new(regions, 10000);
1064
1065 let found = layout.get_region("b").unwrap();
1066 assert_eq!(found.name, "b");
1067 assert_eq!(found.max_tokens, 3000);
1068 }
1069
1070 #[test]
1071 fn test_get_region_not_found() {
1072 let regions = vec![RegionDefinition::new(
1073 "a".to_string(),
1074 RegionKind::Pinned,
1075 5000,
1076 )];
1077 let layout = ContextLayout::new(regions, 10000);
1078 assert!(layout.get_region("missing").is_none());
1079 }
1080
1081 #[test]
1082 fn test_region_definition_with_schema() {
1083 let schema = crate::region::RegionSchema::new(crate::region::ContentFormat::Json);
1084 let def =
1085 RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000).with_schema(schema);
1086 assert_eq!(
1087 def.schema.as_ref().unwrap().format,
1088 crate::region::ContentFormat::Json
1089 );
1090 }
1091
1092 #[test]
1093 fn test_region_definition_with_description() {
1094 let def = RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000)
1095 .with_description("holds architecture notes".to_string());
1096 assert_eq!(def.description.as_deref(), Some("holds architecture notes"));
1097 }
1098
1099 #[test]
1100 fn parse_budget_accepts_plain_and_decimal_percentages() {
1101 assert_eq!(BudgetSpec::parse_budget("35%").unwrap(), 0.35);
1102 assert_eq!(BudgetSpec::parse_budget("100%").unwrap(), 1.0);
1103 assert!((BudgetSpec::parse_budget("0.6%").unwrap() - 0.006).abs() < 1e-9);
1104 }
1105
1106 #[test]
1107 fn parse_budget_trims_surrounding_and_inner_whitespace() {
1108 assert_eq!(BudgetSpec::parse_budget(" 35 % ").unwrap(), 0.35);
1109 }
1110
1111 #[test]
1112 fn parse_budget_rejects_missing_percent_sign() {
1113 let err = BudgetSpec::parse_budget("35").unwrap_err();
1114 assert!(err.contains("must end with '%'"), "{err}");
1115 }
1116
1117 #[test]
1118 fn parse_budget_rejects_non_numeric() {
1119 let err = BudgetSpec::parse_budget("abc%").unwrap_err();
1120 assert!(err.contains("not a valid number"), "{err}");
1121 }
1122
1123 #[test]
1124 fn parse_budget_rejects_zero_and_negative() {
1125 let zero = BudgetSpec::parse_budget("0%").unwrap_err();
1126 assert!(zero.contains("greater than 0%"), "{zero}");
1127 let neg = BudgetSpec::parse_budget("-10%").unwrap_err();
1128 assert!(neg.contains("greater than 0%"), "{neg}");
1129 }
1130
1131 #[test]
1132 fn parse_budget_rejects_over_one_hundred() {
1133 let err = BudgetSpec::parse_budget("150%").unwrap_err();
1134 assert!(err.contains("at most 100%"), "{err}");
1135 }
1136
1137 #[test]
1138 fn resolve_absolute_ignores_window() {
1139 assert_eq!(BudgetSpec::Absolute(4000).resolve(1_000_000), 4000);
1140 assert!(!BudgetSpec::Absolute(4000).is_percent());
1141 }
1142
1143 #[test]
1144 fn resolve_percent_of_window() {
1145 let spec = BudgetSpec::Percent {
1146 percent: 0.35,
1147 min: None,
1148 max: None,
1149 };
1150 assert_eq!(spec.resolve(1_000_000), 350_000);
1151 assert!(spec.is_percent());
1152 }
1153
1154 #[test]
1155 fn resolve_percent_applies_max_cap() {
1156 let spec = BudgetSpec::Percent {
1157 percent: 0.02,
1158 min: None,
1159 max: Some(4000),
1160 };
1161 // 2% of 1M = 20_000, capped to 4000.
1162 assert_eq!(spec.resolve(1_000_000), 4000);
1163 }
1164
1165 #[test]
1166 fn resolve_percent_applies_min_floor() {
1167 let spec = BudgetSpec::Percent {
1168 percent: 0.02,
1169 min: Some(2000),
1170 max: None,
1171 };
1172 // 2% of 8000 = 160, floored to 2000.
1173 assert_eq!(spec.resolve(8000), 2000);
1174 }
1175
1176 #[test]
1177 fn resolve_percent_within_bounds_takes_neither_clamp() {
1178 let spec = BudgetSpec::Percent {
1179 percent: 0.10,
1180 min: Some(1000),
1181 max: Some(50_000),
1182 };
1183 // 10% of 200k = 20_000, between the floor and cap.
1184 assert_eq!(spec.resolve(200_000), 20_000);
1185 }
1186
1187 #[test]
1188 fn resolve_percent_floor_wins_when_min_exceeds_max() {
1189 let spec = BudgetSpec::Percent {
1190 percent: 0.10,
1191 min: Some(9000),
1192 max: Some(4000),
1193 };
1194 // 10% of 200k = 20_000 → capped to 4000 → floored up to 9000 (floor wins).
1195 assert_eq!(spec.resolve(200_000), 9000);
1196 }
1197
1198 #[test]
1199 fn has_percent_budgets_detects_percentage_regions() {
1200 let absolute = ContextLayout::new(
1201 vec![RegionDefinition::new(
1202 "a".to_string(),
1203 RegionKind::Pinned,
1204 5000,
1205 )],
1206 5000,
1207 );
1208 assert!(!absolute.has_percent_budgets());
1209
1210 let percent = ContextLayout::new(
1211 vec![
1212 RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000).with_budget(
1213 BudgetSpec::Percent {
1214 percent: 0.05,
1215 min: None,
1216 max: None,
1217 },
1218 ),
1219 ],
1220 5000,
1221 );
1222 assert!(percent.has_percent_budgets());
1223 }
1224
1225 #[test]
1226 fn resolved_is_noop_for_absolute_layout() {
1227 let layout = ContextLayout::new(
1228 vec![RegionDefinition::new(
1229 "a".to_string(),
1230 RegionKind::Pinned,
1231 5000,
1232 )],
1233 5000,
1234 );
1235 let resolved = layout.resolved(1_000_000);
1236 assert_eq!(resolved.regions[0].max_tokens, 5000);
1237 // An absolute layout keeps its summed total, not the window.
1238 assert_eq!(resolved.total_budget_tokens, 5000);
1239 }
1240
1241 #[test]
1242 fn resolved_percent_layout_uses_window_as_total() {
1243 let layout = ContextLayout::new(
1244 vec![
1245 RegionDefinition::new("a".to_string(), RegionKind::Pinned, 0).with_budget(
1246 BudgetSpec::Percent {
1247 percent: 0.10,
1248 min: None,
1249 max: None,
1250 },
1251 ),
1252 ],
1253 0,
1254 )
1255 .with_eviction_order(vec!["a".to_string()]);
1256 let resolved = layout.resolved(1_000_000);
1257 assert_eq!(resolved.regions[0].max_tokens, 100_000);
1258 assert_eq!(resolved.total_budget_tokens, 1_000_000);
1259 // eviction order carried through.
1260 assert_eq!(resolved.eviction_order, vec!["a".to_string()]);
1261 }
1262
1263 #[test]
1264 fn resolved_per_region_sizes_each_region_against_its_own_window() {
1265 let pct = || BudgetSpec::Percent {
1266 percent: 0.10,
1267 min: None,
1268 max: None,
1269 };
1270 // A Compacting region whose threshold is a fraction of its resolved
1271 // budget, so the per-region path must recompute it against the region's
1272 // own window like `resolved()` does.
1273 let mut compact = RegionDefinition::new(
1274 "roll".to_string(),
1275 RegionKind::Compacting {
1276 threshold_tokens: usize::MAX,
1277 },
1278 0,
1279 )
1280 .with_budget(pct());
1281 compact.compact_at = Some(0.5);
1282 let layout = ContextLayout::new(
1283 vec![
1284 RegionDefinition::new("wide".to_string(), RegionKind::Pinned, 0).with_budget(pct()),
1285 RegionDefinition::new("narrow".to_string(), RegionKind::Pinned, 0)
1286 .with_budget(pct()),
1287 compact,
1288 ],
1289 0,
1290 );
1291 let resolved = layout.resolved_per_region(&|name| match name {
1292 "wide" => 200_000,
1293 _ => 32_768,
1294 });
1295 let max_of = |n: &str| {
1296 resolved
1297 .regions
1298 .iter()
1299 .find(|r| r.name == n)
1300 .unwrap()
1301 .max_tokens
1302 };
1303 assert_eq!(max_of("wide"), 20_000); // 10% of 200k
1304 assert_eq!(max_of("narrow"), 3_277); // 10% of 32768, rounded
1305 // roll: 10% of 32768 = 3277 budget; threshold = 50% of that, sized
1306 // against the region's own window, not the widest.
1307 assert_eq!(max_of("roll"), 3_277);
1308 let roll = resolved.regions.iter().find(|r| r.name == "roll").unwrap();
1309 assert!(
1310 matches!(roll.kind, RegionKind::Compacting { threshold_tokens } if threshold_tokens == 1_639),
1311 "compacting threshold resolved per region: {:?}",
1312 roll.kind
1313 );
1314 // The total is the largest per-region window; the real fit check is per
1315 // stage, done by the caller.
1316 assert_eq!(resolved.total_budget_tokens, 200_000);
1317
1318 // An absolute layout has nothing to resolve, so this is a no-op.
1319 let absolute = ContextLayout::new(
1320 vec![RegionDefinition::new(
1321 "x".to_string(),
1322 RegionKind::Pinned,
1323 5_000,
1324 )],
1325 5_000,
1326 );
1327 let same = absolute.resolved_per_region(&|_| 999_999);
1328 assert_eq!(same.regions[0].max_tokens, 5_000);
1329 assert_eq!(same.total_budget_tokens, 5_000);
1330 }
1331
1332 #[test]
1333 fn retaining_keeps_only_the_named_regions_and_eviction_entries() {
1334 let layout = ContextLayout {
1335 regions: vec![
1336 RegionDefinition::new("keep".to_string(), RegionKind::Pinned, 1_000),
1337 RegionDefinition::new("drop".to_string(), RegionKind::Temporary, 1_000),
1338 ],
1339 total_budget_tokens: 10_000,
1340 eviction_order: vec!["keep".to_string(), "drop".to_string()],
1341 };
1342 let kept = layout.retaining(|name| name == "keep");
1343 assert_eq!(kept.regions.len(), 1);
1344 assert_eq!(kept.regions[0].name, "keep");
1345 assert_eq!(kept.eviction_order, vec!["keep".to_string()]);
1346 // total_budget rides along unchanged; the working-room check judges
1347 // against a window the caller passes, not this field.
1348 assert_eq!(kept.total_budget_tokens, 10_000);
1349 }
1350
1351 #[test]
1352 fn resolved_compacting_threshold_all_cases() {
1353 // compact_at + explicit threshold cap → min(pct, cap).
1354 let both = RegionDefinition::new(
1355 "c".to_string(),
1356 RegionKind::Compacting {
1357 threshold_tokens: 25_000,
1358 },
1359 0,
1360 )
1361 .with_budget(BudgetSpec::Percent {
1362 percent: 0.20,
1363 min: None,
1364 max: None,
1365 })
1366 .with_compact_at(0.80);
1367 let r = ContextLayout::new(vec![both], 0).resolved(200_000);
1368 // budget = 40_000; 80% = 32_000; capped to 25_000.
1369 assert_eq!(
1370 r.regions[0].kind,
1371 RegionKind::Compacting {
1372 threshold_tokens: 25_000
1373 }
1374 );
1375
1376 // compact_at with no cap (usize::MAX sentinel) → pct only.
1377 let pct_only = RegionDefinition::new(
1378 "c".to_string(),
1379 RegionKind::Compacting {
1380 threshold_tokens: usize::MAX,
1381 },
1382 0,
1383 )
1384 .with_budget(BudgetSpec::Percent {
1385 percent: 0.20,
1386 min: None,
1387 max: None,
1388 })
1389 .with_compact_at(0.80);
1390 let r = ContextLayout::new(vec![pct_only], 0).resolved(200_000);
1391 assert_eq!(
1392 r.regions[0].kind,
1393 RegionKind::Compacting {
1394 threshold_tokens: 32_000
1395 }
1396 );
1397
1398 // compact_at = None → absolute threshold passes through unchanged.
1399 let absolute = RegionDefinition::new(
1400 "c".to_string(),
1401 RegionKind::Compacting {
1402 threshold_tokens: 8000,
1403 },
1404 10_000,
1405 );
1406 let r = ContextLayout::new(vec![absolute], 10_000).resolved(1_000_000);
1407 assert_eq!(
1408 r.regions[0].kind,
1409 RegionKind::Compacting {
1410 threshold_tokens: 8000
1411 }
1412 );
1413 }
1414
1415 #[test]
1416 fn validate_skips_token_checks_for_percent_layouts() {
1417 // A percentage layout whose provisional max_tokens are tiny/zero must not
1418 // trip the fixed-working-budget hard error - that check is deferred to
1419 // post-resolution. Wrap in tracing so no warn-arg lines read uncovered.
1420 let regions = vec![
1421 RegionDefinition::new("big_pinned".to_string(), RegionKind::Pinned, 0).with_budget(
1422 BudgetSpec::Percent {
1423 percent: 0.95,
1424 min: None,
1425 max: None,
1426 },
1427 ),
1428 ];
1429 let layout = ContextLayout::new(regions, 100_000);
1430 with_tracing(|| {
1431 assert!(layout.validate().is_ok());
1432 });
1433 }
1434
1435 #[test]
1436 fn region_definition_default_budget_matches_max_tokens() {
1437 let def = RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000);
1438 assert_eq!(def.budget, BudgetSpec::Absolute(5000));
1439 assert_eq!(def.compact_at, None);
1440 }
1441
1442 #[test]
1443 fn budget_spec_default_is_absolute_zero() {
1444 assert_eq!(BudgetSpec::default(), BudgetSpec::Absolute(0));
1445 }
1446
1447 #[test]
1448 fn test_validate_with_sliding_window_present() {
1449 // A layout that DOES contain a SlidingWindow region exercises the
1450 // has_sliding_window detection returning true, so the "no sliding
1451 // window" warning branch is skipped.
1452 let regions = vec![
1453 RegionDefinition::new("pinned".to_string(), RegionKind::Pinned, 5000),
1454 RegionDefinition::new(
1455 "conv".to_string(),
1456 RegionKind::SlidingWindow {
1457 max_items: 50,
1458 eviction_strategy: crate::region::EvictionStrategy::PerItem,
1459 },
1460 5000,
1461 ),
1462 ];
1463 let layout = ContextLayout::new(regions, 20000);
1464 with_tracing(|| {
1465 assert!(layout.validate().is_ok());
1466 });
1467 }
1468
1469 /// `validate` sums every region's ceiling to warn when they exceed the
1470 /// budget. Two saturated ceilings must warn rather than overflow that sum
1471 /// and abort.
1472 #[test]
1473 fn validate_does_not_abort_when_region_ceilings_sum_past_usize_max() {
1474 let layout = ContextLayout::new(
1475 vec![
1476 RegionDefinition::new("a".to_string(), RegionKind::Pinned, usize::MAX),
1477 RegionDefinition::new("b".to_string(), RegionKind::Pinned, usize::MAX),
1478 ],
1479 1_000,
1480 );
1481 let _ = layout.validate();
1482 }
1483}