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 region = Region::new(
29            region_def.name.clone(),
30            region_def.kind.clone(),
31            region_def.max_tokens,
32        );
33        window.add_region(region);
34    }
35
36    if window.get_region("tool_results").is_none() {
37        let tool_region = Region::new("tool_results".to_string(), RegionKind::Temporary, 5000);
38        window.add_region(tool_region);
39    }
40
41    if window.get_region("conversation").is_none() {
42        let conv_region = Region::new(
43            "conversation".to_string(),
44            RegionKind::SlidingWindow {
45                max_items: 50,
46                eviction_strategy: EvictionStrategy::PerItem,
47            },
48            10000,
49        );
50        window.add_region(conv_region);
51    }
52
53    for (name, content) in seeds {
54        // The task key keeps its legacy fallback: prefer a region named "task",
55        // else the first pinned region. Every other key targets its region by
56        // exact name (unknown names are already rejected upstream, so ignore
57        // them here to keep this pure/infallible).
58        let target = if name == "task" {
59            task_region_name(blueprint)
60        } else {
61            blueprint
62                .context_layout
63                .regions
64                .iter()
65                .find(|r| &r.name == name)
66                .map(|r| r.name.clone())
67        };
68        if let Some(region_name) = target {
69            // Trim to the region's (already-resolved) budget first: `add_entry`
70            // REJECTS an over-budget entry outright rather than truncating it, so
71            // without this a seed larger than its region - a big README, a long
72            // `git ls-files` - would silently leave the region completely empty.
73            let budget = window
74                .get_region(&region_name)
75                .map(|r| r.max_tokens)
76                .unwrap_or(0);
77            let fitted = fit_seed_to_budget(content, budget);
78            let tokens = leviath_core::estimate_tokens(&fitted);
79            let _ = window.add_to_region(&region_name, fitted, tokens);
80        }
81    }
82}
83
84/// Marker appended to a seed that was trimmed to fit its region.
85const SEED_TRUNCATION_MARKER: &str =
86    "\n[...truncated by leviath: seed exceeded this region's budget]";
87
88/// Trim `content` so that its `len/4 + 1` token estimate fits `max_tokens`,
89/// leaving room for [`SEED_TRUNCATION_MARKER`]. Returns `content` unchanged when
90/// it already fits. Always cuts on a UTF-8 char boundary.
91fn fit_seed_to_budget(content: &str, max_tokens: usize) -> String {
92    // The token estimate used throughout: `len / 4 + 1`. Fitting means
93    // `len / 4 + 1 <= max_tokens`, i.e. `len <= (max_tokens - 1) * 4`.
94    let allowed = max_tokens.saturating_sub(1).saturating_mul(4);
95    if content.len() <= allowed {
96        return content.to_string();
97    }
98    // Reserve room for the marker; if even that doesn't fit, the region is too
99    // small to say anything useful, so emit nothing rather than a lone marker.
100    let Some(room) = allowed.checked_sub(SEED_TRUNCATION_MARKER.len()) else {
101        return String::new();
102    };
103    format!(
104        "{}{SEED_TRUNCATION_MARKER}",
105        truncate_at_boundary(content, room)
106    )
107}
108
109/// Resolve which region the `task` text seeds into: prefer a pinned region named
110/// `task`, else the first pinned region.
111fn task_region_name(blueprint: &Blueprint) -> Option<String> {
112    blueprint
113        .context_layout
114        .regions
115        .iter()
116        .find(|r| r.name == "task" && matches!(r.kind, RegionKind::Pinned))
117        .or_else(|| {
118            blueprint
119                .context_layout
120                .regions
121                .iter()
122                .find(|r| matches!(r.kind, RegionKind::Pinned))
123        })
124        .map(|r| r.name.clone())
125}
126
127/// Initialize a [`ContextWindow`] seeding only the task text - the thin
128/// back-compat wrapper over [`init_window_seeded`] used by callers that carry a
129/// single task string (the imperative engine and existing tests).
130pub fn init_window(window: &mut ContextWindow, blueprint: &Blueprint, task: &str) {
131    let seeds = HashMap::from([("task".to_string(), task.to_string())]);
132    init_window_seeded(window, blueprint, &seeds);
133}
134
135/// Swap a [`ContextWindow`] to a stage-specific layout in place, preserving each
136/// carried-over region's existing content by name. Pure over the window (no
137/// engine/entity), so both the imperative engine and the ECS pipeline's
138/// stage-entry can share it.
139pub fn apply_layout(window: &mut ContextWindow, layout: &ContextLayout) {
140    let mut new_regions = Vec::new();
141    let mut kept: std::collections::HashSet<&str> = std::collections::HashSet::new();
142    for region_def in &layout.regions {
143        let mut new_region = Region::new(
144            region_def.name.clone(),
145            region_def.kind.clone(),
146            region_def.max_tokens,
147        );
148
149        if let Some(existing) = window.get_region(&region_def.name) {
150            // Carry entries verbatim - kind, metadata, key, timestamp survive
151            // the swap. Rebuilding via `add_entry` flattened every carried
152            // entry to `EntryKind::Text`, which destroyed the typed tool_use/
153            // tool_result pairing of any message-bearing region and left the
154            // assembler's orphan sanitizer to strip the whole history.
155            for entry in &existing.content {
156                let _ = new_region.carry_entry(entry.clone());
157            }
158            // The region-level taint state carries wholesale too; the rebuild
159            // used to silently reset it.
160            new_region.taint = existing.taint.clone();
161        }
162
163        kept.insert(region_def.name.as_str());
164        new_regions.push(new_region);
165    }
166
167    // Carry the message-stream regions across the transition even when the new
168    // stage layout doesn't declare them. Dropping `conversation` (the sole
169    // SlidingWindow that holds typed turns) would strand the whole message history -
170    // the next stage would assemble with no messages, and its typed tool_use/
171    // tool_result turns would have nowhere to land. `tool_results` likewise. A
172    // blueprint that DOES declare them keeps its own budget (handled above).
173    for infra in ["conversation", "tool_results"] {
174        if !kept.contains(infra)
175            && let Some(existing) = window.get_region(infra)
176        {
177            let mut carried = Region::new(
178                existing.name.clone(),
179                existing.kind.clone(),
180                existing.max_tokens,
181            );
182            // Same verbatim carry as above: these are exactly the regions whose
183            // typed turns the transition must not flatten.
184            for entry in &existing.content {
185                let _ = carried.carry_entry(entry.clone());
186            }
187            carried.taint = existing.taint.clone();
188            new_regions.push(carried);
189        }
190    }
191
192    window.regions = new_regions;
193    window.current_tokens = window.calculate_tokens();
194}
195
196#[cfg(test)]
197mod tests {
198    use super::{
199        SEED_TRUNCATION_MARKER, apply_layout, fit_seed_to_budget, init_window, init_window_seeded,
200    };
201    use crate::ContextWindow;
202    use leviath_core::{
203        Blueprint, ContextLayout, EvictionStrategy, RegionKind, Stage, blueprint::ModelConfig,
204        layout::RegionDefinition,
205    };
206    use std::collections::HashMap;
207
208    fn blueprint_with(regions: Vec<RegionDefinition>) -> Blueprint {
209        let layout = ContextLayout::new(regions, 100_000);
210        let stages = vec![Stage::new(
211            "main".to_string(),
212            ModelConfig::new("anthropic".to_string(), "claude-sonnet-4".to_string()),
213        )];
214        Blueprint::new("bp".to_string(), "desc".to_string(), stages, layout)
215    }
216
217    fn seeded_window(bp: &Blueprint, task: &str) -> ContextWindow {
218        let mut window = ContextWindow::new(100_000);
219        init_window(&mut window, bp, task);
220        window
221    }
222
223    #[test]
224    fn init_window_seeded_fills_multiple_named_regions_and_ignores_unknown() {
225        let bp = blueprint_with(vec![
226            RegionDefinition::new("task".to_string(), RegionKind::Pinned, 5000),
227            RegionDefinition::new("criteria".to_string(), RegionKind::Pinned, 5000),
228        ]);
229        let seeds = HashMap::from([
230            ("task".to_string(), "build a parser".to_string()),
231            ("criteria".to_string(), "focus on safety".to_string()),
232            ("ghost".to_string(), "no such region".to_string()),
233        ]);
234        let mut window = ContextWindow::new(100_000);
235        init_window_seeded(&mut window, &bp, &seeds);
236
237        assert!(
238            window
239                .get_region("task")
240                .unwrap()
241                .content
242                .iter()
243                .any(|e| e.content.contains("build a parser"))
244        );
245        assert!(
246            window
247                .get_region("criteria")
248                .unwrap()
249                .content
250                .iter()
251                .any(|e| e.content.contains("focus on safety"))
252        );
253        // An unknown seed key targets no region and is silently dropped.
254        assert!(window.get_region("ghost").is_none());
255    }
256
257    #[test]
258    fn fit_seed_to_budget_leaves_a_fitting_seed_untouched() {
259        assert_eq!(fit_seed_to_budget("hello", 100), "hello");
260        // Exactly at the limit: len == (max_tokens - 1) * 4.
261        let exact = "x".repeat(36);
262        assert_eq!(fit_seed_to_budget(&exact, 10), exact);
263    }
264
265    /// The token estimate `init_window_seeded` computes for a fitted seed - the
266    /// number that has to land inside the region's budget.
267    fn estimated_tokens(fitted: &str) -> usize {
268        leviath_core::estimate_tokens(fitted)
269    }
270
271    #[test]
272    fn fit_seed_to_budget_truncates_and_marks_an_oversized_seed() {
273        let big = "x".repeat(10_000);
274        let fitted = fit_seed_to_budget(&big, 100);
275        assert!(fitted.ends_with(SEED_TRUNCATION_MARKER));
276        // The estimate the caller will compute must actually fit the budget.
277        let estimate = estimated_tokens(&fitted);
278        assert!(estimate <= 100, "estimate was {estimate}");
279    }
280
281    #[test]
282    fn fit_seed_to_budget_cuts_on_a_char_boundary() {
283        // Place a 2-byte char so it straddles the cut exactly: slicing there
284        // would panic, so the walk-back has to move off it.
285        const MAX_TOKENS: usize = 60;
286        let room = (MAX_TOKENS - 1) * 4 - SEED_TRUNCATION_MARKER.len();
287        let mut s = "a".repeat(room - 1);
288        s.push('é'); // occupies bytes room-1 and room - the cut lands inside it
289        s.push_str(&"b".repeat(500));
290        assert!(!s.is_char_boundary(room), "test must straddle the cut");
291
292        let fitted = fit_seed_to_budget(&s, MAX_TOKENS);
293        assert!(fitted.ends_with(SEED_TRUNCATION_MARKER));
294        assert!(estimated_tokens(&fitted) <= MAX_TOKENS);
295        // The straddling char was dropped whole rather than split.
296        assert_eq!(
297            fitted,
298            format!("{}{SEED_TRUNCATION_MARKER}", "a".repeat(room - 1))
299        );
300    }
301
302    #[test]
303    fn fit_seed_to_budget_yields_nothing_when_even_the_marker_cannot_fit() {
304        // A region too small to hold the marker gets nothing rather than a bare
305        // "[...truncated]" with no content.
306        assert_eq!(fit_seed_to_budget("some content here", 2), "");
307        // Degenerate budgets are handled by the saturating arithmetic.
308        assert_eq!(fit_seed_to_budget("x", 0), "");
309    }
310
311    #[test]
312    fn init_window_seeded_truncates_a_seed_larger_than_its_region() {
313        // Regression: `add_entry` rejects an over-budget entry outright, so a
314        // seed must be trimmed first - an untrimmed oversized seed leaves the
315        // region completely EMPTY.
316        let bp = blueprint_with(vec![RegionDefinition::new(
317            "facts".to_string(),
318            RegionKind::Pinned,
319            50,
320        )]);
321        let seeds = HashMap::from([("facts".to_string(), "y".repeat(10_000))]);
322        let mut window = ContextWindow::new(100_000);
323        init_window_seeded(&mut window, &bp, &seeds);
324
325        let region = window.get_region("facts").unwrap();
326        assert!(
327            !region.content.is_empty(),
328            "an oversized seed must be trimmed, not dropped"
329        );
330        assert!(region.content[0].content.ends_with(SEED_TRUNCATION_MARKER));
331    }
332
333    #[test]
334    fn init_window_seeded_task_key_falls_back_to_first_pinned() {
335        // No region literally named "task": the "task" seed key still lands in
336        // the first pinned region (legacy fallback), while a named key does not.
337        let bp = blueprint_with(vec![RegionDefinition::new(
338            "system".to_string(),
339            RegionKind::Pinned,
340            5000,
341        )]);
342        let seeds = HashMap::from([("task".to_string(), "fallback text".to_string())]);
343        let mut window = ContextWindow::new(100_000);
344        init_window_seeded(&mut window, &bp, &seeds);
345        assert!(
346            window
347                .get_region("system")
348                .unwrap()
349                .content
350                .iter()
351                .any(|e| e.content.contains("fallback text"))
352        );
353    }
354
355    #[test]
356    fn init_prefers_named_task_region_and_keeps_existing_infra_regions() {
357        let bp = blueprint_with(vec![
358            RegionDefinition::new("task".to_string(), RegionKind::Pinned, 5000),
359            RegionDefinition::new("tool_results".to_string(), RegionKind::Temporary, 5000),
360            RegionDefinition::new(
361                "conversation".to_string(),
362                RegionKind::SlidingWindow {
363                    max_items: 10,
364                    eviction_strategy: EvictionStrategy::PerItem,
365                },
366                10_000,
367            ),
368        ]);
369
370        let window = seeded_window(&bp, "do the thing");
371        // Task seeded into the explicitly-named "task" pinned region.
372        assert!(
373            window
374                .get_region("task")
375                .unwrap()
376                .content
377                .iter()
378                .any(|e| e.content.contains("do the thing"))
379        );
380        // Blueprint-declared tool_results / conversation are not duplicated.
381        assert_eq!(
382            window
383                .regions
384                .iter()
385                .filter(|r| r.name == "tool_results")
386                .count(),
387            1
388        );
389        assert_eq!(
390            window
391                .regions
392                .iter()
393                .filter(|r| r.name == "conversation")
394                .count(),
395            1
396        );
397    }
398
399    #[test]
400    fn init_adds_infra_regions_and_falls_back_to_first_pinned() {
401        // Only a pinned "system" region (not named "task"): task falls back to
402        // it, and tool_results + conversation are auto-added.
403        let bp = blueprint_with(vec![RegionDefinition::new(
404            "system".to_string(),
405            RegionKind::Pinned,
406            5000,
407        )]);
408
409        let window = seeded_window(&bp, "seed task");
410        assert!(window.get_region("tool_results").is_some());
411        assert!(window.get_region("conversation").is_some());
412        assert!(
413            window
414                .get_region("system")
415                .unwrap()
416                .content
417                .iter()
418                .any(|e| e.content.contains("seed task"))
419        );
420    }
421
422    #[test]
423    fn init_without_pinned_region_does_not_seed_task() {
424        let bp = blueprint_with(vec![RegionDefinition::new(
425            "scratch".to_string(),
426            RegionKind::Temporary,
427            5000,
428        )]);
429
430        let window = seeded_window(&bp, "unseeded task");
431        // No pinned region → task text is seeded nowhere; the sole declared
432        // region stays empty.
433        assert!(window.get_region("scratch").unwrap().content.is_empty());
434        // Infra regions still added.
435        assert!(window.get_region("tool_results").is_some());
436        assert!(window.get_region("conversation").is_some());
437    }
438
439    #[test]
440    fn init_task_named_region_that_is_not_pinned_falls_back_to_first_pinned() {
441        // A region literally named "task" but NOT pinned must be rejected by
442        // the `name == "task" && matches!(kind, Pinned)` guard, falling back to
443        // the first pinned region ("system").
444        let bp = blueprint_with(vec![
445            RegionDefinition::new("task".to_string(), RegionKind::Temporary, 5000),
446            RegionDefinition::new("system".to_string(), RegionKind::Pinned, 5000),
447        ]);
448
449        let window = seeded_window(&bp, "fallback seed");
450        // The non-pinned "task" region is left empty...
451        assert!(window.get_region("task").unwrap().content.is_empty());
452        // ...and the seed lands in the first pinned region instead.
453        assert!(
454            window
455                .get_region("system")
456                .unwrap()
457                .content
458                .iter()
459                .any(|e| e.content.contains("fallback seed"))
460        );
461    }
462
463    #[test]
464    fn apply_layout_preserves_overlapping_content_and_creates_new_regions() {
465        let bp = blueprint_with(vec![RegionDefinition::new(
466            "system".to_string(),
467            RegionKind::Pinned,
468            5000,
469        )]);
470        let mut window = seeded_window(&bp, "carried content");
471
472        // New layout keeps "system" (content should carry over) and adds a
473        // brand-new "scratch" region (no prior content → the None branch).
474        let new_layout = ContextLayout::new(
475            vec![
476                RegionDefinition::new("system".to_string(), RegionKind::Pinned, 5000),
477                RegionDefinition::new("scratch".to_string(), RegionKind::Temporary, 3000),
478            ],
479            8000,
480        );
481
482        apply_layout(&mut window, &new_layout);
483
484        // system + scratch from the new layout, PLUS the auto-added message-stream
485        // regions (conversation, tool_results) carried across the transition so the
486        // message history survives even though the new layout doesn't declare them.
487        assert_eq!(window.regions.len(), 4);
488        assert!(window.get_region("conversation").is_some());
489        assert!(window.get_region("tool_results").is_some());
490        assert!(
491            window
492                .get_region("system")
493                .unwrap()
494                .content
495                .iter()
496                .any(|e| e.content.contains("carried content"))
497        );
498        assert!(window.get_region("scratch").unwrap().content.is_empty());
499        // Token total recomputed from the surviving content.
500        assert_eq!(window.current_tokens, window.calculate_tokens());
501        assert!(window.current_tokens > 0);
502    }
503
504    #[test]
505    fn apply_layout_preserves_entry_kinds_and_taint_across_swap() {
506        // Regression: the carry used to rebuild entries via `add_entry`, which
507        // stamped every carried entry `EntryKind::Text` (destroying tool_use/
508        // tool_result pairing) and silently reset region-level taint.
509        let bp = blueprint_with(vec![RegionDefinition::new(
510            "task".to_string(),
511            RegionKind::Pinned,
512            5000,
513        )]);
514        let mut window = seeded_window(&bp, "the task");
515        window
516            .add_typed_entry(
517                "conversation",
518                leviath_core::EntryKind::AssistantTurn {
519                    tool_calls: vec![leviath_core::SerializedToolCall {
520                        id: "call_9".to_string(),
521                        name: "shell".to_string(),
522                        arguments: serde_json::json!({"command": "ls"}),
523                        thought_signature: None,
524                    }],
525                },
526                "running ls".to_string(),
527                10,
528            )
529            .unwrap();
530        window
531            .add_typed_entry(
532                "conversation",
533                leviath_core::EntryKind::ToolResult {
534                    tool_call_id: "call_9".to_string(),
535                    tool_name: "shell".to_string(),
536                    is_error: false,
537                },
538                "file_a\nfile_b".to_string(),
539                10,
540            )
541            .unwrap();
542        window
543            .get_region_mut("conversation")
544            .unwrap()
545            .enable_taint_tracking();
546
547        // Swap 1: layout omits conversation (the infra-carry loop).
548        let omitting = ContextLayout::new(
549            vec![RegionDefinition::new(
550                "task".to_string(),
551                RegionKind::Pinned,
552                5000,
553            )],
554            8000,
555        );
556        apply_layout(&mut window, &omitting);
557
558        // Swap 2: layout declares conversation (the by-name carry loop).
559        let declaring = ContextLayout::new(
560            vec![
561                RegionDefinition::new("task".to_string(), RegionKind::Pinned, 5000),
562                RegionDefinition::new(
563                    "conversation".to_string(),
564                    RegionKind::SlidingWindow {
565                        max_items: 10,
566                        eviction_strategy: EvictionStrategy::PerItem,
567                    },
568                    10_000,
569                ),
570            ],
571            20_000,
572        );
573        apply_layout(&mut window, &declaring);
574
575        let conv = window.get_region("conversation").unwrap();
576        assert!(
577            conv.content.iter().any(|e| matches!(
578                &e.kind,
579                leviath_core::EntryKind::AssistantTurn { tool_calls }
580                    if tool_calls.iter().any(|c| c.id == "call_9")
581            )),
582            "assistant turn must keep its typed tool_calls through both carry paths"
583        );
584        assert!(
585            conv.content.iter().any(|e| matches!(
586                &e.kind,
587                leviath_core::EntryKind::ToolResult { tool_call_id, .. }
588                    if tool_call_id == "call_9"
589            )),
590            "tool result must keep its typed pairing through both carry paths"
591        );
592        assert!(
593            conv.taint.is_some(),
594            "region-level taint state must carry across layout swaps"
595        );
596    }
597
598    #[test]
599    fn apply_layout_carries_conversation_when_new_layout_omits_it() {
600        // A blueprint whose stage layout has NO conversation region. The auto-added
601        // conversation (with typed history) must survive the transition, else the
602        // next stage assembles with no messages.
603        let bp = blueprint_with(vec![RegionDefinition::new(
604            "task".to_string(),
605            RegionKind::Pinned,
606            5000,
607        )]);
608        let mut window = seeded_window(&bp, "the task");
609        window
610            .add_typed_entry(
611                "conversation",
612                leviath_core::EntryKind::UserMessage,
613                "hello from stage 0".to_string(),
614                10,
615            )
616            .unwrap();
617
618        // Transition to a layout that omits conversation entirely.
619        let next = ContextLayout::new(
620            vec![RegionDefinition::new(
621                "task".to_string(),
622                RegionKind::Pinned,
623                5000,
624            )],
625            8000,
626        );
627        apply_layout(&mut window, &next);
628
629        let conv = window
630            .get_region("conversation")
631            .expect("conversation carried across transition");
632        assert!(
633            conv.content
634                .iter()
635                .any(|e| e.content.contains("hello from stage 0")),
636            "carried conversation must retain its history"
637        );
638    }
639}