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