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