Skip to main content

leviath_runtime/
context_setup.rs

1//! Context-window setup helpers shared by the ECS pipeline's spawner and
2//! stage-entry.
3//!
4//! These are pure operations over a [`ContextWindow`] driven by a
5//! blueprint/layout.
6
7use std::collections::HashMap;
8
9use leviath_core::{
10    Blueprint, ContextLayout, EvictionStrategy, Region, RegionKind, truncate_at_boundary,
11};
12
13use crate::ContextWindow;
14
15/// Initialize a [`ContextWindow`] from a blueprint and seed its regions from a
16/// name→content map. Adds each layout region plus the infra
17/// `tool_results`/`conversation` regions, then fills each seed whose key matches
18/// a declared region. The `task` key gets the legacy fallback: if there is no
19/// region literally named `task`, it seeds the first pinned region instead.
20/// Pure over the window (no engine/entity), so both the imperative engine and
21/// the ECS pipeline's spawner can share it.
22pub fn init_window_seeded(
23    window: &mut ContextWindow,
24    blueprint: &Blueprint,
25    seeds: &HashMap<String, String>,
26) {
27    for region_def in &blueprint.context_layout.regions {
28        let mut region = Region::new(
29            region_def.name.clone(),
30            region_def.kind.clone(),
31            region_def.max_tokens,
32        );
33        region.summarizable = region_def.summarizable;
34        region.admission = region_def.admission;
35        window.add_region(region);
36    }
37
38    if window.get_region("tool_results").is_none() {
39        let tool_region = Region::new("tool_results".to_string(), RegionKind::Temporary, 5000);
40        window.add_region(tool_region);
41    }
42
43    if window.get_region("conversation").is_none() {
44        let conv_region = Region::new(
45            "conversation".to_string(),
46            RegionKind::SlidingWindow {
47                max_items: 50,
48                eviction_strategy: EvictionStrategy::PerItem,
49            },
50            10000,
51        );
52        window.add_region(conv_region);
53    }
54
55    // Where `submit_output` mirrors the run's answer. Pinned, so the answer
56    // stays visible to later stages (one can revise it) and is never evicted to
57    // make room for the work that produced it. Its budget is the output cap
58    // expressed in tokens, so a submission at the size limit still fits.
59    if window
60        .get_region(crate::output_tool::FINAL_OUTPUT_REGION)
61        .is_none()
62    {
63        window.add_region(Region::new(
64            crate::output_tool::FINAL_OUTPUT_REGION.to_string(),
65            RegionKind::Pinned,
66            crate::output_tool::FINAL_OUTPUT_REGION_TOKENS,
67        ));
68    }
69
70    for (name, content) in seeds {
71        // The task key keeps its legacy fallback: prefer a region named "task",
72        // else the first pinned region. Every other key targets its region by
73        // exact name (unknown names are already rejected upstream, so ignore
74        // them here to keep this pure/infallible).
75        let target = if name == "task" {
76            task_region_name(blueprint)
77        } else {
78            blueprint
79                .context_layout
80                .regions
81                .iter()
82                .find(|r| &r.name == name)
83                .map(|r| r.name.clone())
84        };
85        if let Some(region_name) = target {
86            // Trim to the region's (already-resolved) budget first: `add_entry`
87            // REJECTS an over-budget entry outright rather than truncating it, so
88            // without this a seed larger than its region - a big README, a long
89            // `git ls-files` - would silently leave the region completely empty.
90            let budget = window
91                .get_region(&region_name)
92                .map(|r| r.max_tokens)
93                .unwrap_or(0);
94            let fitted = fit_seed_to_budget(content, budget);
95            let tokens = leviath_core::estimate_tokens(&fitted);
96            let _ = window.add_to_region(&region_name, fitted, tokens);
97        }
98    }
99}
100
101/// Marker appended to a seed that was trimmed to fit its region.
102const SEED_TRUNCATION_MARKER: &str =
103    "\n[...truncated by leviath: seed exceeded this region's budget]";
104
105/// Trim `content` so that its `len/4 + 1` token estimate fits `max_tokens`,
106/// leaving room for [`SEED_TRUNCATION_MARKER`]. Returns `content` unchanged when
107/// it already fits. Always cuts on a UTF-8 char boundary.
108fn fit_seed_to_budget(content: &str, max_tokens: usize) -> String {
109    // The token estimate used throughout: `len / 4 + 1`. Fitting means
110    // `len / 4 + 1 <= max_tokens`, i.e. `len <= (max_tokens - 1) * 4`.
111    let allowed = max_tokens.saturating_sub(1).saturating_mul(4);
112    if content.len() <= allowed {
113        return content.to_string();
114    }
115    // Reserve room for the marker; if even that doesn't fit, the region is too
116    // small to say anything useful, so emit nothing rather than a lone marker.
117    let Some(room) = allowed.checked_sub(SEED_TRUNCATION_MARKER.len()) else {
118        return String::new();
119    };
120    format!(
121        "{}{SEED_TRUNCATION_MARKER}",
122        truncate_at_boundary(content, room)
123    )
124}
125
126/// Resolve which region the `task` text seeds into: prefer a pinned region named
127/// `task`, else the first pinned region.
128fn task_region_name(blueprint: &Blueprint) -> Option<String> {
129    blueprint
130        .context_layout
131        .regions
132        .iter()
133        .find(|r| r.name == "task" && matches!(r.kind, RegionKind::Pinned))
134        .or_else(|| {
135            blueprint
136                .context_layout
137                .regions
138                .iter()
139                .find(|r| matches!(r.kind, RegionKind::Pinned))
140        })
141        .map(|r| r.name.clone())
142}
143
144/// Initialize a [`ContextWindow`] seeding only the task text - the thin
145/// back-compat wrapper over [`init_window_seeded`] used by callers that carry a
146/// single task string (the imperative engine and existing tests).
147pub fn init_window(window: &mut ContextWindow, blueprint: &Blueprint, task: &str) {
148    let seeds = HashMap::from([("task".to_string(), task.to_string())]);
149    init_window_seeded(window, blueprint, &seeds);
150}
151
152/// Swap a [`ContextWindow`] to a stage-specific layout in place, preserving each
153/// carried-over region's existing content by name. Pure over the window (no
154/// engine/entity), so both the imperative engine and the ECS pipeline's
155/// stage-entry can share it.
156pub fn apply_layout(window: &mut ContextWindow, layout: &ContextLayout) {
157    let mut new_regions = Vec::new();
158    let mut kept: std::collections::HashSet<&str> = std::collections::HashSet::new();
159    for region_def in &layout.regions {
160        let mut new_region = Region::new(
161            region_def.name.clone(),
162            region_def.kind.clone(),
163            region_def.max_tokens,
164        );
165        new_region.summarizable = region_def.summarizable;
166        new_region.admission = region_def.admission;
167
168        if let Some(existing) = window.get_region(&region_def.name) {
169            // Carry entries verbatim - kind, metadata, key, timestamp survive
170            // the swap. Rebuilding via `add_entry` flattened every carried
171            // entry to `EntryKind::Text`, which destroyed the typed tool_use/
172            // tool_result pairing of any message-bearing region and left the
173            // assembler's orphan sanitizer to strip the whole history.
174            for entry in &existing.content {
175                let _ = new_region.carry_entry(entry.clone());
176            }
177            // The region-level taint state carries wholesale too; the rebuild
178            // used to silently reset it.
179            new_region.taint = existing.taint.clone();
180        }
181
182        kept.insert(region_def.name.as_str());
183        new_regions.push(new_region);
184    }
185
186    // Everything the stage layout did not declare is carried anyway, and
187    // hidden instead of deleted.
188    //
189    // Dropping them made `[stages.X.context.regions]` unusable for the thing it
190    // looks designed for: narrowing what one stage attends to, in a pipeline
191    // whose later stages still need the data. Re-declaring a region downstream
192    // brought it back empty, so an author had to choose between carrying a
193    // 6,700-token data preview through every call of every stage and destroying
194    // it. Omission now means "not assembled for this stage" and nothing else.
195    //
196    // `conversation`, `tool_results` and `final_output` are carried *visible*
197    // regardless: the first two hold the typed tool_use/tool_result turns, and
198    // hiding them would strand a message history the next stage's own turns
199    // have to attach to. An answer submitted early has to survive to the end
200    // for the same reason.
201    // `stage_instructions` joins them for a different reason: it holds the
202    // prompt of the stage being entered, which is written straight after this
203    // runs. Hiding it because a stage's own `[context.regions]` did not list it
204    // would silently drop that stage's instructions - the region is the
205    // runtime's to fill, not something an author has to remember to re-declare
206    // in every stage.
207    let always_visible = [
208        "conversation",
209        "tool_results",
210        crate::output_tool::FINAL_OUTPUT_REGION,
211        leviath_core::layout::STAGE_INSTRUCTIONS_REGION,
212    ];
213    let mut hidden = std::collections::HashSet::new();
214    for existing in &window.regions {
215        if kept.contains(existing.name.as_str()) {
216            continue;
217        }
218        let mut carried = Region::new(
219            existing.name.clone(),
220            existing.kind.clone(),
221            existing.max_tokens,
222        );
223        carried.summarizable = existing.summarizable;
224        carried.admission = existing.admission;
225        // Verbatim, exactly as above: these are the regions whose typed turns
226        // a rebuild would flatten.
227        for entry in &existing.content {
228            let _ = carried.carry_entry(entry.clone());
229        }
230        carried.taint = existing.taint.clone();
231        if !always_visible.contains(&existing.name.as_str()) {
232            hidden.insert(existing.name.clone());
233        }
234        new_regions.push(carried);
235    }
236    // Describes the stage being entered, so it replaces rather than accumulates.
237    window.hidden = hidden;
238
239    window.regions = new_regions;
240    window.current_tokens = window.calculate_tokens();
241}
242
243/// Give the stage prompts a region of their own when the blueprint did not.
244///
245/// [`STAGE_INSTRUCTIONS_REGION`] is, in this file's own words further up, "the
246/// runtime's to fill, not something an author has to remember to re-declare".
247/// It was only ever *used* when an author declared it, though - and when they
248/// did not, the prompt went into whatever pinned region happened to be first.
249/// That is usually `task`, whose budget is sized for a sentence from the caller
250/// and not for a stage's instructions.
251///
252/// Under window pressure that is a spawn failure rather than a squeeze:
253///
254/// ```text
255/// stage system prompt does not fit region 'task' (2887 > 2560)
256/// ```
257///
258/// The workaround is to floor every `task` declaration with a `min_tokens` sized
259/// for the largest *stage prompt* - which couples an unrelated region's floor to
260/// prompt lengths, and only shows up at spawn on a small window, so it reads as
261/// the caller's fault rather than as routing.
262///
263/// Sized to the largest prompt the blueprint actually carries, because that is
264/// the one that has to fit and anything beyond it is budget taken from the work.
265/// A blueprint whose stages have no prompts gets no region: there would be
266/// nothing to put in it.
267///
268/// Capped at a quarter of the window, which is what keeps
269/// this from turning a real failure into a silent one. A prompt larger than the
270/// whole window cannot be made to fit by giving it a bigger region, and a spawn
271/// that says so is right to. What changes is only which region the message
272/// names: `stage_instructions`, which is where the prompt was going, rather than
273/// `task`, which is the caller's.
274///
275/// [`STAGE_INSTRUCTIONS_REGION`]: leviath_core::layout::STAGE_INSTRUCTIONS_REGION
276pub fn ensure_stage_instructions_region(window: &mut ContextWindow, prompts: &[Option<String>]) {
277    let declared = leviath_core::layout::STAGE_INSTRUCTIONS_REGION;
278    if window.get_region(declared).is_some() {
279        return;
280    }
281    // The wrapper travels with the prompt, so it is measured with it.
282    let widest = prompts
283        .iter()
284        .flatten()
285        .map(|p| leviath_core::estimate_tokens(&format!("[Stage instructions: {p}]")))
286        .max();
287    let Some(widest) = widest.filter(|t| *t > 0) else {
288        return;
289    };
290    let ceiling = window.max_tokens / INSTRUCTIONS_SHARE_OF_WINDOW;
291    window.add_region(Region::new(
292        declared.to_string(),
293        RegionKind::Pinned,
294        widest.min(ceiling),
295    ));
296}
297
298/// The largest share of the window an auto-created instructions region may take,
299/// as a divisor: a quarter.
300///
301/// Only ever a ceiling - the region is sized to the prompt it has to hold, and
302/// this is what it may not exceed. A quarter is generous for instructions and
303/// still leaves the window mostly for the work; a prompt that will not fit in it
304/// is one no region size was going to rescue.
305const INSTRUCTIONS_SHARE_OF_WINDOW: usize = 4;
306
307#[cfg(test)]
308mod tests {
309    use super::{
310        SEED_TRUNCATION_MARKER, apply_layout, fit_seed_to_budget, init_window, init_window_seeded,
311    };
312    use crate::ContextWindow;
313    use leviath_core::{
314        Blueprint, ContextLayout, EvictionStrategy, RegionKind, Stage, blueprint::ModelConfig,
315        layout::RegionDefinition,
316    };
317    use std::collections::HashMap;
318
319    fn blueprint_with(regions: Vec<RegionDefinition>) -> Blueprint {
320        let layout = ContextLayout::new(regions, 100_000);
321        let stages = vec![Stage::new(
322            "main".to_string(),
323            ModelConfig::new("anthropic".to_string(), "claude-sonnet-4".to_string()),
324        )];
325        Blueprint::new("bp".to_string(), "desc".to_string(), stages, layout)
326    }
327
328    fn seeded_window(bp: &Blueprint, task: &str) -> ContextWindow {
329        let mut window = ContextWindow::new(100_000);
330        init_window(&mut window, bp, task);
331        window
332    }
333
334    /// The infra region is added when the layout does not already declare it. A
335    /// blueprint that names `final_output` itself keeps its own definition,
336    /// budget and all, rather than being silently overwritten with the default.
337    #[test]
338    fn a_layout_that_declares_final_output_keeps_its_own() {
339        const DECLARED_TOKENS: usize = 12_345;
340        let bp = blueprint_with(vec![
341            RegionDefinition::new("task".to_string(), RegionKind::Pinned, 1_000),
342            RegionDefinition::new(
343                crate::output_tool::FINAL_OUTPUT_REGION.to_string(),
344                RegionKind::Pinned,
345                DECLARED_TOKENS,
346            ),
347        ]);
348
349        let window = seeded_window(&bp, "t");
350
351        assert_eq!(
352            window
353                .get_region(crate::output_tool::FINAL_OUTPUT_REGION)
354                .expect("the region is there")
355                .max_tokens,
356            DECLARED_TOKENS,
357            "the blueprint's own budget survives"
358        );
359    }
360
361    /// And a layout that says nothing about it gets the default, so an agent
362    /// never has to declare a region it did not ask for.
363    #[test]
364    fn a_layout_without_final_output_gets_the_default_one() {
365        let bp = blueprint_with(vec![RegionDefinition::new(
366            "task".to_string(),
367            RegionKind::Pinned,
368            1_000,
369        )]);
370
371        let window = seeded_window(&bp, "t");
372
373        assert_eq!(
374            window
375                .get_region(crate::output_tool::FINAL_OUTPUT_REGION)
376                .expect("added for us")
377                .max_tokens,
378            crate::output_tool::FINAL_OUTPUT_REGION_TOKENS
379        );
380    }
381
382    #[test]
383    fn init_window_seeded_fills_multiple_named_regions_and_ignores_unknown() {
384        let bp = blueprint_with(vec![
385            RegionDefinition::new("task".to_string(), RegionKind::Pinned, 5000),
386            RegionDefinition::new("criteria".to_string(), RegionKind::Pinned, 5000),
387        ]);
388        let seeds = HashMap::from([
389            ("task".to_string(), "build a parser".to_string()),
390            ("criteria".to_string(), "focus on safety".to_string()),
391            ("ghost".to_string(), "no such region".to_string()),
392        ]);
393        let mut window = ContextWindow::new(100_000);
394        init_window_seeded(&mut window, &bp, &seeds);
395
396        assert!(
397            window
398                .get_region("task")
399                .unwrap()
400                .content
401                .iter()
402                .any(|e| e.content.contains("build a parser"))
403        );
404        assert!(
405            window
406                .get_region("criteria")
407                .unwrap()
408                .content
409                .iter()
410                .any(|e| e.content.contains("focus on safety"))
411        );
412        // An unknown seed key targets no region and is silently dropped.
413        assert!(window.get_region("ghost").is_none());
414    }
415
416    #[test]
417    fn fit_seed_to_budget_leaves_a_fitting_seed_untouched() {
418        assert_eq!(fit_seed_to_budget("hello", 100), "hello");
419        // Exactly at the limit: len == (max_tokens - 1) * 4.
420        let exact = "x".repeat(36);
421        assert_eq!(fit_seed_to_budget(&exact, 10), exact);
422    }
423
424    /// The token estimate `init_window_seeded` computes for a fitted seed - the
425    /// number that has to land inside the region's budget.
426    fn estimated_tokens(fitted: &str) -> usize {
427        leviath_core::estimate_tokens(fitted)
428    }
429
430    #[test]
431    fn fit_seed_to_budget_truncates_and_marks_an_oversized_seed() {
432        let big = "x".repeat(10_000);
433        let fitted = fit_seed_to_budget(&big, 100);
434        assert!(fitted.ends_with(SEED_TRUNCATION_MARKER));
435        // The estimate the caller will compute must actually fit the budget.
436        let estimate = estimated_tokens(&fitted);
437        assert!(estimate <= 100, "estimate was {estimate}");
438    }
439
440    #[test]
441    fn fit_seed_to_budget_cuts_on_a_char_boundary() {
442        // Place a 2-byte char so it straddles the cut exactly: slicing there
443        // would panic, so the walk-back has to move off it.
444        const MAX_TOKENS: usize = 60;
445        let room = (MAX_TOKENS - 1) * 4 - SEED_TRUNCATION_MARKER.len();
446        let mut s = "a".repeat(room - 1);
447        s.push('é'); // occupies bytes room-1 and room - the cut lands inside it
448        s.push_str(&"b".repeat(500));
449        assert!(!s.is_char_boundary(room), "test must straddle the cut");
450
451        let fitted = fit_seed_to_budget(&s, MAX_TOKENS);
452        assert!(fitted.ends_with(SEED_TRUNCATION_MARKER));
453        assert!(estimated_tokens(&fitted) <= MAX_TOKENS);
454        // The straddling char was dropped whole rather than split.
455        assert_eq!(
456            fitted,
457            format!("{}{SEED_TRUNCATION_MARKER}", "a".repeat(room - 1))
458        );
459    }
460
461    #[test]
462    fn fit_seed_to_budget_yields_nothing_when_even_the_marker_cannot_fit() {
463        // A region too small to hold the marker gets nothing rather than a bare
464        // "[...truncated]" with no content.
465        assert_eq!(fit_seed_to_budget("some content here", 2), "");
466        // Degenerate budgets are handled by the saturating arithmetic.
467        assert_eq!(fit_seed_to_budget("x", 0), "");
468    }
469
470    #[test]
471    fn init_window_seeded_truncates_a_seed_larger_than_its_region() {
472        // Regression: `add_entry` rejects an over-budget entry outright, so a
473        // seed must be trimmed first - an untrimmed oversized seed leaves the
474        // region completely EMPTY.
475        let bp = blueprint_with(vec![RegionDefinition::new(
476            "facts".to_string(),
477            RegionKind::Pinned,
478            50,
479        )]);
480        let seeds = HashMap::from([("facts".to_string(), "y".repeat(10_000))]);
481        let mut window = ContextWindow::new(100_000);
482        init_window_seeded(&mut window, &bp, &seeds);
483
484        let region = window.get_region("facts").unwrap();
485        assert!(
486            !region.content.is_empty(),
487            "an oversized seed must be trimmed, not dropped"
488        );
489        assert!(region.content[0].content.ends_with(SEED_TRUNCATION_MARKER));
490    }
491
492    #[test]
493    fn init_window_seeded_task_key_falls_back_to_first_pinned() {
494        // No region literally named "task": the "task" seed key still lands in
495        // the first pinned region (legacy fallback), while a named key does not.
496        let bp = blueprint_with(vec![RegionDefinition::new(
497            "system".to_string(),
498            RegionKind::Pinned,
499            5000,
500        )]);
501        let seeds = HashMap::from([("task".to_string(), "fallback text".to_string())]);
502        let mut window = ContextWindow::new(100_000);
503        init_window_seeded(&mut window, &bp, &seeds);
504        assert!(
505            window
506                .get_region("system")
507                .unwrap()
508                .content
509                .iter()
510                .any(|e| e.content.contains("fallback text"))
511        );
512    }
513
514    #[test]
515    fn init_prefers_named_task_region_and_keeps_existing_infra_regions() {
516        let bp = blueprint_with(vec![
517            RegionDefinition::new("task".to_string(), RegionKind::Pinned, 5000),
518            RegionDefinition::new("tool_results".to_string(), RegionKind::Temporary, 5000),
519            RegionDefinition::new(
520                "conversation".to_string(),
521                RegionKind::SlidingWindow {
522                    max_items: 10,
523                    eviction_strategy: EvictionStrategy::PerItem,
524                },
525                10_000,
526            ),
527        ]);
528
529        let window = seeded_window(&bp, "do the thing");
530        // Task seeded into the explicitly-named "task" pinned region.
531        assert!(
532            window
533                .get_region("task")
534                .unwrap()
535                .content
536                .iter()
537                .any(|e| e.content.contains("do the thing"))
538        );
539        // Blueprint-declared tool_results / conversation are not duplicated.
540        assert_eq!(
541            window
542                .regions
543                .iter()
544                .filter(|r| r.name == "tool_results")
545                .count(),
546            1
547        );
548        assert_eq!(
549            window
550                .regions
551                .iter()
552                .filter(|r| r.name == "conversation")
553                .count(),
554            1
555        );
556    }
557
558    #[test]
559    fn init_adds_infra_regions_and_falls_back_to_first_pinned() {
560        // Only a pinned "system" region (not named "task"): task falls back to
561        // it, and tool_results + conversation are auto-added.
562        let bp = blueprint_with(vec![RegionDefinition::new(
563            "system".to_string(),
564            RegionKind::Pinned,
565            5000,
566        )]);
567
568        let window = seeded_window(&bp, "seed task");
569        assert!(window.get_region("tool_results").is_some());
570        assert!(window.get_region("conversation").is_some());
571        assert!(
572            window
573                .get_region("system")
574                .unwrap()
575                .content
576                .iter()
577                .any(|e| e.content.contains("seed task"))
578        );
579    }
580
581    #[test]
582    fn init_without_pinned_region_does_not_seed_task() {
583        let bp = blueprint_with(vec![RegionDefinition::new(
584            "scratch".to_string(),
585            RegionKind::Temporary,
586            5000,
587        )]);
588
589        let window = seeded_window(&bp, "unseeded task");
590        // No pinned region → task text is seeded nowhere; the sole declared
591        // region stays empty.
592        assert!(window.get_region("scratch").unwrap().content.is_empty());
593        // Infra regions still added.
594        assert!(window.get_region("tool_results").is_some());
595        assert!(window.get_region("conversation").is_some());
596    }
597
598    #[test]
599    fn init_task_named_region_that_is_not_pinned_falls_back_to_first_pinned() {
600        // A region literally named "task" but NOT pinned must be rejected by
601        // the `name == "task" && matches!(kind, Pinned)` guard, falling back to
602        // the first pinned region ("system").
603        let bp = blueprint_with(vec![
604            RegionDefinition::new("task".to_string(), RegionKind::Temporary, 5000),
605            RegionDefinition::new("system".to_string(), RegionKind::Pinned, 5000),
606        ]);
607
608        let window = seeded_window(&bp, "fallback seed");
609        // The non-pinned "task" region is left empty...
610        assert!(window.get_region("task").unwrap().content.is_empty());
611        // ...and the seed lands in the first pinned region instead.
612        assert!(
613            window
614                .get_region("system")
615                .unwrap()
616                .content
617                .iter()
618                .any(|e| e.content.contains("fallback seed"))
619        );
620    }
621
622    #[test]
623    fn apply_layout_preserves_overlapping_content_and_creates_new_regions() {
624        let bp = blueprint_with(vec![RegionDefinition::new(
625            "system".to_string(),
626            RegionKind::Pinned,
627            5000,
628        )]);
629        let mut window = seeded_window(&bp, "carried content");
630
631        // New layout keeps "system" (content should carry over) and adds a
632        // brand-new "scratch" region (no prior content → the None branch).
633        let new_layout = ContextLayout::new(
634            vec![
635                RegionDefinition::new("system".to_string(), RegionKind::Pinned, 5000),
636                RegionDefinition::new("scratch".to_string(), RegionKind::Temporary, 3000),
637            ],
638            8000,
639        );
640
641        apply_layout(&mut window, &new_layout);
642
643        // system + scratch from the new layout, PLUS the auto-added infra regions
644        // carried across the transition even though the new layout doesn't declare
645        // them: conversation and tool_results so the message history survives, and
646        // final_output so an answer submitted before the transition is still there
647        // after it.
648        assert_eq!(window.regions.len(), 5);
649        assert!(window.get_region("conversation").is_some());
650        assert!(window.get_region("tool_results").is_some());
651        assert!(
652            window
653                .get_region(crate::output_tool::FINAL_OUTPUT_REGION)
654                .is_some(),
655            "a submitted answer must survive a stage transition"
656        );
657        assert!(
658            window
659                .get_region("system")
660                .unwrap()
661                .content
662                .iter()
663                .any(|e| e.content.contains("carried content"))
664        );
665        assert!(window.get_region("scratch").unwrap().content.is_empty());
666        // Token total recomputed from the surviving content.
667        assert_eq!(window.current_tokens, window.calculate_tokens());
668        assert!(window.current_tokens > 0);
669    }
670
671    #[test]
672    fn apply_layout_preserves_entry_kinds_and_taint_across_swap() {
673        // Regression: the carry used to rebuild entries via `add_entry`, which
674        // stamped every carried entry `EntryKind::Text` (destroying tool_use/
675        // tool_result pairing) and silently reset region-level taint.
676        let bp = blueprint_with(vec![RegionDefinition::new(
677            "task".to_string(),
678            RegionKind::Pinned,
679            5000,
680        )]);
681        let mut window = seeded_window(&bp, "the task");
682        window
683            .add_typed_entry(
684                "conversation",
685                leviath_core::EntryKind::AssistantTurn {
686                    tool_calls: vec![leviath_core::SerializedToolCall {
687                        id: "call_9".to_string(),
688                        name: "shell".to_string(),
689                        arguments: serde_json::json!({"command": "ls"}),
690                        thought_signature: None,
691                    }],
692                },
693                "running ls".to_string(),
694                10,
695            )
696            .unwrap();
697        window
698            .add_typed_entry(
699                "conversation",
700                leviath_core::EntryKind::ToolResult {
701                    tool_call_id: "call_9".to_string(),
702                    tool_name: "shell".to_string(),
703                    is_error: false,
704                },
705                "file_a\nfile_b".to_string(),
706                10,
707            )
708            .unwrap();
709        window
710            .get_region_mut("conversation")
711            .unwrap()
712            .enable_taint_tracking();
713
714        // Swap 1: layout omits conversation (the infra-carry loop).
715        let omitting = ContextLayout::new(
716            vec![RegionDefinition::new(
717                "task".to_string(),
718                RegionKind::Pinned,
719                5000,
720            )],
721            8000,
722        );
723        apply_layout(&mut window, &omitting);
724
725        // Swap 2: layout declares conversation (the by-name carry loop).
726        let declaring = ContextLayout::new(
727            vec![
728                RegionDefinition::new("task".to_string(), RegionKind::Pinned, 5000),
729                RegionDefinition::new(
730                    "conversation".to_string(),
731                    RegionKind::SlidingWindow {
732                        max_items: 10,
733                        eviction_strategy: EvictionStrategy::PerItem,
734                    },
735                    10_000,
736                ),
737            ],
738            20_000,
739        );
740        apply_layout(&mut window, &declaring);
741
742        let conv = window.get_region("conversation").unwrap();
743        assert!(
744            conv.content.iter().any(|e| matches!(
745                &e.kind,
746                leviath_core::EntryKind::AssistantTurn { tool_calls }
747                    if tool_calls.iter().any(|c| c.id == "call_9")
748            )),
749            "assistant turn must keep its typed tool_calls through both carry paths"
750        );
751        assert!(
752            conv.content.iter().any(|e| matches!(
753                &e.kind,
754                leviath_core::EntryKind::ToolResult { tool_call_id, .. }
755                    if tool_call_id == "call_9"
756            )),
757            "tool result must keep its typed pairing through both carry paths"
758        );
759        assert!(
760            conv.taint.is_some(),
761            "region-level taint state must carry across layout swaps"
762        );
763    }
764
765    #[test]
766    fn apply_layout_carries_conversation_when_new_layout_omits_it() {
767        // A blueprint whose stage layout has NO conversation region. The auto-added
768        // conversation (with typed history) must survive the transition, else the
769        // next stage assembles with no messages.
770        let bp = blueprint_with(vec![RegionDefinition::new(
771            "task".to_string(),
772            RegionKind::Pinned,
773            5000,
774        )]);
775        let mut window = seeded_window(&bp, "the task");
776        window
777            .add_typed_entry(
778                "conversation",
779                leviath_core::EntryKind::UserMessage,
780                "hello from stage 0".to_string(),
781                10,
782            )
783            .unwrap();
784
785        // Transition to a layout that omits conversation entirely.
786        let next = ContextLayout::new(
787            vec![RegionDefinition::new(
788                "task".to_string(),
789                RegionKind::Pinned,
790                5000,
791            )],
792            8000,
793        );
794        apply_layout(&mut window, &next);
795
796        let conv = window
797            .get_region("conversation")
798            .expect("conversation carried across transition");
799        assert!(
800            conv.content
801                .iter()
802                .any(|e| e.content.contains("hello from stage 0")),
803            "carried conversation must retain its history"
804        );
805    }
806}