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, .. } = ®ion.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(®ion.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(®ion.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 ®ion.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 ®ion.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 ®ion.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}