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