Skip to main content

leviath_runtime/components/
context_window.rs

1//! The agent's context window: what it remembers, and what it forgets first.
2//!
3//! Every region an agent holds lives here, along with the assembly that turns
4//! them into a request and the eviction that keeps them inside a budget. Split
5//! out of `components` because it was two thirds of that file on its own, and
6//! because "what an agent *is*" and "what an agent *remembers*" are different
7//! questions to arrive with.
8
9use super::*;
10
11/// Result of an eviction attempt, including tokens freed and regions needing LLM compaction.
12#[derive(Debug, Clone)]
13pub struct EvictionResult {
14    /// Number of tokens freed by eviction phases 1-2 (Clearable + Temporary).
15    pub tokens_freed: usize,
16    /// Region names that need LLM-based compaction (phase 3).
17    pub needs_compaction: Vec<String>,
18}
19
20/// Per-stage inference configuration overrides.
21///
22/// Set on the agent entity before each stage to override default inference
23/// parameters like temperature and max output tokens. When absent, defaults
24/// are used (temperature 0.7, max output 4096).
25#[derive(Component, Debug, Clone, Default)]
26pub struct InferenceConfig {
27    /// Temperature override. If None, uses 0.7 (or 0.0 if model doesn't support it).
28    pub temperature: Option<f32>,
29    /// Max output tokens override. If None, caps at model's max_output_tokens capability.
30    pub max_output_tokens: Option<usize>,
31    /// Extra provider parameters from `[stages.<name>.model.parameters]` beyond
32    /// `temperature`/`max_output_tokens` (e.g. `top_p`, `stop`, `seed`,
33    /// `frequency_penalty`). Passed through to the provider request so models can
34    /// be tuned from the manifest. Empty when none are set.
35    pub extra_params: serde_json::Map<String, serde_json::Value>,
36    /// Whether to prepend the batch-tool-calls hint to this stage's system
37    /// prompt. Resolved from the global config → agent → stage cascade at spawn
38    /// (see [`leviath_core::taint::resolve_batch_tool_hint`]); `false` by default
39    /// so an unset config is a no-op.
40    pub batch_tool_hint: bool,
41    /// Whether this stage is eligible for the platform shell hint. Resolved from
42    /// the global config → agent → stage cascade at spawn (see
43    /// [`leviath_core::taint::resolve_shell_hint`]); `false` by default so an
44    /// unset config is a no-op. Eligibility is not emission: the hint also needs
45    /// a platform worth describing and a stage that advertises the shell tool.
46    pub shell_hint: bool,
47    /// Per-stage cap on the wall-clock time (in seconds) one inference for this
48    /// stage may run (the whole call including retries). Sourced from
49    /// `[stages.<name>.model] request_timeout_secs`. When `Some`, it overrides the
50    /// default inference job timeout at dispatch; when `None`, the default applies.
51    pub request_timeout_secs: Option<u64>,
52}
53
54/// Per-entity tool result routing configuration.
55///
56/// When present on an entity, tool results are routed to the specified region(s)
57/// instead of the default "conversation" region.
58#[derive(Component, Debug, Clone)]
59pub struct ToolResultRoutingComponent {
60    /// The routing configuration.
61    pub routing: leviath_core::ToolResultRouting,
62}
63
64/// Result of assembling a context window into system blocks and conversation messages.
65///
66/// Produced by [`ContextWindow::assemble()`]. System-bound regions (Pinned,
67/// CompactHistory, etc.) become `system_blocks`; the messages region
68/// (SlidingWindow) becomes typed `messages`.
69#[derive(Debug, Clone)]
70pub struct AssembledContext {
71    /// System prompt blocks (from Pinned, CompactHistory, etc. regions).
72    pub system_blocks: Vec<leviath_providers::SystemBlock>,
73    /// Conversation messages with proper role typing.
74    pub messages: Vec<leviath_providers::Message>,
75}
76
77/// Sort priority for a system block's cache hint.
78///
79/// Anthropic caches system content by prefix matching, so the most stable
80/// blocks must sort first to form the cacheable prefix. Lower value = earlier.
81pub(super) fn cache_hint_sort_priority(hint: leviath_core::CacheHint) -> u8 {
82    use leviath_core::CacheHint;
83    match hint {
84        CacheHint::Always => 0,               // Pinned, CompactHistory - most stable
85        CacheHint::SlidingPrefix { .. } => 1, // Partially stable
86        CacheHint::UntilChanged => 2,         // Compacting - changes on compaction
87        // Same tier as UntilChanged on purpose: the hint marks where a cache
88        // breakpoint belongs, never where a block belongs in the prompt.
89        CacheHint::RecentlyChanged => 2,
90        CacheHint::Never => 3, // Temporary, Clearable - changes every iteration
91    }
92}
93
94/// The most cache breakpoints assembly will let the system blocks claim.
95///
96/// Anthropic allows four `cache_control` blocks across the whole request and
97/// the provider hands the system blocks first claim on that budget, so leaving
98/// one run unclaimed is what keeps a breakpoint available for the messages.
99const MAX_SYSTEM_CACHE_RUNS: usize = 3;
100
101/// Split the volatile tier of the system prompt at its most recently changed
102/// block, by retagging that block and every block after it as
103/// [`leviath_core::CacheHint::RecentlyChanged`].
104///
105/// Providers place one cache breakpoint per run of same-hint blocks, so with a
106/// single `UntilChanged` run the only breakpoint sits at the end of the tier.
107/// A block mutating in the middle of that run therefore invalidates the whole
108/// run, and every block after the mutation is re-sent as a cache write. Adding
109/// a boundary just before the changed block gives the unchanged head of the
110/// tier a cache entry of its own. Only the breakpoint metadata moves: block
111/// order and block text are both left exactly as the sort left them, and this
112/// runs after the sort so it cannot influence ordering at all. The effect on
113/// cache-write volume is not measurable inside this repository.
114///
115/// `recency` carries the newest entry timestamp of the region behind each
116/// `UntilChanged` block, in the order those blocks were assembled. The sort is
117/// stable and every block in the tier shares one sort priority, so that order
118/// is also the order the blocks appear in now.
119///
120/// Nothing is retagged when the newest block is already the first one (there is
121/// no stable head to protect), or when the blocks already fill the run budget.
122fn mark_recently_changed_run(blocks: &mut [leviath_providers::SystemBlock], recency: &[i64]) {
123    use leviath_core::CacheHint;
124
125    let mut volatile: Vec<usize> = Vec::new();
126    for (index, block) in blocks.iter().enumerate() {
127        if block.cache_hint == CacheHint::UntilChanged {
128            volatile.push(index);
129        }
130    }
131
132    // The first block carrying the newest timestamp. Ties resolve to the
133    // earliest block, which is the conservative choice: when two regions were
134    // written in the same second, both of them changed, and the boundary
135    // belongs ahead of the earlier one.
136    let mut boundary = 0usize;
137    let mut newest = i64::MIN;
138    for (position, &stamp) in recency.iter().enumerate() {
139        if stamp > newest {
140            newest = stamp;
141            boundary = position;
142        }
143    }
144    if boundary == 0 {
145        return;
146    }
147
148    // Count the breakpoints the provider would place today. Splitting the
149    // volatile tier adds exactly one, so refusing at the limit is what keeps
150    // the messages breakpoint from being squeezed out.
151    let mut runs = 0usize;
152    for index in 0..blocks.len() {
153        let hint = blocks[index].cache_hint;
154        if hint != CacheHint::Never && blocks.get(index + 1).map(|b| b.cache_hint) != Some(hint) {
155            runs += 1;
156        }
157    }
158    if runs >= MAX_SYSTEM_CACHE_RUNS {
159        return;
160    }
161
162    for &index in volatile.iter().skip(boundary) {
163        blocks[index].cache_hint = CacheHint::RecentlyChanged;
164    }
165}
166
167/// Context window component storing the agent's memory regions.
168#[derive(Component, Debug, Clone)]
169pub struct ContextWindow {
170    /// All regions in this context window
171    pub regions: Vec<Region>,
172
173    /// Current total token usage
174    pub current_tokens: usize,
175
176    /// Maximum token budget
177    pub max_tokens: usize,
178
179    /// Compiled custom-region scripts, keyed by the script path each
180    /// `RegionKind::Custom` carries. Populated once at spawn by the CLI
181    /// (which resolves blueprint-dir-relative paths and compile-checks the
182    /// files); a stage-layout swap rebuilds `regions` but leaves this table
183    /// untouched, so per-stage custom regions keep working. Empty when no
184    /// custom regions exist - every hook lookup then misses and the region
185    /// renders its fallback shape.
186    pub region_scripts: std::collections::HashMap<
187        String,
188        std::sync::Arc<leviath_scripting::region_hook::RegionScript>,
189    >,
190
191    /// Regions the current stage does not attend to.
192    ///
193    /// Held, not deleted. A stage layout that omits a region used to have it
194    /// dropped from the window entirely, so re-declaring it in a later stage
195    /// brought it back empty - which made the feature unusable for the thing it
196    /// looks designed for, narrowing what one stage sees in a pipeline whose
197    /// later stages still need the data. Omission now means "not assembled for
198    /// this stage" and nothing else.
199    ///
200    /// Reset on every stage entry by [`crate::context_setup::apply_layout`], so
201    /// it describes the stage in front of it rather than accumulating.
202    pub hidden: std::collections::HashSet<String>,
203}
204
205impl ContextWindow {
206    /// Create a new context window with the specified budget.
207    pub fn new(max_tokens: usize) -> Self {
208        Self {
209            regions: Vec::new(),
210            hidden: std::collections::HashSet::new(),
211            current_tokens: 0,
212            max_tokens,
213            region_scripts: std::collections::HashMap::new(),
214        }
215    }
216
217    /// The compiled script backing `region_name`, when it is a custom region
218    /// whose script path has an entry in [`Self::region_scripts`].
219    fn custom_script_for(
220        &self,
221        region_name: &str,
222    ) -> Option<std::sync::Arc<leviath_scripting::region_hook::RegionScript>> {
223        let region = self.get_region(region_name)?;
224        let leviath_core::RegionKind::Custom { script, .. } = &region.kind else {
225            return None;
226        };
227        self.region_scripts.get(script).cloned()
228    }
229
230    /// Run a custom region's `on_write` hook (when defined) for an incoming
231    /// entry. `None` means the script dropped the entry - the write reports
232    /// success without storing anything. Non-custom regions, missing scripts,
233    /// and hook failures all accept the entry unchanged.
234    ///
235    /// Deliberately NOT invoked by the layout-swap carry or restore overlay:
236    /// those re-add entries the hook already accepted once.
237    fn on_write_outcome(
238        &self,
239        region_name: &str,
240        content: String,
241        tokens: usize,
242        kind: &leviath_core::EntryKind,
243    ) -> Option<(String, usize)> {
244        let Some(script) = self.custom_script_for(region_name) else {
245            return Some((content, tokens));
246        };
247        if !script.has_on_write() {
248            return Some((content, tokens));
249        }
250        // The region exists - custom_script_for resolved through it.
251        let region = self
252            .get_region(region_name)
253            .expect("custom_script_for resolved through this region");
254        match crate::custom_region::apply_on_write(&script, region, content, tokens, kind) {
255            crate::custom_region::OnWriteOutcome::Accept(content, tokens) => {
256                Some((content, tokens))
257            }
258            crate::custom_region::OnWriteOutcome::Drop => None,
259        }
260    }
261
262    /// Retry hook for a custom-region write that hit `TokenBudgetExceeded`:
263    /// let the script's `on_overflow` free room, then report whether a single
264    /// retry is worthwhile. Non-custom regions and hook failures leave the
265    /// original error standing (the callers' existing truncation ladders
266    /// apply).
267    fn try_custom_overflow(&mut self, region_name: &str, incoming_tokens: usize) -> bool {
268        let Some(script) = self.custom_script_for(region_name) else {
269            return false;
270        };
271        if !script.has_on_overflow() {
272            return false;
273        }
274        let region = self
275            .get_region_mut(region_name)
276            .expect("custom_script_for resolved through this region");
277        let needed = (region.current_tokens + incoming_tokens).saturating_sub(region.max_tokens);
278        let freed = crate::custom_region::apply_overflow(&script, region, needed);
279        self.current_tokens = self.calculate_tokens();
280        freed >= needed && needed > 0
281    }
282
283    /// Get a region by name.
284    pub fn get_region(&self, name: &str) -> Option<&Region> {
285        self.regions.iter().find(|r| r.name == name)
286    }
287
288    /// Get a mutable reference to a region by name.
289    pub fn get_region_mut(&mut self, name: &str) -> Option<&mut Region> {
290        self.regions.iter_mut().find(|r| r.name == name)
291    }
292
293    /// Add a region to this context window.
294    pub fn add_region(&mut self, region: Region) {
295        self.regions.push(region);
296        self.current_tokens = self.calculate_tokens();
297    }
298
299    /// Add content to a specific region.
300    pub fn add_to_region(
301        &mut self,
302        region_name: &str,
303        content: String,
304        tokens: usize,
305    ) -> leviath_core::Result<()> {
306        let Some((content, tokens)) =
307            self.on_write_outcome(region_name, content, tokens, &leviath_core::EntryKind::Text)
308        else {
309            return Ok(()); // the region's script dropped the entry
310        };
311        self.write_to_region(region_name, tokens, &mut |region, tokens| {
312            region.add_entry(content.clone(), tokens)
313        })
314    }
315
316    /// Replace a region's entire content with a single entry (clear, then add).
317    /// Returns `false` (no-op) if the region does not exist. Used to keep an
318    /// authoritative document region (e.g. the plan) holding only its current
319    /// version, so revisions build on it instead of accumulating stale copies.
320    pub fn replace_region(&mut self, region_name: &str, content: String, tokens: usize) -> bool {
321        // The replacement passes through on_write like any incoming entry - a
322        // custom region's script sees (and may transform or refuse) it.
323        let Some((content, tokens)) =
324            self.on_write_outcome(region_name, content, tokens, &leviath_core::EntryKind::Text)
325        else {
326            // Dropped by the script: the region keeps its current content.
327            return self.get_region(region_name).is_some();
328        };
329        if let Some(region) = self.get_region_mut(region_name) {
330            region.clear();
331            let _ = region.add_entry(content, tokens);
332            self.current_tokens = self.calculate_tokens();
333            true
334        } else {
335            false
336        }
337    }
338
339    /// Add a typed entry to a specific region.
340    ///
341    /// Like [`add_to_region`](Self::add_to_region) but the entry carries an
342    /// `EntryKind` so message roles are determined by type, not text-prefix
343    /// parsing.
344    pub fn add_typed_entry(
345        &mut self,
346        region_name: &str,
347        kind: leviath_core::EntryKind,
348        content: String,
349        tokens: usize,
350    ) -> leviath_core::Result<()> {
351        let Some((content, tokens)) = self.on_write_outcome(region_name, content, tokens, &kind)
352        else {
353            return Ok(());
354        };
355        self.write_to_region(region_name, tokens, &mut |region, tokens| {
356            region.add_typed_entry(content.clone(), tokens, kind.clone())
357        })
358    }
359
360    /// Shared tail of every region write: run the insert, give a custom
361    /// region's `on_overflow` one shot at freeing room when the budget
362    /// rejects it, and recount the window. A `&mut dyn FnMut` (not generic)
363    /// keeps one instantiation for the coverage gate.
364    fn write_to_region(
365        &mut self,
366        region_name: &str,
367        tokens: usize,
368        insert: &mut dyn FnMut(&mut Region, usize) -> leviath_core::Result<()>,
369    ) -> leviath_core::Result<()> {
370        if self.get_region(region_name).is_none() {
371            return Err(leviath_core::Error::RegionNotFound(region_name.to_string()));
372        }
373        let first = {
374            let region = self.get_region_mut(region_name).expect("checked above");
375            insert(region, tokens)
376        };
377        match first {
378            Ok(()) => {
379                self.current_tokens = self.calculate_tokens();
380                Ok(())
381            }
382            Err(leviath_core::Error::TokenBudgetExceeded { .. })
383                if self.try_custom_overflow(region_name, tokens) =>
384            {
385                let region = self.get_region_mut(region_name).expect("checked above");
386                let retried = insert(region, tokens);
387                self.current_tokens = self.calculate_tokens();
388                retried
389            }
390            Err(e) => Err(e),
391        }
392    }
393
394    /// Calculate current token usage across all regions.
395    pub fn calculate_tokens(&self) -> usize {
396        self.regions.iter().map(|r| r.current_tokens).sum()
397    }
398
399    /// Check if the context window needs eviction.
400    pub fn needs_eviction(&self, threshold: f32) -> bool {
401        let usage_ratio = self.current_tokens as f32 / self.max_tokens as f32;
402        usage_ratio >= threshold
403    }
404
405    /// Execute eviction cascade to free up space.
406    ///
407    /// Returns an `EvictionResult` with tokens freed and any regions that need
408    /// LLM-based compaction. The caller is responsible for performing compaction
409    /// on the listed regions (since it requires async LLM access).
410    pub fn try_evict(&mut self, target_free_tokens: usize) -> leviath_core::Result<EvictionResult> {
411        use leviath_core::RegionKind;
412
413        let initial_tokens = self.current_tokens;
414
415        // Check if we have any evictable regions
416        let has_evictable = self.regions.iter().any(|r| {
417            matches!(
418                r.kind,
419                RegionKind::Clearable
420                    | RegionKind::Temporary
421                    | RegionKind::Custom {
422                        persistent: false,
423                        ..
424                    }
425            )
426        });
427
428        if !has_evictable {
429            tracing::warn!(
430                "Context window has no Clearable or Temporary regions. \
431                 This may be intentional, but usually indicates a configuration error."
432            );
433        }
434
435        // Phase 1: Clear Clearable regions (all-or-nothing)
436        for region in &mut self.regions {
437            if matches!(region.kind, RegionKind::Clearable) && !region.content.is_empty() {
438                let freed = region.current_tokens;
439                region.clear();
440                self.current_tokens -= freed;
441                tracing::debug!(
442                    region = %region.name,
443                    tokens_freed = freed,
444                    "Cleared Clearable region (all-or-nothing)"
445                );
446
447                if self.max_tokens.saturating_sub(self.current_tokens) >= target_free_tokens {
448                    return Ok(EvictionResult {
449                        tokens_freed: initial_tokens - self.current_tokens,
450                        needs_compaction: Vec::new(),
451                    });
452                }
453            }
454        }
455
456        // Phase 1.5: Give each non-persistent custom region's on_overflow
457        // hook first say over what IT loses, before the indiscriminate
458        // oldest-first cascade below. A script that keeps errors and drops
459        // successes only works if it runs before oldest-first does. Hook
460        // absent/failing/insufficient → phase 2 makes the guaranteed
461        // progress.
462        let mut custom_freed = 0usize;
463        for i in 0..self.regions.len() {
464            let needed = target_free_tokens
465                .saturating_sub(self.max_tokens.saturating_sub(self.current_tokens));
466            if needed == 0 {
467                break;
468            }
469            let region = &self.regions[i];
470            if !matches!(
471                region.kind,
472                RegionKind::Custom {
473                    persistent: false,
474                    ..
475                }
476            ) || region.content.is_empty()
477            {
478                continue;
479            }
480            let Some(script) = self.custom_script_for(&region.name.clone()) else {
481                continue;
482            };
483            if !script.has_on_overflow() {
484                continue;
485            }
486            let freed = crate::custom_region::apply_overflow(&script, &mut self.regions[i], needed);
487            self.current_tokens = self.current_tokens.saturating_sub(freed);
488            custom_freed += freed;
489            if freed > 0 {
490                tracing::debug!(
491                    region = %self.regions[i].name,
492                    tokens_freed = freed,
493                    "custom region's on_overflow chose its own evictions"
494                );
495            }
496        }
497        // Return early ONLY when a script's own drops satisfied the target -
498        // otherwise phase 2 would immediately evict one more entry (it checks
499        // the target *after* each eviction), overriding the script's
500        // retention choice. Windows with no custom drops (custom_freed == 0)
501        // fall through with phase 2's pre-existing behavior, byte-identical.
502        if custom_freed > 0
503            && self.max_tokens.saturating_sub(self.current_tokens) >= target_free_tokens
504        {
505            return Ok(EvictionResult {
506                tokens_freed: initial_tokens - self.current_tokens,
507                needs_compaction: Vec::new(),
508            });
509        }
510
511        // Phase 2: Evict from Temporary regions (oldest first, one at a time).
512        // Non-persistent Custom regions join this phase: their script's
513        // on_overflow hook (when present) has already had its say in phase
514        // 1.5; oldest-first is the guaranteed-progress fallback.
515        loop {
516            let mut evicted_any = false;
517
518            for region in &mut self.regions {
519                if matches!(
520                    region.kind,
521                    RegionKind::Temporary
522                        | RegionKind::Custom {
523                            persistent: false,
524                            ..
525                        }
526                ) && let Some(entry) = region.remove_oldest()
527                {
528                    let freed = entry.tokens;
529                    self.current_tokens -= freed;
530                    evicted_any = true;
531
532                    tracing::debug!(
533                        region = %region.name,
534                        tokens_freed = freed,
535                        "Evicted temporary region entry (oldest first)"
536                    );
537
538                    if self.max_tokens.saturating_sub(self.current_tokens) >= target_free_tokens {
539                        return Ok(EvictionResult {
540                            tokens_freed: initial_tokens - self.current_tokens,
541                            needs_compaction: Vec::new(),
542                        });
543                    }
544                }
545            }
546
547            if !evicted_any {
548                break;
549            }
550        }
551
552        // Phase 3: If still need space, identify Compacting regions that need compaction
553        let mut needs_compaction = Vec::new();
554        if self.max_tokens.saturating_sub(self.current_tokens) < target_free_tokens {
555            for region in &self.regions {
556                if region.needs_compaction() {
557                    needs_compaction.push(region.name.clone());
558                }
559            }
560        }
561
562        // Phase 4: SlidingWindow regions are NEVER reduced
563        // Phase 5: Pinned and CompactHistory regions are NEVER touched
564
565        // Check for pinned regions over budget
566        let pinned_tokens: usize = self
567            .regions
568            .iter()
569            .filter(|r| {
570                matches!(
571                    r.kind,
572                    RegionKind::Pinned
573                        | RegionKind::CompactHistory { .. }
574                        | RegionKind::Custom {
575                            persistent: true,
576                            ..
577                        }
578                )
579            })
580            .map(|r| r.current_tokens)
581            .sum();
582
583        if pinned_tokens > self.max_tokens {
584            return Err(leviath_core::Error::PinnedRegionsOverBudget {
585                pinned_tokens,
586                total_budget: self.max_tokens,
587            });
588        }
589
590        Ok(EvictionResult {
591            tokens_freed: initial_tokens - self.current_tokens,
592            needs_compaction,
593        })
594    }
595
596    /// Result of assembling the context window into system blocks + messages.
597    ///
598    /// System-bound regions become `system_blocks`; the messages region
599    /// becomes `messages` with proper typed entries (no text-prefix parsing).
600    ///
601    /// Thin wrapper over [`assemble_with_meta`](Self::assemble_with_meta) with
602    /// no stage metadata - custom-region scripts see empty stage fields.
603    pub fn assemble(&self) -> AssembledContext {
604        self.assemble_with_meta(&crate::custom_region::AssembleMeta::default())
605    }
606
607    /// [`assemble`](Self::assemble) with stage metadata for custom-region
608    /// `render(ctx)` hooks (stage name, per-stage iteration count, model).
609    /// The inference path (`build_request`) threads real values; other
610    /// callers use the default.
611    pub fn assemble_with_meta(
612        &self,
613        meta: &crate::custom_region::AssembleMeta,
614    ) -> AssembledContext {
615        use leviath_core::{CacheHint, EntryKind};
616
617        let mut system_blocks = Vec::new();
618        let mut messages: Vec<leviath_providers::Message> = Vec::new();
619        // One entry per `UntilChanged` system block, in assembly order, holding
620        // the newest entry timestamp of the region that produced it. Feeds the
621        // cache-breakpoint split after the sort.
622        let mut volatile_recency: Vec<i64> = Vec::new();
623
624        for region in &self.regions {
625            // A region this stage does not attend to is held but not shown.
626            // Skipped here rather than dropped from the window, so a later
627            // stage that declares it again gets its contents back.
628            if self.hidden.contains(&region.name) {
629                continue;
630            }
631            // Custom regions render even when empty - a script may emit
632            // static scaffolding. Every other kind skips an empty region.
633            let is_custom = matches!(region.kind, leviath_core::RegionKind::Custom { .. });
634            if region.content.is_empty() && !is_custom {
635                continue;
636            }
637
638            // Where this region's system blocks begin, so the recency mapping
639            // below covers exactly the blocks this region adds.
640            let first_new_block = system_blocks.len();
641
642            match &region.kind {
643                // System-level content → system blocks
644                leviath_core::RegionKind::Pinned => {
645                    let text = region
646                        .content
647                        .iter()
648                        .map(|e| e.content.as_str())
649                        .collect::<Vec<_>>()
650                        .join("\n\n");
651                    system_blocks.push(leviath_providers::SystemBlock {
652                        text,
653                        cache_hint: CacheHint::Always,
654                    });
655                }
656                // A checklist renders as instruction rather than history: one
657                // stable block, open items first, so what is left to do is at
658                // the top of what the model reads every turn. The whole value
659                // of the state being real is that this block is derived from
660                // it rather than from whatever prose the model last wrote.
661                leviath_core::RegionKind::Checklist => {
662                    let text = region.render_checklist();
663                    if !text.is_empty() {
664                        system_blocks.push(leviath_providers::SystemBlock {
665                            text,
666                            cache_hint: CacheHint::UntilChanged,
667                        });
668                    }
669                }
670                leviath_core::RegionKind::CompactHistory { .. } => {
671                    let text = region
672                        .content
673                        .iter()
674                        .map(|e| e.content.as_str())
675                        .collect::<Vec<_>>()
676                        .join("\n\n");
677                    system_blocks.push(leviath_providers::SystemBlock {
678                        text,
679                        cache_hint: CacheHint::Always,
680                    });
681                }
682
683                // Messages region → Vec<Message> with proper typed entries.
684                // Consecutive ToolResult entries are merged into a single user
685                // message with multiple tool_result content blocks (required by
686                // Anthropic: one assistant tool_use msg → one user tool_result msg).
687                leviath_core::RegionKind::SlidingWindow { .. } => {
688                    let mut pending_tool_results: Vec<leviath_providers::ContentBlock> = Vec::new();
689
690                    for entry in &region.content {
691                        // Flush any pending tool results when we hit a non-ToolResult entry
692                        if !matches!(entry.kind, EntryKind::ToolResult { .. })
693                            && !pending_tool_results.is_empty()
694                        {
695                            messages.push(leviath_providers::Message {
696                                role: "user".to_string(),
697                                content: leviath_providers::MessageContent::Blocks(std::mem::take(
698                                    &mut pending_tool_results,
699                                )),
700                                cache_breakpoint: false,
701                            });
702                        }
703
704                        match &entry.kind {
705                            EntryKind::UserMessage => {
706                                messages.push(leviath_providers::Message {
707                                    role: "user".to_string(),
708                                    content: entry.content.clone().into(),
709                                    cache_breakpoint: false,
710                                });
711                            }
712                            EntryKind::AssistantTurn { tool_calls } => {
713                                if tool_calls.is_empty() {
714                                    messages.push(leviath_providers::Message {
715                                        role: "assistant".to_string(),
716                                        content: entry.content.clone().into(),
717                                        cache_breakpoint: false,
718                                    });
719                                } else {
720                                    let mut blocks = Vec::new();
721                                    if !entry.content.is_empty() {
722                                        blocks.push(leviath_providers::ContentBlock::Text {
723                                            text: entry.content.clone(),
724                                        });
725                                    }
726                                    for tc in tool_calls {
727                                        blocks.push(leviath_providers::ContentBlock::ToolUse {
728                                            id: tc.id.clone(),
729                                            name: tc.name.clone(),
730                                            input: tc.arguments.clone(),
731                                            thought_signature: tc.thought_signature.clone(),
732                                        });
733                                    }
734                                    messages.push(leviath_providers::Message {
735                                        role: "assistant".to_string(),
736                                        content: leviath_providers::MessageContent::Blocks(blocks),
737                                        cache_breakpoint: false,
738                                    });
739                                }
740                            }
741                            EntryKind::ToolResult {
742                                tool_call_id,
743                                is_error,
744                                ..
745                            } => {
746                                // Accumulate - will be flushed on next non-ToolResult or end
747                                pending_tool_results.push(
748                                    leviath_providers::ContentBlock::ToolResult {
749                                        tool_use_id: tool_call_id.clone(),
750                                        content: entry.content.clone(),
751                                        is_error: *is_error,
752                                    },
753                                );
754                            }
755                            EntryKind::Text => {
756                                let trimmed = entry.content.trim();
757                                if let Some(rest) = trimmed.strip_prefix("Assistant: ") {
758                                    messages.push(leviath_providers::Message {
759                                        role: "assistant".to_string(),
760                                        content: rest.to_string().into(),
761                                        cache_breakpoint: false,
762                                    });
763                                } else if let Some(rest) = trimmed.strip_prefix("User: ") {
764                                    messages.push(leviath_providers::Message {
765                                        role: "user".to_string(),
766                                        content: rest.to_string().into(),
767                                        cache_breakpoint: false,
768                                    });
769                                } else {
770                                    messages.push(leviath_providers::Message {
771                                        role: "user".to_string(),
772                                        content: entry.content.clone().into(),
773                                        cache_breakpoint: false,
774                                    });
775                                }
776                            }
777                        }
778                    }
779
780                    // Flush any remaining tool results at the end of the region
781                    if !pending_tool_results.is_empty() {
782                        messages.push(leviath_providers::Message {
783                            role: "user".to_string(),
784                            content: leviath_providers::MessageContent::Blocks(std::mem::take(
785                                &mut pending_tool_results,
786                            )),
787                            cache_breakpoint: false,
788                        });
789                    }
790                }
791
792                // Compacting / Temporary / Clearable → system blocks
793                leviath_core::RegionKind::Compacting { .. } => {
794                    let text = region
795                        .content
796                        .iter()
797                        .map(|e| e.content.as_str())
798                        .collect::<Vec<_>>()
799                        .join("\n\n");
800                    system_blocks.push(leviath_providers::SystemBlock {
801                        text: format!("[{}]:\n{}", region.name, text),
802                        cache_hint: CacheHint::UntilChanged,
803                    });
804                }
805                leviath_core::RegionKind::Temporary => {
806                    let text = region
807                        .content
808                        .iter()
809                        .map(|e| e.content.as_str())
810                        .collect::<Vec<_>>()
811                        .join("\n\n");
812                    system_blocks.push(leviath_providers::SystemBlock {
813                        text: format!("[{}]:\n{}", region.name, text),
814                        cache_hint: CacheHint::Never,
815                    });
816                }
817                leviath_core::RegionKind::Clearable => {
818                    let text = region
819                        .content
820                        .iter()
821                        .map(|e| e.content.as_str())
822                        .collect::<Vec<_>>()
823                        .join("\n\n");
824                    system_blocks.push(leviath_providers::SystemBlock {
825                        text: format!("[{}]:\n{}", region.name, text),
826                        cache_hint: CacheHint::Never,
827                    });
828                }
829
830                // Custom (script-backed) regions render through their Rhai
831                // hook; a missing script or any hook failure falls back to
832                // the Temporary-style block inside `render_custom_region`,
833                // so a custom region is never silently dropped.
834                leviath_core::RegionKind::Custom { script, persistent } => {
835                    crate::custom_region::render_custom_region(
836                        crate::custom_region::RegionRender {
837                            region,
838                            script: self.region_scripts.get(script),
839                            persistent: *persistent,
840                            meta,
841                            window_current: self.current_tokens,
842                            window_max: self.max_tokens,
843                        },
844                        crate::custom_region::RenderSink {
845                            system_blocks: &mut system_blocks,
846                            messages: &mut messages,
847                        },
848                    );
849                }
850
851                // HashMap regions → system blocks with key headers
852                leviath_core::RegionKind::HashMap { .. } => {
853                    let text = region
854                        .content
855                        .iter()
856                        .map(|e| {
857                            if let Some(key) = &e.key {
858                                format!("### [{}]\n{}", key, e.content)
859                            } else {
860                                e.content.clone()
861                            }
862                        })
863                        .collect::<Vec<_>>()
864                        .join("\n\n");
865                    system_blocks.push(leviath_providers::SystemBlock {
866                        text: format!("[{}]:\n{}", region.name, text),
867                        cache_hint: CacheHint::UntilChanged,
868                    });
869                }
870            }
871
872            // The newest entry timestamp stands in for "when did this region
873            // last change". Regions are append-mostly and timestamps only move
874            // forward, so the block holding the newest entry is the one that
875            // mutated most recently.
876            let mut newest = i64::MIN;
877            for entry in &region.content {
878                if entry.timestamp > newest {
879                    newest = entry.timestamp;
880                }
881            }
882            for block in &system_blocks[first_new_block..] {
883                if block.cache_hint == CacheHint::UntilChanged {
884                    volatile_recency.push(newest);
885                }
886            }
887        }
888
889        // ── Sort system blocks for optimal prefix caching ────────────────
890        //
891        // Anthropic caches system content based on prefix matching.
892        // Stable blocks (Pinned, CompactHistory) should come first so
893        // they form the cacheable prefix, with volatile blocks
894        // (Compacting, Temporary, Clearable) after.
895        system_blocks.sort_by_key(|block| cache_hint_sort_priority(block.cache_hint));
896
897        // ── Spend a cache breakpoint on the volatile boundary ────────────
898        //
899        // Runs strictly after the sort, so it can only change breakpoint
900        // metadata: the block order and the block text are already final.
901        mark_recently_changed_run(&mut system_blocks, &volatile_recency);
902
903        // ── Sanitize orphaned tool_use / tool_result blocks ──────────────
904        //
905        // Collect all tool_use IDs from assistant messages and all tool_result
906        // IDs from user messages. Strip any that don't have a matching pair.
907        let mut tool_use_ids = std::collections::HashSet::new();
908        let mut tool_result_ids = std::collections::HashSet::new();
909
910        for msg in &messages {
911            if let leviath_providers::MessageContent::Blocks(blocks) = &msg.content {
912                for block in blocks {
913                    match block {
914                        leviath_providers::ContentBlock::ToolUse { id, .. } => {
915                            tool_use_ids.insert(id.clone());
916                        }
917                        leviath_providers::ContentBlock::ToolResult { tool_use_id, .. } => {
918                            tool_result_ids.insert(tool_use_id.clone());
919                        }
920                        _ => {}
921                    }
922                }
923            }
924        }
925
926        let orphaned_tool_uses: std::collections::HashSet<_> =
927            tool_use_ids.difference(&tool_result_ids).cloned().collect();
928        let orphaned_tool_results: std::collections::HashSet<_> =
929            tool_result_ids.difference(&tool_use_ids).cloned().collect();
930
931        if !orphaned_tool_uses.is_empty() || !orphaned_tool_results.is_empty() {
932            tracing::warn!(
933                orphaned_tool_uses = orphaned_tool_uses.len(),
934                orphaned_tool_results = orphaned_tool_results.len(),
935                "Stripping orphaned tool_use/tool_result blocks from assembled context"
936            );
937
938            messages = messages
939                .into_iter()
940                .filter_map(|msg| {
941                    if let leviath_providers::MessageContent::Blocks(blocks) = &msg.content {
942                        let filtered: Vec<_> = blocks
943                            .iter()
944                            .filter(|block| match block {
945                                leviath_providers::ContentBlock::ToolUse { id, .. } => {
946                                    !orphaned_tool_uses.contains(id)
947                                }
948                                leviath_providers::ContentBlock::ToolResult {
949                                    tool_use_id, ..
950                                } => !orphaned_tool_results.contains(tool_use_id),
951                                _ => true,
952                            })
953                            .cloned()
954                            .collect();
955
956                        if filtered.is_empty() {
957                            // No content left - drop this message entirely
958                            None
959                        } else {
960                            Some(leviath_providers::Message {
961                                role: msg.role.clone(),
962                                content: leviath_providers::MessageContent::Blocks(filtered),
963                                cache_breakpoint: msg.cache_breakpoint,
964                            })
965                        }
966                    } else {
967                        Some(msg)
968                    }
969                })
970                .collect();
971        }
972
973        // ── Set cache breakpoints on stable message prefix ──────────────
974        //
975        // In an iterative inference loop, only the last few messages change
976        // each iteration (new assistant turn + tool results). Everything
977        // before is stable across iterations and benefits from Anthropic's
978        // prompt caching. We place a cache breakpoint near the end of the
979        // stable prefix to maximize cache hits.
980        //
981        // Anthropic allows up to 4 breakpoints. Exactly 1 lands on the
982        // messages; the system blocks get theirs from their cache hints, and
983        // [`MAX_SYSTEM_CACHE_RUNS`] caps those at 3 so this one always fits.
984        // Place it on the 4th-from-last message to give a buffer for the
985        // new messages added each iteration (typically 2-3).
986        if messages.len() >= 5 {
987            let bp_idx = messages.len() - 4;
988            messages[bp_idx].cache_breakpoint = true;
989        } else if messages.len() >= 2 {
990            // Small conversation - cache at least the first message
991            messages[0].cache_breakpoint = true;
992        }
993
994        // Ensure there's at least one user message
995        if !messages.iter().any(|m| m.role == "user") {
996            messages.push(leviath_providers::Message {
997                role: "user".to_string(),
998                content: "Begin.".into(),
999                cache_breakpoint: false,
1000            });
1001        }
1002
1003        // The conversation must END with a user message: providers reject a
1004        // request that ends on an assistant turn as an (unsupported) prefill
1005        // ("This model does not support assistant message prefill"). After a
1006        // stage transition that carries the conversation, the last message is
1007        // the previous stage's final assistant turn - hand the turn back to the
1008        // model with a minimal nudge so it acts on the new stage's instructions.
1009        if messages.last().map(|m| m.role.as_str()) == Some("assistant") {
1010            messages.push(leviath_providers::Message {
1011                role: "user".to_string(),
1012                content: "Continue.".into(),
1013                cache_breakpoint: false,
1014            });
1015        }
1016
1017        AssembledContext {
1018            system_blocks,
1019            messages,
1020        }
1021    }
1022
1023    /// Enable taint tracking on all regions in this context window.
1024    pub fn enable_taint_tracking(&mut self) {
1025        for region in &mut self.regions {
1026            region.enable_taint_tracking();
1027        }
1028    }
1029
1030    /// Add tainted content to a specific region.
1031    pub fn add_tainted_to_region(
1032        &mut self,
1033        region_name: &str,
1034        content: String,
1035        tokens: usize,
1036        taint_level: leviath_core::TaintLevel,
1037    ) -> leviath_core::Result<()> {
1038        let Some((content, tokens)) =
1039            self.on_write_outcome(region_name, content, tokens, &leviath_core::EntryKind::Text)
1040        else {
1041            return Ok(());
1042        };
1043        self.write_to_region(region_name, tokens, &mut |region, tokens| {
1044            region.add_tainted_entry(content.clone(), tokens, taint_level)
1045        })
1046    }
1047
1048    /// Add a typed entry to a region with a specific taint level.
1049    ///
1050    /// The typed+tainted counterpart of [`add_typed_entry`](Self::add_typed_entry)
1051    /// and [`add_tainted_to_region`](Self::add_tainted_to_region): the entry keeps
1052    /// its `EntryKind` (so turn-group eviction stays intact) while contributing
1053    /// the given taint level (so the taint gate sees sensitive tool output).
1054    pub fn add_typed_tainted_to_region(
1055        &mut self,
1056        region_name: &str,
1057        kind: leviath_core::EntryKind,
1058        content: String,
1059        tokens: usize,
1060        taint_level: leviath_core::TaintLevel,
1061    ) -> leviath_core::Result<()> {
1062        let Some((content, tokens)) = self.on_write_outcome(region_name, content, tokens, &kind)
1063        else {
1064            return Ok(());
1065        };
1066        self.write_to_region(region_name, tokens, &mut |region, tokens| {
1067            region.add_typed_tainted_entry(content.clone(), tokens, kind.clone(), taint_level)
1068        })
1069    }
1070
1071    /// Get the overall taint level (max across all regions).
1072    /// Returns None if no region has taint tracking enabled.
1073    pub fn overall_taint(&self) -> Option<leviath_core::TaintLevel> {
1074        let mut max_taint = None;
1075        for region in &self.regions {
1076            if let Some(level) = region.taint_level() {
1077                max_taint = Some(match max_taint {
1078                    Some(current) => level.max(current),
1079                    None => level,
1080                });
1081            }
1082        }
1083        max_taint
1084    }
1085
1086    /// Get a summary of taint levels across all regions (for dashboard/audit).
1087    pub fn taint_summary(&self) -> Vec<(String, leviath_core::TaintLevel)> {
1088        self.regions
1089            .iter()
1090            .filter_map(|r| r.taint_level().map(|t| (r.name.clone(), t)))
1091            .collect()
1092    }
1093}
1094
1095#[cfg(test)]
1096mod tests {
1097    use super::*;
1098    use leviath_core::{CacheHint, Region, RegionKind};
1099    use leviath_providers::SystemBlock;
1100
1101    /// A system block carrying only the hint under test.
1102    fn block(hint: CacheHint) -> SystemBlock {
1103        SystemBlock {
1104            text: "x".to_string(),
1105            cache_hint: hint,
1106        }
1107    }
1108
1109    /// The number of cache breakpoints the Anthropic provider would place on
1110    /// these system blocks: one per contiguous run of same-hint cacheable
1111    /// blocks. Mirrors `system_cache_breakpoints` so the ceiling can be
1112    /// asserted from this side of the crate boundary.
1113    fn provider_system_breakpoints(blocks: &[SystemBlock]) -> usize {
1114        let mut runs = 0;
1115        for (index, block) in blocks.iter().enumerate() {
1116            let hint = block.cache_hint;
1117            if hint != CacheHint::Never && blocks.get(index + 1).map(|b| b.cache_hint) != Some(hint)
1118            {
1119                runs += 1;
1120            }
1121        }
1122        runs
1123    }
1124
1125    /// A region holding one entry stamped at `timestamp`.
1126    fn stamped_region(name: &str, kind: RegionKind, timestamp: i64) -> Region {
1127        let mut region = Region::new(name.to_string(), kind, 10_000);
1128        region.add_entry(format!("{name} contents"), 10).unwrap();
1129        region.content[0].timestamp = timestamp;
1130        region
1131    }
1132
1133    fn hashmap_region(name: &str, timestamp: i64) -> Region {
1134        stamped_region(
1135            name,
1136            RegionKind::HashMap {
1137                max_entries: Some(16),
1138            },
1139            timestamp,
1140        )
1141    }
1142
1143    #[test]
1144    fn recently_changed_sorts_with_until_changed() {
1145        assert_eq!(
1146            cache_hint_sort_priority(CacheHint::RecentlyChanged),
1147            cache_hint_sort_priority(CacheHint::UntilChanged)
1148        );
1149        assert_eq!(cache_hint_sort_priority(CacheHint::RecentlyChanged), 2);
1150    }
1151
1152    #[test]
1153    fn mark_recently_changed_run_splits_at_the_newest_block() {
1154        // Always, three volatile blocks, and a trailing uncacheable block. The
1155        // third volatile block is the one that just changed.
1156        let mut blocks = vec![
1157            block(CacheHint::Always),
1158            block(CacheHint::UntilChanged),
1159            block(CacheHint::UntilChanged),
1160            block(CacheHint::UntilChanged),
1161            block(CacheHint::Never),
1162        ];
1163        mark_recently_changed_run(&mut blocks, &[10, 20, 90]);
1164
1165        let hints: Vec<CacheHint> = blocks.iter().map(|b| b.cache_hint).collect();
1166        assert_eq!(
1167            hints,
1168            vec![
1169                CacheHint::Always,
1170                CacheHint::UntilChanged,
1171                CacheHint::UntilChanged,
1172                CacheHint::RecentlyChanged,
1173                CacheHint::Never,
1174            ]
1175        );
1176        // Always run, stable volatile head, changed volatile tail. The
1177        // uncacheable trailing block claims nothing.
1178        assert_eq!(provider_system_breakpoints(&blocks), 3);
1179    }
1180
1181    #[test]
1182    fn mark_recently_changed_run_retags_every_block_after_the_boundary() {
1183        let mut blocks = vec![
1184            block(CacheHint::UntilChanged),
1185            block(CacheHint::UntilChanged),
1186            block(CacheHint::UntilChanged),
1187        ];
1188        mark_recently_changed_run(&mut blocks, &[1, 7, 5]);
1189
1190        let hints: Vec<CacheHint> = blocks.iter().map(|b| b.cache_hint).collect();
1191        assert_eq!(
1192            hints,
1193            vec![
1194                CacheHint::UntilChanged,
1195                CacheHint::RecentlyChanged,
1196                CacheHint::RecentlyChanged,
1197            ]
1198        );
1199    }
1200
1201    #[test]
1202    fn mark_recently_changed_run_ties_resolve_to_the_earliest_block() {
1203        // Two regions written in the same second both changed, so the boundary
1204        // belongs ahead of the earlier one.
1205        let mut blocks = vec![
1206            block(CacheHint::UntilChanged),
1207            block(CacheHint::UntilChanged),
1208            block(CacheHint::UntilChanged),
1209        ];
1210        mark_recently_changed_run(&mut blocks, &[1, 9, 9]);
1211
1212        assert_eq!(blocks[0].cache_hint, CacheHint::UntilChanged);
1213        assert_eq!(blocks[1].cache_hint, CacheHint::RecentlyChanged);
1214        assert_eq!(blocks[2].cache_hint, CacheHint::RecentlyChanged);
1215    }
1216
1217    #[test]
1218    fn mark_recently_changed_run_leaves_a_headless_tier_alone() {
1219        // The newest block is already first, so there is no stable head to put
1220        // behind a breakpoint.
1221        let mut blocks = vec![
1222            block(CacheHint::UntilChanged),
1223            block(CacheHint::UntilChanged),
1224        ];
1225        mark_recently_changed_run(&mut blocks, &[42, 1]);
1226        assert!(
1227            blocks
1228                .iter()
1229                .all(|b| b.cache_hint == CacheHint::UntilChanged)
1230        );
1231    }
1232
1233    #[test]
1234    fn mark_recently_changed_run_leaves_a_tierless_prompt_alone() {
1235        // No volatile blocks at all means an empty recency list.
1236        let mut blocks = vec![block(CacheHint::Always), block(CacheHint::Never)];
1237        mark_recently_changed_run(&mut blocks, &[]);
1238        assert_eq!(blocks[0].cache_hint, CacheHint::Always);
1239        assert_eq!(blocks[1].cache_hint, CacheHint::Never);
1240    }
1241
1242    #[test]
1243    fn mark_recently_changed_run_refuses_when_the_run_budget_is_full() {
1244        // Always, SlidingPrefix and UntilChanged already claim three
1245        // breakpoints. Splitting further would take the messages' one.
1246        let mut blocks = vec![
1247            block(CacheHint::Always),
1248            block(CacheHint::SlidingPrefix {
1249                stable_fraction: 0.75,
1250            }),
1251            block(CacheHint::UntilChanged),
1252            block(CacheHint::UntilChanged),
1253        ];
1254        assert_eq!(provider_system_breakpoints(&blocks), 3);
1255
1256        mark_recently_changed_run(&mut blocks, &[1, 99]);
1257
1258        assert_eq!(blocks[2].cache_hint, CacheHint::UntilChanged);
1259        assert_eq!(blocks[3].cache_hint, CacheHint::UntilChanged);
1260        assert_eq!(provider_system_breakpoints(&blocks), 3);
1261    }
1262
1263    #[test]
1264    fn assemble_marks_the_volatile_tail_without_moving_any_block() {
1265        let mut window = ContextWindow::new(100_000);
1266        window.add_region(stamped_region("brief", RegionKind::Pinned, 1));
1267        window.add_region(hashmap_region("spec", 100));
1268        window.add_region(hashmap_region("data_preview", 200));
1269        window.add_region(hashmap_region("results", 300));
1270        window.add_region(stamped_region("scratch", RegionKind::Temporary, 400));
1271
1272        let assembled = window.assemble();
1273
1274        // Declaration order inside each tier is exactly what it was: the split
1275        // only rewrites cache hints.
1276        let texts: Vec<&str> = assembled
1277            .system_blocks
1278            .iter()
1279            .map(|b| b.text.as_str())
1280            .collect();
1281        assert!(texts[0].contains("brief contents"));
1282        assert!(texts[1].starts_with("[spec]:"));
1283        assert!(texts[2].starts_with("[data_preview]:"));
1284        assert!(texts[3].starts_with("[results]:"));
1285        assert!(texts[4].starts_with("[scratch]:"));
1286
1287        let hints: Vec<CacheHint> = assembled
1288            .system_blocks
1289            .iter()
1290            .map(|b| b.cache_hint)
1291            .collect();
1292        assert_eq!(
1293            hints,
1294            vec![
1295                CacheHint::Always,
1296                CacheHint::UntilChanged,
1297                CacheHint::UntilChanged,
1298                CacheHint::RecentlyChanged,
1299                CacheHint::Never,
1300            ]
1301        );
1302    }
1303
1304    #[test]
1305    fn assemble_leaves_a_flat_window_untouched() {
1306        // One pinned block and one working region: the flat shape that already
1307        // caches well keeps a single volatile run and a single breakpoint.
1308        let mut window = ContextWindow::new(100_000);
1309        window.add_region(stamped_region("brief", RegionKind::Pinned, 1));
1310        window.add_region(hashmap_region("results", 300));
1311
1312        let assembled = window.assemble();
1313        let hints: Vec<CacheHint> = assembled
1314            .system_blocks
1315            .iter()
1316            .map(|b| b.cache_hint)
1317            .collect();
1318        assert_eq!(hints, vec![CacheHint::Always, CacheHint::UntilChanged]);
1319        assert_eq!(provider_system_breakpoints(&assembled.system_blocks), 2);
1320    }
1321
1322    #[test]
1323    fn assemble_stays_within_four_cache_breakpoints() {
1324        let mut window = ContextWindow::new(1_000_000);
1325        window.add_region(stamped_region("brief", RegionKind::Pinned, 1));
1326        window.add_region(stamped_region(
1327            "history",
1328            RegionKind::CompactHistory {
1329                source_region: "conversation".to_string(),
1330            },
1331            2,
1332        ));
1333        for (index, name) in ["spec", "data_preview", "scripts", "results"]
1334            .iter()
1335            .enumerate()
1336        {
1337            window.add_region(hashmap_region(name, 100 + index as i64));
1338        }
1339        window.add_region(stamped_region("scratch", RegionKind::Clearable, 500));
1340
1341        let mut conversation = Region::new(
1342            "conversation".to_string(),
1343            RegionKind::SlidingWindow {
1344                max_items: 100,
1345                eviction_strategy: leviath_core::EvictionStrategy::PerItem,
1346            },
1347            100_000,
1348        );
1349        for turn in 0..8 {
1350            conversation
1351                .add_entry(format!("User: turn {turn}"), 10)
1352                .unwrap();
1353        }
1354        window.add_region(conversation);
1355
1356        let assembled = window.assemble();
1357        let system = provider_system_breakpoints(&assembled.system_blocks);
1358        let message = assembled
1359            .messages
1360            .iter()
1361            .filter(|m| m.cache_breakpoint)
1362            .count();
1363
1364        assert!(system <= MAX_SYSTEM_CACHE_RUNS, "system runs: {system}");
1365        assert_eq!(message, 1);
1366        let total = system + message;
1367        assert!(total <= 4, "total breakpoints: {total}");
1368    }
1369}