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 layout leaves a minimum working budget once the fixed,
254 // non-evictable regions are full. Pinned / HashMap / CompactHistory
255 // regions persist for the whole run and consume budget; if they leave
256 // too little room, the conversation/tool-result (evictable) regions have
257 // almost no space and the agent operates "blind". Fail loudly at load
258 // instead of degrading silently at runtime.
259 let fixed_tokens: usize = self
260 .regions
261 .iter()
262 .filter(|r| {
263 matches!(
264 r.kind,
265 RegionKind::Pinned
266 | RegionKind::HashMap { .. }
267 | RegionKind::CompactHistory { .. }
268 | RegionKind::Custom {
269 persistent: true,
270 ..
271 }
272 )
273 })
274 .fold(0usize, |acc, r| acc.saturating_add(r.max_tokens));
275 // Only enforce the absolute working-budget floor on realistically-sized
276 // layouts. Tiny illustrative layouts (toy examples, unit-test fixtures)
277 // have small budgets by design and are not real agent runs; applying an
278 // absolute floor to them would be nonsensical.
279 let working_tokens = self.total_budget_tokens.saturating_sub(fixed_tokens);
280 if self.total_budget_tokens >= Self::BUDGET_CHECK_MIN_TOTAL
281 && working_tokens < Self::MIN_WORKING_TOKENS
282 {
283 return Err(ValidationError::Layout(format!(
284 "context layout leaves only {working_tokens} working tokens after fixed \
285 regions (pinned/hashmap/compact_history/persistent custom) consume \
286 {fixed_tokens} of the {} \
287 total budget; at least {} are needed for the agent to operate. Reduce the \
288 fixed regions' max_tokens or increase the total budget.",
289 self.total_budget_tokens,
290 Self::MIN_WORKING_TOKENS
291 )));
292 }
293
294 Ok(())
295 }
296
297 /// Minimum token budget that must remain for evictable/working regions
298 /// (conversation, tool results, scratch) after the fixed regions are full,
299 /// so the agent has room to hold recent context and generate. Below this a
300 /// run would operate with almost no working space.
301 const MIN_WORKING_TOKENS: usize = 8000;
302
303 /// The working-budget floor is only enforced when the layout's total budget
304 /// is at least this large - i.e. it's a realistically-sized agent, not a
305 /// toy/illustrative layout where an absolute floor wouldn't make sense.
306 const BUDGET_CHECK_MIN_TOTAL: usize = 20_000;
307
308 /// Get a region definition by name.
309 pub fn get_region(&self, name: &str) -> Option<&RegionDefinition> {
310 self.regions.iter().find(|r| r.name == name)
311 }
312
313 /// Whether any region uses a percentage budget (and therefore needs a model
314 /// context window to resolve to concrete token counts).
315 pub fn has_percent_budgets(&self) -> bool {
316 self.regions.iter().any(|r| r.budget.is_percent())
317 }
318
319 /// Resolve every region's percentage budget against a concrete model context
320 /// `window`, returning a fully-absolute layout.
321 ///
322 /// Each region's `max_tokens` becomes `budget.resolve(window)`, and each
323 /// [`RegionKind::Compacting`] region's `threshold_tokens` is recomputed from
324 /// its [`compact_at`](RegionDefinition::compact_at) fraction (via the private
325 /// `resolve_compacting_threshold` helper). `eviction_order` is preserved. The
326 /// total budget becomes the model `window` when any percentage budget is
327 /// present (percentage ceilings are relative to the whole window and may sum
328 /// past 100%); a pure-absolute layout keeps its legacy summed total unchanged.
329 ///
330 /// Resolving an already-absolute layout is a no-op, so this is safe to call
331 /// unconditionally at window-build time.
332 pub fn resolved(&self, window: usize) -> ContextLayout {
333 let regions = self
334 .regions
335 .iter()
336 .map(|r| {
337 let max_tokens = r.budget.resolve(window);
338 let kind = match &r.kind {
339 RegionKind::Compacting { threshold_tokens } => RegionKind::Compacting {
340 threshold_tokens: Self::resolve_compacting_threshold(
341 r.compact_at,
342 *threshold_tokens,
343 max_tokens,
344 ),
345 },
346 other => other.clone(),
347 };
348 // Emit a fully-absolute region: the percentage has been baked
349 // into `max_tokens` and the compaction threshold into `kind`, so
350 // the resolved layout carries no `Percent` budgets. This makes
351 // `has_percent_budgets()` false on the result, so a post-resolution
352 // `validate()` runs the real token/working-budget checks.
353 RegionDefinition {
354 kind,
355 max_tokens,
356 budget: BudgetSpec::Absolute(max_tokens),
357 compact_at: None,
358 ..r.clone()
359 }
360 })
361 .collect();
362
363 let total_budget_tokens = if self.has_percent_budgets() {
364 window
365 } else {
366 self.total_budget_tokens
367 };
368
369 ContextLayout {
370 regions,
371 total_budget_tokens,
372 eviction_order: self.eviction_order.clone(),
373 }
374 }
375
376 /// Compute a Compacting region's concrete compaction threshold from its
377 /// `compact_at` fraction, the absolute `threshold_tokens` guard carried on
378 /// the kind, and the region's resolved budget.
379 ///
380 /// - `compact_at = Some(f)` with an explicit `threshold_tokens` cap (any
381 /// value below the [`usize::MAX`] sentinel) → `min(round(budget * f), cap)`:
382 /// compact at the percentage, but never later than the absolute guard-rail.
383 /// - `compact_at = Some(f)` with no cap (`threshold_tokens == usize::MAX`
384 /// sentinel) → `round(budget * f)`.
385 /// - `compact_at = None` → the absolute `threshold_tokens` as-is (back-compat,
386 /// including the parser's `max_tokens * 8 / 10` default).
387 ///
388 /// The `usize::MAX` sentinel is safe: a layout is always resolved before any
389 /// [`Region::needs_compaction`](crate::region::Region::needs_compaction) check.
390 fn resolve_compacting_threshold(
391 compact_at: Option<f64>,
392 threshold_tokens: usize,
393 resolved_budget: usize,
394 ) -> usize {
395 match compact_at {
396 Some(fraction) => {
397 let pct = (resolved_budget as f64 * fraction).round() as usize;
398 pct.min(threshold_tokens)
399 }
400 None => threshold_tokens,
401 }
402 }
403}
404
405/// Where a region's initial content comes from at run start.
406///
407/// A region without a seed starts empty and is populated by the agent. A seeded
408/// region is filled before the first inference: `CallerInput` regions are filled
409/// by the run's caller (a CLI `--<name>` flag, an ACP `---region:<name>---`
410/// marker, or the API `regions` map); the remaining variants are resolved by the
411/// daemon from the run's workdir (which is why this type only *declares* the
412/// source - `leviath-core` stays filesystem-agnostic; resolution lives in the
413/// CLI daemon's spawner).
414#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
415#[serde(rename_all = "snake_case")]
416pub enum RegionSeed {
417 /// Filled at run time by the caller, keyed by `name` (defaults to the
418 /// region's own name; the sentinel `task` maps to the `--task`/prompt text).
419 /// When the owning region is `required`, a missing value is a hard error
420 /// before any inference runs.
421 CallerInput {
422 /// The caller-input key this region is filled from.
423 name: String,
424 },
425 /// Concatenated contents of the workdir files matching a glob pattern.
426 Glob {
427 /// Glob pattern, resolved relative to the run's workdir.
428 pattern: String,
429 },
430 /// Concatenated contents of an explicit list of workdir-relative files.
431 Files {
432 /// File paths, resolved relative to the run's workdir.
433 paths: Vec<String>,
434 },
435 /// A static literal string baked into the blueprint.
436 Literal {
437 /// The verbatim seed text.
438 text: String,
439 },
440 /// The `String` returned by running a Rhai script from the workdir.
441 Rhai {
442 /// Script path, resolved relative to the run's workdir.
443 script: String,
444 },
445 /// The combined stdout/stderr of a shell command run in the workdir at spawn.
446 ///
447 /// Unlike every other variant this *executes* something, and it does so
448 /// before the first inference - so before any tool-approval prompt. The
449 /// daemon runs it inside the entry stage's sandbox when one is configured,
450 /// caps its runtime and output, and honours the `[security]
451 /// allow_seed_commands` kill switch. A failure is non-fatal unless the
452 /// owning region is `required`.
453 Command {
454 /// The shell command line, run with the platform shell in the workdir.
455 command: String,
456 },
457 /// The combined output of one or more tool calls, run at spawn.
458 ///
459 /// Like [`Command`](Self::Command) this *executes* something, but through
460 /// the run's own tool layer rather than a shell: any tool the agent could
461 /// call is callable here - a built-in, an MCP server's, a Rhai script's -
462 /// and each call answers to the same `tool_permissions` and taint rules it
463 /// would answer to mid-run. That is what makes an unrestricted list safe:
464 /// a seed can reach nothing the agent was not already granted.
465 ///
466 /// Several calls write into one region, in the order given, each under its
467 /// own heading. A failed call is skipped with a warning unless the region is
468 /// `required`, so one unavailable tool does not cost the others.
469 Tools {
470 /// The calls to run, in order.
471 calls: Vec<SeedToolCall>,
472 /// Whether the calls run once, or again on every stage entry.
473 refresh: SeedRefresh,
474 },
475}
476
477/// When a [`RegionSeed::Tools`] seed runs again.
478///
479/// Every other seed kind resolves once, at spawn, and this defaults to the
480/// same: a region seeded from the filesystem or a literal has no reason to be
481/// re-read, and re-running a call on every stage entry costs a tool call and
482/// rewrites a region the cache was holding still.
483///
484/// [`EachStage`](Self::EachStage) is for the seeds where the answer moves.
485/// A clock is the clear case: a run that spends an hour in one stage and then
486/// enters another should date the second stage from when it started, not from
487/// when the run did.
488#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
489#[serde(rename_all = "snake_case")]
490pub enum SeedRefresh {
491 /// Resolve once, at spawn. The default, and what every other seed does.
492 #[default]
493 Once,
494 /// Resolve again whenever a stage is entered, replacing the region.
495 EachStage,
496}
497
498impl SeedRefresh {
499 /// Parse the manifest spelling, or `None` for a word that is neither.
500 ///
501 /// A wrong spelling is rejected rather than defaulted, so
502 /// `refresh = "each stage"` is reported instead of quietly meaning `once` -
503 /// which would read as the feature not working.
504 pub fn from_str_loose(value: &str) -> Option<Self> {
505 match value.trim().to_ascii_lowercase().as_str() {
506 "once" | "spawn" => Some(Self::Once),
507 "each_stage" | "stage" => Some(Self::EachStage),
508 _ => None,
509 }
510 }
511}
512
513/// One tool call in a [`RegionSeed::Tools`] seed.
514#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
515pub struct SeedToolCall {
516 /// The tool to call, spelled as the agent would spell it (so an MCP tool
517 /// keeps its `<server>__<tool>` qualification).
518 pub name: String,
519 /// The arguments object. Empty for the many tools that take none.
520 pub args: serde_json::Value,
521}
522
523impl SeedToolCall {
524 /// A call with no arguments.
525 pub fn new(name: impl Into<String>) -> Self {
526 Self {
527 name: name.into(),
528 args: serde_json::Value::Object(serde_json::Map::new()),
529 }
530 }
531
532 /// A call with an arguments object.
533 pub fn with_args(name: impl Into<String>, args: serde_json::Value) -> Self {
534 Self {
535 name: name.into(),
536 args,
537 }
538 }
539}
540
541#[cfg(test)]
542mod seed_refresh_tests {
543 use super::*;
544
545 #[test]
546 fn both_spellings_and_their_aliases_parse() {
547 assert_eq!(SeedRefresh::from_str_loose("once"), Some(SeedRefresh::Once));
548 assert_eq!(
549 SeedRefresh::from_str_loose("spawn"),
550 Some(SeedRefresh::Once)
551 );
552 assert_eq!(
553 SeedRefresh::from_str_loose("each_stage"),
554 Some(SeedRefresh::EachStage)
555 );
556 assert_eq!(
557 SeedRefresh::from_str_loose("stage"),
558 Some(SeedRefresh::EachStage)
559 );
560 // Case and surrounding space are not the author's problem.
561 assert_eq!(
562 SeedRefresh::from_str_loose(" EACH_STAGE "),
563 Some(SeedRefresh::EachStage)
564 );
565 }
566
567 /// A word that is neither is rejected rather than defaulted. Defaulting
568 /// would make `refresh = "each stage"` silently mean `once`, which reads as
569 /// the feature not working rather than as a typo.
570 #[test]
571 fn an_unrecognised_word_is_not_quietly_once() {
572 assert_eq!(SeedRefresh::from_str_loose("each stage"), None);
573 assert_eq!(SeedRefresh::from_str_loose("always"), None);
574 assert_eq!(SeedRefresh::from_str_loose(""), None);
575 }
576
577 /// The default matches every other seed kind: resolve at spawn, once.
578 #[test]
579 fn the_default_is_once() {
580 assert_eq!(SeedRefresh::default(), SeedRefresh::Once);
581 }
582}
583
584/// Definition of a region in a layout.
585///
586/// This is the blueprint for creating a Region instance. It specifies the
587/// region's configuration but doesn't contain actual content.
588#[derive(Debug, Clone, Serialize, Deserialize)]
589pub struct RegionDefinition {
590 /// Unique name for this region
591 pub name: String,
592
593 /// Region lifecycle policy
594 pub kind: RegionKind,
595
596 /// **Resolved** maximum tokens for this region. This is the concrete ceiling
597 /// every downstream consumer reads; for a percentage budget it is populated
598 /// when the layout is resolved against a model window (see
599 /// [`ContextLayout::resolved`]). [`Self::budget`] is the source of truth for
600 /// how this value is derived.
601 pub max_tokens: usize,
602
603 /// How this region's ceiling is expressed. Defaults (via [`Self::new`]) to
604 /// [`BudgetSpec::Absolute`] holding `max_tokens`, so a region that names a
605 /// token count directly gets an absolute ceiling. A percentage budget is
606 /// resolved against the model context window at window-build time.
607 #[serde(default)]
608 pub budget: BudgetSpec,
609
610 /// For [`RegionKind::Compacting`] regions only: compact when the region
611 /// reaches this fraction of its resolved budget (`0.80` for `compact_at =
612 /// "80%"`). `None` keeps the absolute `threshold_tokens` carried on the kind.
613 /// See [`ContextLayout::resolved`] for how this becomes a concrete threshold.
614 #[serde(default)]
615 pub compact_at: Option<f64>,
616
617 /// Optional validation schema
618 pub schema: Option<RegionSchema>,
619
620 /// Human-readable description of this region's purpose
621 pub description: Option<String>,
622
623 /// Whether `description` is also shown to the model, under the region's
624 /// name. Off by default - see [`crate::region::Region::describe_in_prompt`].
625 #[serde(default)]
626 pub describe_in_prompt: bool,
627
628 /// When true, this region must be non-empty before a stage that can write
629 /// to it is allowed to complete. Guards against an agent skipping a
630 /// context-population step (e.g. never writing the `plan` region). Enforced
631 /// in the run loop, which re-runs the stage with [`Self::required_message`]
632 /// until the region is populated.
633 #[serde(default)]
634 pub required: bool,
635
636 /// Whether an edge transform may hand this region to the summarizer.
637 ///
638 /// `transform = "compact"` reads as "summarize the transcript on the way
639 /// out" and means "summarize every region that is not pinned", which
640 /// includes the ones holding the run's results. Figures that survive a
641 /// paraphrase are no longer figures: without this, a `results` region
642 /// carrying computed values is rewritten into prose before the stage that
643 /// reports them ever sees it.
644 ///
645 /// Setting this false protects the region wherever it is used, rather than
646 /// at each of the N edges that might touch it. `clear` still applies - this
647 /// says "do not paraphrase my content", not "keep it forever".
648 #[serde(default = "crate::default_true")]
649 pub summarizable: bool,
650
651 /// What this region does when a write does not fit.
652 ///
653 /// Declared per region rather than per stage: whether losing the oldest
654 /// entry is acceptable is a property of what the region holds, and does not
655 /// change depending on which stage is writing to it.
656 #[serde(default)]
657 pub admission: crate::region::Admission,
658 /// How much this region's contents move between requests. See
659 /// [`crate::region::Volatility`].
660 ///
661 /// Defaulted on the wire so a definition written before this existed still
662 /// loads, and loads as the pessimistic value - which is what an unclassified
663 /// region should be.
664 #[serde(default)]
665 pub volatility: crate::region::Volatility,
666
667 /// Optional custom message shown to the agent when this region is required
668 /// but empty. Falls back to a generated default when `None`.
669 #[serde(default)]
670 pub required_message: Option<String>,
671
672 /// Where this region's initial content comes from at run start. `None`
673 /// means the region starts empty (the agent populates it). See
674 /// [`RegionSeed`].
675 #[serde(default)]
676 pub seed: Option<RegionSeed>,
677}
678
679impl RegionDefinition {
680 /// Create a new region definition with an absolute token ceiling.
681 ///
682 /// The `budget` is set to [`BudgetSpec::Absolute`] holding `max_tokens` and
683 /// `compact_at` to `None`, so every existing caller (and every region without
684 /// a percentage budget) is unaffected - resolving such a layout is a no-op.
685 pub fn new(name: String, kind: RegionKind, max_tokens: usize) -> Self {
686 Self {
687 name,
688 kind,
689 max_tokens,
690 budget: BudgetSpec::Absolute(max_tokens),
691 compact_at: None,
692 schema: None,
693 description: None,
694 describe_in_prompt: false,
695 required: false,
696 required_message: None,
697 summarizable: true,
698 admission: crate::region::Admission::default(),
699 volatility: crate::region::Volatility::default(),
700 seed: None,
701 }
702 }
703
704 /// Set this region's budget spec (e.g. a percentage of the model window).
705 /// `max_tokens` is left as the provisional/resolved value; it is (re)computed
706 /// from the budget when the owning layout is resolved.
707 pub fn with_budget(mut self, budget: BudgetSpec) -> Self {
708 self.budget = budget;
709 self
710 }
711
712 /// Set the compaction trigger fraction for a [`RegionKind::Compacting`]
713 /// region (`0.80` == compact at 80% of the resolved budget).
714 pub fn with_compact_at(mut self, fraction: f64) -> Self {
715 self.compact_at = Some(fraction);
716 self
717 }
718
719 /// Set this region's seed source.
720 pub fn with_seed(mut self, seed: RegionSeed) -> Self {
721 self.seed = Some(seed);
722 self
723 }
724
725 /// Mark this region as required, with an optional custom nudge message.
726 pub fn with_required(mut self, required: bool, message: Option<String>) -> Self {
727 self.required = required;
728 self.required_message = message;
729 self
730 }
731
732 /// Add a schema to this region definition.
733 pub fn with_schema(mut self, schema: RegionSchema) -> Self {
734 self.schema = Some(schema);
735 self
736 }
737
738 /// Add a description to this region definition.
739 pub fn with_description(mut self, description: String) -> Self {
740 self.description = Some(description);
741 self
742 }
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748 use leviath_testkit::with_tracing;
749
750 #[test]
751 fn test_layout_creation() {
752 let regions = vec![
753 RegionDefinition::new("pinned".to_string(), RegionKind::Pinned, 5000),
754 RegionDefinition::new("temp".to_string(), RegionKind::Temporary, 10000),
755 ];
756 let layout = ContextLayout::new(regions, 20000);
757 assert_eq!(layout.regions.len(), 2);
758 assert_eq!(layout.total_budget_tokens, 20000);
759 }
760
761 #[test]
762 fn test_layout_validation() {
763 let regions = vec![RegionDefinition::new(
764 "test".to_string(),
765 RegionKind::Pinned,
766 5000,
767 )];
768 let layout =
769 ContextLayout::new(regions, 10000).with_eviction_order(vec!["test".to_string()]);
770
771 assert!(layout.validate().is_ok());
772 }
773
774 #[test]
775 fn test_duplicate_region_names() {
776 let regions = vec![
777 RegionDefinition::new("test".to_string(), RegionKind::Pinned, 5000),
778 RegionDefinition::new("test".to_string(), RegionKind::Temporary, 3000),
779 ];
780 let layout = ContextLayout::new(regions, 10000);
781
782 assert!(layout.validate().is_err());
783 }
784
785 #[test]
786 fn test_eviction_order_unknown_region_is_error() {
787 let regions = vec![RegionDefinition::new(
788 "test".to_string(),
789 RegionKind::Pinned,
790 5000,
791 )];
792 let layout =
793 ContextLayout::new(regions, 10000).with_eviction_order(vec!["nonexistent".to_string()]);
794
795 let err = layout.validate().unwrap_err();
796 assert_eq!(
797 err,
798 ValidationError::Layout(
799 "eviction order references unknown region: nonexistent".to_string()
800 )
801 );
802 }
803
804 #[test]
805 fn test_validate_warns_but_does_not_error_when_max_tokens_exceed_budget() {
806 // Sum of region max_tokens (5000 + 10000 = 15000) exceeds the total
807 // budget (10000) - this should only warn, not fail validation, since
808 // not all regions are full simultaneously.
809 let regions = vec![
810 RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000),
811 RegionDefinition::new("b".to_string(), RegionKind::Temporary, 10000),
812 ];
813 let layout = ContextLayout::new(regions, 10000);
814 with_tracing(|| {
815 assert!(layout.validate().is_ok());
816 });
817 }
818
819 #[test]
820 fn validate_errors_when_fixed_regions_starve_working_budget() {
821 // Realistically-sized layout (>= 20k) where a huge fixed (pinned) region
822 // leaves < 8000 working tokens for conversation/tool-results → hard error.
823 let regions = vec![
824 RegionDefinition::new("big_pinned".to_string(), RegionKind::Pinned, 95_000),
825 RegionDefinition::new("work".to_string(), RegionKind::Temporary, 5_000),
826 ];
827 let layout = ContextLayout::new(regions, 100_000);
828 with_tracing(|| {
829 let err = layout.validate().unwrap_err();
830 assert!(
831 err.to_string().contains("working tokens"),
832 "actionable budget error: {err}"
833 );
834 });
835 }
836
837 #[test]
838 fn validate_ok_for_realistic_layout_with_working_room() {
839 let regions = vec![
840 RegionDefinition::new("task".to_string(), RegionKind::Pinned, 4_000),
841 RegionDefinition::new("conversation".to_string(), RegionKind::Temporary, 40_000),
842 ];
843 let layout = ContextLayout::new(regions, 44_000);
844 with_tracing(|| {
845 assert!(layout.validate().is_ok());
846 });
847 }
848
849 fn custom_kind(script: &str, persistent: bool) -> RegionKind {
850 RegionKind::Custom {
851 script: script.to_string(),
852 persistent,
853 }
854 }
855
856 #[test]
857 fn validate_rejects_custom_region_with_empty_script() {
858 // Whitespace-only counts as empty: it could never resolve to a file
859 // and the runtime would silently fall back on every inference.
860 let regions = vec![RegionDefinition::new(
861 "brain".to_string(),
862 custom_kind(" ", false),
863 5000,
864 )];
865 let layout = ContextLayout::new(regions, 10_000);
866 let err = with_tracing(|| layout.validate().unwrap_err());
867 assert!(
868 err.to_string().contains("non-empty script path"),
869 "actionable error: {err}"
870 );
871 }
872
873 #[test]
874 fn validate_counts_persistent_custom_as_fixed_budget() {
875 // A persistent custom region is Pinned-like: protected from eviction,
876 // so it must count toward the fixed budget that can starve the
877 // working room.
878 let regions = vec![
879 RegionDefinition::new("vault".to_string(), custom_kind("v.rhai", true), 95_000),
880 RegionDefinition::new("work".to_string(), RegionKind::Temporary, 5_000),
881 ];
882 let layout = ContextLayout::new(regions, 100_000);
883 let err = with_tracing(|| layout.validate().unwrap_err());
884 assert!(err.to_string().contains("working tokens"), "{err}");
885 }
886
887 #[test]
888 fn validate_counts_non_persistent_custom_as_working_budget() {
889 // Same shape, but the custom region is evictable - it IS the working
890 // room, so validation passes.
891 let regions = vec![
892 RegionDefinition::new("brain".to_string(), custom_kind("b.rhai", false), 95_000),
893 RegionDefinition::new("task".to_string(), RegionKind::Pinned, 4_000),
894 ];
895 let layout = ContextLayout::new(regions, 100_000);
896 with_tracing(|| {
897 assert!(layout.validate().is_ok());
898 });
899 }
900
901 #[test]
902 fn custom_region_satisfies_the_message_region_check() {
903 // A layout whose only region is custom must not trip the "no
904 // SlidingWindow region" warning path - its script can render typed
905 // entries as messages. (Mirrors the sliding-window-present test: the
906 // skip branch is exercised, validation succeeds.)
907 let regions = vec![RegionDefinition::new(
908 "everything".to_string(),
909 custom_kind("all.rhai", false),
910 9_000,
911 )];
912 let layout = ContextLayout::new(regions, 10_000);
913 with_tracing(|| {
914 assert!(layout.validate().is_ok());
915 });
916 }
917
918 #[test]
919 fn resolved_percent_budget_applies_to_custom_region() {
920 // The "recreate built-ins in Rhai" guarantee: percentage budgets work
921 // on custom regions exactly as on built-in kinds, resolved against
922 // the stage model's context window at spawn.
923 let def = RegionDefinition::new("brain".to_string(), custom_kind("b.rhai", false), 0)
924 .with_budget(BudgetSpec::Percent {
925 percent: 0.40,
926 min: Some(10_000),
927 max: None,
928 });
929 let layout = ContextLayout::new(vec![def], 0);
930 let resolved = layout.resolved(200_000);
931 assert_eq!(resolved.regions[0].max_tokens, 80_000);
932 assert!(matches!(
933 resolved.regions[0].kind,
934 RegionKind::Custom { ref script, persistent: false } if script == "b.rhai"
935 ));
936 // The min floor wins on a small window.
937 let small = layout.resolved(8_192);
938 assert_eq!(small.regions[0].max_tokens, 10_000);
939 }
940
941 #[test]
942 fn test_get_region_found() {
943 let regions = vec![
944 RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000),
945 RegionDefinition::new("b".to_string(), RegionKind::Temporary, 3000),
946 ];
947 let layout = ContextLayout::new(regions, 10000);
948
949 let found = layout.get_region("b").unwrap();
950 assert_eq!(found.name, "b");
951 assert_eq!(found.max_tokens, 3000);
952 }
953
954 #[test]
955 fn test_get_region_not_found() {
956 let regions = vec![RegionDefinition::new(
957 "a".to_string(),
958 RegionKind::Pinned,
959 5000,
960 )];
961 let layout = ContextLayout::new(regions, 10000);
962 assert!(layout.get_region("missing").is_none());
963 }
964
965 #[test]
966 fn test_region_definition_with_schema() {
967 let schema = crate::region::RegionSchema::new(crate::region::ContentFormat::Json);
968 let def =
969 RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000).with_schema(schema);
970 assert_eq!(
971 def.schema.as_ref().unwrap().format,
972 crate::region::ContentFormat::Json
973 );
974 }
975
976 #[test]
977 fn test_region_definition_with_description() {
978 let def = RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000)
979 .with_description("holds architecture notes".to_string());
980 assert_eq!(def.description.as_deref(), Some("holds architecture notes"));
981 }
982
983 #[test]
984 fn parse_budget_accepts_plain_and_decimal_percentages() {
985 assert_eq!(BudgetSpec::parse_budget("35%").unwrap(), 0.35);
986 assert_eq!(BudgetSpec::parse_budget("100%").unwrap(), 1.0);
987 assert!((BudgetSpec::parse_budget("0.6%").unwrap() - 0.006).abs() < 1e-9);
988 }
989
990 #[test]
991 fn parse_budget_trims_surrounding_and_inner_whitespace() {
992 assert_eq!(BudgetSpec::parse_budget(" 35 % ").unwrap(), 0.35);
993 }
994
995 #[test]
996 fn parse_budget_rejects_missing_percent_sign() {
997 let err = BudgetSpec::parse_budget("35").unwrap_err();
998 assert!(err.contains("must end with '%'"), "{err}");
999 }
1000
1001 #[test]
1002 fn parse_budget_rejects_non_numeric() {
1003 let err = BudgetSpec::parse_budget("abc%").unwrap_err();
1004 assert!(err.contains("not a valid number"), "{err}");
1005 }
1006
1007 #[test]
1008 fn parse_budget_rejects_zero_and_negative() {
1009 let zero = BudgetSpec::parse_budget("0%").unwrap_err();
1010 assert!(zero.contains("greater than 0%"), "{zero}");
1011 let neg = BudgetSpec::parse_budget("-10%").unwrap_err();
1012 assert!(neg.contains("greater than 0%"), "{neg}");
1013 }
1014
1015 #[test]
1016 fn parse_budget_rejects_over_one_hundred() {
1017 let err = BudgetSpec::parse_budget("150%").unwrap_err();
1018 assert!(err.contains("at most 100%"), "{err}");
1019 }
1020
1021 #[test]
1022 fn resolve_absolute_ignores_window() {
1023 assert_eq!(BudgetSpec::Absolute(4000).resolve(1_000_000), 4000);
1024 assert!(!BudgetSpec::Absolute(4000).is_percent());
1025 }
1026
1027 #[test]
1028 fn resolve_percent_of_window() {
1029 let spec = BudgetSpec::Percent {
1030 percent: 0.35,
1031 min: None,
1032 max: None,
1033 };
1034 assert_eq!(spec.resolve(1_000_000), 350_000);
1035 assert!(spec.is_percent());
1036 }
1037
1038 #[test]
1039 fn resolve_percent_applies_max_cap() {
1040 let spec = BudgetSpec::Percent {
1041 percent: 0.02,
1042 min: None,
1043 max: Some(4000),
1044 };
1045 // 2% of 1M = 20_000, capped to 4000.
1046 assert_eq!(spec.resolve(1_000_000), 4000);
1047 }
1048
1049 #[test]
1050 fn resolve_percent_applies_min_floor() {
1051 let spec = BudgetSpec::Percent {
1052 percent: 0.02,
1053 min: Some(2000),
1054 max: None,
1055 };
1056 // 2% of 8000 = 160, floored to 2000.
1057 assert_eq!(spec.resolve(8000), 2000);
1058 }
1059
1060 #[test]
1061 fn resolve_percent_within_bounds_takes_neither_clamp() {
1062 let spec = BudgetSpec::Percent {
1063 percent: 0.10,
1064 min: Some(1000),
1065 max: Some(50_000),
1066 };
1067 // 10% of 200k = 20_000, between the floor and cap.
1068 assert_eq!(spec.resolve(200_000), 20_000);
1069 }
1070
1071 #[test]
1072 fn resolve_percent_floor_wins_when_min_exceeds_max() {
1073 let spec = BudgetSpec::Percent {
1074 percent: 0.10,
1075 min: Some(9000),
1076 max: Some(4000),
1077 };
1078 // 10% of 200k = 20_000 → capped to 4000 → floored up to 9000 (floor wins).
1079 assert_eq!(spec.resolve(200_000), 9000);
1080 }
1081
1082 #[test]
1083 fn has_percent_budgets_detects_percentage_regions() {
1084 let absolute = ContextLayout::new(
1085 vec![RegionDefinition::new(
1086 "a".to_string(),
1087 RegionKind::Pinned,
1088 5000,
1089 )],
1090 5000,
1091 );
1092 assert!(!absolute.has_percent_budgets());
1093
1094 let percent = ContextLayout::new(
1095 vec![
1096 RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000).with_budget(
1097 BudgetSpec::Percent {
1098 percent: 0.05,
1099 min: None,
1100 max: None,
1101 },
1102 ),
1103 ],
1104 5000,
1105 );
1106 assert!(percent.has_percent_budgets());
1107 }
1108
1109 #[test]
1110 fn resolved_is_noop_for_absolute_layout() {
1111 let layout = ContextLayout::new(
1112 vec![RegionDefinition::new(
1113 "a".to_string(),
1114 RegionKind::Pinned,
1115 5000,
1116 )],
1117 5000,
1118 );
1119 let resolved = layout.resolved(1_000_000);
1120 assert_eq!(resolved.regions[0].max_tokens, 5000);
1121 // Absolute layout keeps its legacy summed total, not the window.
1122 assert_eq!(resolved.total_budget_tokens, 5000);
1123 }
1124
1125 #[test]
1126 fn resolved_percent_layout_uses_window_as_total() {
1127 let layout = ContextLayout::new(
1128 vec![
1129 RegionDefinition::new("a".to_string(), RegionKind::Pinned, 0).with_budget(
1130 BudgetSpec::Percent {
1131 percent: 0.10,
1132 min: None,
1133 max: None,
1134 },
1135 ),
1136 ],
1137 0,
1138 )
1139 .with_eviction_order(vec!["a".to_string()]);
1140 let resolved = layout.resolved(1_000_000);
1141 assert_eq!(resolved.regions[0].max_tokens, 100_000);
1142 assert_eq!(resolved.total_budget_tokens, 1_000_000);
1143 // eviction order carried through.
1144 assert_eq!(resolved.eviction_order, vec!["a".to_string()]);
1145 }
1146
1147 #[test]
1148 fn resolved_compacting_threshold_all_cases() {
1149 // compact_at + explicit threshold cap → min(pct, cap).
1150 let both = RegionDefinition::new(
1151 "c".to_string(),
1152 RegionKind::Compacting {
1153 threshold_tokens: 25_000,
1154 },
1155 0,
1156 )
1157 .with_budget(BudgetSpec::Percent {
1158 percent: 0.20,
1159 min: None,
1160 max: None,
1161 })
1162 .with_compact_at(0.80);
1163 let r = ContextLayout::new(vec![both], 0).resolved(200_000);
1164 // budget = 40_000; 80% = 32_000; capped to 25_000.
1165 assert_eq!(
1166 r.regions[0].kind,
1167 RegionKind::Compacting {
1168 threshold_tokens: 25_000
1169 }
1170 );
1171
1172 // compact_at with no cap (usize::MAX sentinel) → pct only.
1173 let pct_only = RegionDefinition::new(
1174 "c".to_string(),
1175 RegionKind::Compacting {
1176 threshold_tokens: usize::MAX,
1177 },
1178 0,
1179 )
1180 .with_budget(BudgetSpec::Percent {
1181 percent: 0.20,
1182 min: None,
1183 max: None,
1184 })
1185 .with_compact_at(0.80);
1186 let r = ContextLayout::new(vec![pct_only], 0).resolved(200_000);
1187 assert_eq!(
1188 r.regions[0].kind,
1189 RegionKind::Compacting {
1190 threshold_tokens: 32_000
1191 }
1192 );
1193
1194 // compact_at = None → absolute threshold passes through unchanged.
1195 let absolute = RegionDefinition::new(
1196 "c".to_string(),
1197 RegionKind::Compacting {
1198 threshold_tokens: 8000,
1199 },
1200 10_000,
1201 );
1202 let r = ContextLayout::new(vec![absolute], 10_000).resolved(1_000_000);
1203 assert_eq!(
1204 r.regions[0].kind,
1205 RegionKind::Compacting {
1206 threshold_tokens: 8000
1207 }
1208 );
1209 }
1210
1211 #[test]
1212 fn validate_skips_token_checks_for_percent_layouts() {
1213 // A percentage layout whose provisional max_tokens are tiny/zero must not
1214 // trip the fixed-working-budget hard error - that check is deferred to
1215 // post-resolution. Wrap in tracing so no warn-arg lines read uncovered.
1216 let regions = vec![
1217 RegionDefinition::new("big_pinned".to_string(), RegionKind::Pinned, 0).with_budget(
1218 BudgetSpec::Percent {
1219 percent: 0.95,
1220 min: None,
1221 max: None,
1222 },
1223 ),
1224 ];
1225 let layout = ContextLayout::new(regions, 100_000);
1226 with_tracing(|| {
1227 assert!(layout.validate().is_ok());
1228 });
1229 }
1230
1231 #[test]
1232 fn region_definition_default_budget_matches_max_tokens() {
1233 let def = RegionDefinition::new("a".to_string(), RegionKind::Pinned, 5000);
1234 assert_eq!(def.budget, BudgetSpec::Absolute(5000));
1235 assert_eq!(def.compact_at, None);
1236 }
1237
1238 #[test]
1239 fn budget_spec_default_is_absolute_zero() {
1240 assert_eq!(BudgetSpec::default(), BudgetSpec::Absolute(0));
1241 }
1242
1243 #[test]
1244 fn test_validate_with_sliding_window_present() {
1245 // A layout that DOES contain a SlidingWindow region exercises the
1246 // has_sliding_window detection returning true, so the "no sliding
1247 // window" warning branch is skipped.
1248 let regions = vec![
1249 RegionDefinition::new("pinned".to_string(), RegionKind::Pinned, 5000),
1250 RegionDefinition::new(
1251 "conv".to_string(),
1252 RegionKind::SlidingWindow {
1253 max_items: 50,
1254 eviction_strategy: crate::region::EvictionStrategy::PerItem,
1255 },
1256 5000,
1257 ),
1258 ];
1259 let layout = ContextLayout::new(regions, 20000);
1260 with_tracing(|| {
1261 assert!(layout.validate().is_ok());
1262 });
1263 }
1264
1265 /// `validate` sums every region's ceiling to warn when they exceed the
1266 /// budget. Two saturated ceilings must warn rather than overflow that sum
1267 /// and abort.
1268 #[test]
1269 fn validate_does_not_abort_when_region_ceilings_sum_past_usize_max() {
1270 let layout = ContextLayout::new(
1271 vec![
1272 RegionDefinition::new("a".to_string(), RegionKind::Pinned, usize::MAX),
1273 RegionDefinition::new("b".to_string(), RegionKind::Pinned, usize::MAX),
1274 ],
1275 1_000,
1276 );
1277 let _ = layout.validate();
1278 }
1279}