Skip to main content

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, .. } = &region.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}