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 CacheHint::Never => 3, // Temporary, Clearable - changes every iteration
88 }
89}
90
91/// Context window component storing the agent's memory regions.
92#[derive(Component, Debug, Clone)]
93pub struct ContextWindow {
94 /// All regions in this context window
95 pub regions: Vec<Region>,
96
97 /// Current total token usage
98 pub current_tokens: usize,
99
100 /// Maximum token budget
101 pub max_tokens: usize,
102
103 /// Compiled custom-region scripts, keyed by the script path each
104 /// `RegionKind::Custom` carries. Populated once at spawn by the CLI
105 /// (which resolves blueprint-dir-relative paths and compile-checks the
106 /// files); a stage-layout swap rebuilds `regions` but leaves this table
107 /// untouched, so per-stage custom regions keep working. Empty when no
108 /// custom regions exist - every hook lookup then misses and the region
109 /// renders its fallback shape.
110 pub region_scripts: std::collections::HashMap<
111 String,
112 std::sync::Arc<leviath_scripting::region_hook::RegionScript>,
113 >,
114
115 /// Regions the current stage does not attend to.
116 ///
117 /// Held, not deleted. A stage layout that omits a region used to have it
118 /// dropped from the window entirely, so re-declaring it in a later stage
119 /// brought it back empty - which made the feature unusable for the thing it
120 /// looks designed for, narrowing what one stage sees in a pipeline whose
121 /// later stages still need the data. Omission now means "not assembled for
122 /// this stage" and nothing else.
123 ///
124 /// Reset on every stage entry by [`crate::context_setup::apply_layout`], so
125 /// it describes the stage in front of it rather than accumulating.
126 pub hidden: std::collections::HashSet<String>,
127}
128
129impl ContextWindow {
130 /// Create a new context window with the specified budget.
131 pub fn new(max_tokens: usize) -> Self {
132 Self {
133 regions: Vec::new(),
134 hidden: std::collections::HashSet::new(),
135 current_tokens: 0,
136 max_tokens,
137 region_scripts: std::collections::HashMap::new(),
138 }
139 }
140
141 /// The compiled script backing `region_name`, when it is a custom region
142 /// whose script path has an entry in [`Self::region_scripts`].
143 fn custom_script_for(
144 &self,
145 region_name: &str,
146 ) -> Option<std::sync::Arc<leviath_scripting::region_hook::RegionScript>> {
147 let region = self.get_region(region_name)?;
148 let leviath_core::RegionKind::Custom { script, .. } = ®ion.kind else {
149 return None;
150 };
151 self.region_scripts.get(script).cloned()
152 }
153
154 /// Run a custom region's `on_write` hook (when defined) for an incoming
155 /// entry. `None` means the script dropped the entry - the write reports
156 /// success without storing anything. Non-custom regions, missing scripts,
157 /// and hook failures all accept the entry unchanged.
158 ///
159 /// Deliberately NOT invoked by the layout-swap carry or restore overlay:
160 /// those re-add entries the hook already accepted once.
161 fn on_write_outcome(
162 &self,
163 region_name: &str,
164 content: String,
165 tokens: usize,
166 kind: &leviath_core::EntryKind,
167 ) -> Option<(String, usize)> {
168 let Some(script) = self.custom_script_for(region_name) else {
169 return Some((content, tokens));
170 };
171 if !script.has_on_write() {
172 return Some((content, tokens));
173 }
174 // The region exists - custom_script_for resolved through it.
175 let region = self
176 .get_region(region_name)
177 .expect("custom_script_for resolved through this region");
178 match crate::custom_region::apply_on_write(&script, region, content, tokens, kind) {
179 crate::custom_region::OnWriteOutcome::Accept(content, tokens) => {
180 Some((content, tokens))
181 }
182 crate::custom_region::OnWriteOutcome::Drop => None,
183 }
184 }
185
186 /// Retry hook for a custom-region write that hit `TokenBudgetExceeded`:
187 /// let the script's `on_overflow` free room, then report whether a single
188 /// retry is worthwhile. Non-custom regions and hook failures leave the
189 /// original error standing (the callers' existing truncation ladders
190 /// apply).
191 fn try_custom_overflow(&mut self, region_name: &str, incoming_tokens: usize) -> bool {
192 let Some(script) = self.custom_script_for(region_name) else {
193 return false;
194 };
195 if !script.has_on_overflow() {
196 return false;
197 }
198 let region = self
199 .get_region_mut(region_name)
200 .expect("custom_script_for resolved through this region");
201 let needed = (region.current_tokens + incoming_tokens).saturating_sub(region.max_tokens);
202 let freed = crate::custom_region::apply_overflow(&script, region, needed);
203 self.current_tokens = self.calculate_tokens();
204 freed >= needed && needed > 0
205 }
206
207 /// Get a region by name.
208 pub fn get_region(&self, name: &str) -> Option<&Region> {
209 self.regions.iter().find(|r| r.name == name)
210 }
211
212 /// Get a mutable reference to a region by name.
213 pub fn get_region_mut(&mut self, name: &str) -> Option<&mut Region> {
214 self.regions.iter_mut().find(|r| r.name == name)
215 }
216
217 /// Add a region to this context window.
218 pub fn add_region(&mut self, region: Region) {
219 self.regions.push(region);
220 self.current_tokens = self.calculate_tokens();
221 }
222
223 /// Add content to a specific region.
224 pub fn add_to_region(
225 &mut self,
226 region_name: &str,
227 content: String,
228 tokens: usize,
229 ) -> leviath_core::Result<()> {
230 let Some((content, tokens)) =
231 self.on_write_outcome(region_name, content, tokens, &leviath_core::EntryKind::Text)
232 else {
233 return Ok(()); // the region's script dropped the entry
234 };
235 self.write_to_region(region_name, tokens, &mut |region, tokens| {
236 region.add_entry(content.clone(), tokens)
237 })
238 }
239
240 /// Replace a region's entire content with a single entry (clear, then add).
241 /// Returns `false` (no-op) if the region does not exist. Used to keep an
242 /// authoritative document region (e.g. the plan) holding only its current
243 /// version, so revisions build on it instead of accumulating stale copies.
244 pub fn replace_region(&mut self, region_name: &str, content: String, tokens: usize) -> bool {
245 // The replacement passes through on_write like any incoming entry - a
246 // custom region's script sees (and may transform or refuse) it.
247 let Some((content, tokens)) =
248 self.on_write_outcome(region_name, content, tokens, &leviath_core::EntryKind::Text)
249 else {
250 // Dropped by the script: the region keeps its current content.
251 return self.get_region(region_name).is_some();
252 };
253 if let Some(region) = self.get_region_mut(region_name) {
254 region.clear();
255 let _ = region.add_entry(content, tokens);
256 self.current_tokens = self.calculate_tokens();
257 true
258 } else {
259 false
260 }
261 }
262
263 /// Add a typed entry to a specific region.
264 ///
265 /// Like [`add_to_region`](Self::add_to_region) but the entry carries an
266 /// `EntryKind` so message roles are determined by type, not text-prefix
267 /// parsing.
268 pub fn add_typed_entry(
269 &mut self,
270 region_name: &str,
271 kind: leviath_core::EntryKind,
272 content: String,
273 tokens: usize,
274 ) -> leviath_core::Result<()> {
275 let Some((content, tokens)) = self.on_write_outcome(region_name, content, tokens, &kind)
276 else {
277 return Ok(());
278 };
279 self.write_to_region(region_name, tokens, &mut |region, tokens| {
280 region.add_typed_entry(content.clone(), tokens, kind.clone())
281 })
282 }
283
284 /// Shared tail of every region write: run the insert, give a custom
285 /// region's `on_overflow` one shot at freeing room when the budget
286 /// rejects it, and recount the window. A `&mut dyn FnMut` (not generic)
287 /// keeps one instantiation for the coverage gate.
288 fn write_to_region(
289 &mut self,
290 region_name: &str,
291 tokens: usize,
292 insert: &mut dyn FnMut(&mut Region, usize) -> leviath_core::Result<()>,
293 ) -> leviath_core::Result<()> {
294 if self.get_region(region_name).is_none() {
295 return Err(leviath_core::Error::RegionNotFound(region_name.to_string()));
296 }
297 let first = {
298 let region = self.get_region_mut(region_name).expect("checked above");
299 insert(region, tokens)
300 };
301 match first {
302 Ok(()) => {
303 self.current_tokens = self.calculate_tokens();
304 Ok(())
305 }
306 Err(leviath_core::Error::TokenBudgetExceeded { .. })
307 if self.try_custom_overflow(region_name, tokens) =>
308 {
309 let region = self.get_region_mut(region_name).expect("checked above");
310 let retried = insert(region, tokens);
311 self.current_tokens = self.calculate_tokens();
312 retried
313 }
314 Err(e) => Err(e),
315 }
316 }
317
318 /// Calculate current token usage across all regions.
319 pub fn calculate_tokens(&self) -> usize {
320 self.regions.iter().map(|r| r.current_tokens).sum()
321 }
322
323 /// Check if the context window needs eviction.
324 pub fn needs_eviction(&self, threshold: f32) -> bool {
325 let usage_ratio = self.current_tokens as f32 / self.max_tokens as f32;
326 usage_ratio >= threshold
327 }
328
329 /// Execute eviction cascade to free up space.
330 ///
331 /// Returns an `EvictionResult` with tokens freed and any regions that need
332 /// LLM-based compaction. The caller is responsible for performing compaction
333 /// on the listed regions (since it requires async LLM access).
334 pub fn try_evict(&mut self, target_free_tokens: usize) -> leviath_core::Result<EvictionResult> {
335 use leviath_core::RegionKind;
336
337 let initial_tokens = self.current_tokens;
338
339 // Check if we have any evictable regions
340 let has_evictable = self.regions.iter().any(|r| {
341 matches!(
342 r.kind,
343 RegionKind::Clearable
344 | RegionKind::Temporary
345 | RegionKind::Custom {
346 persistent: false,
347 ..
348 }
349 )
350 });
351
352 if !has_evictable {
353 tracing::warn!(
354 "Context window has no Clearable or Temporary regions. \
355 This may be intentional, but usually indicates a configuration error."
356 );
357 }
358
359 // Phase 1: Clear Clearable regions (all-or-nothing)
360 for region in &mut self.regions {
361 if matches!(region.kind, RegionKind::Clearable) && !region.content.is_empty() {
362 let freed = region.current_tokens;
363 region.clear();
364 self.current_tokens -= freed;
365 tracing::debug!(
366 region = %region.name,
367 tokens_freed = freed,
368 "Cleared Clearable region (all-or-nothing)"
369 );
370
371 if self.max_tokens.saturating_sub(self.current_tokens) >= target_free_tokens {
372 return Ok(EvictionResult {
373 tokens_freed: initial_tokens - self.current_tokens,
374 needs_compaction: Vec::new(),
375 });
376 }
377 }
378 }
379
380 // Phase 1.5: Give each non-persistent custom region's on_overflow
381 // hook first say over what IT loses, before the indiscriminate
382 // oldest-first cascade below. A script that keeps errors and drops
383 // successes only works if it runs before oldest-first does. Hook
384 // absent/failing/insufficient → phase 2 makes the guaranteed
385 // progress.
386 let mut custom_freed = 0usize;
387 for i in 0..self.regions.len() {
388 let needed = target_free_tokens
389 .saturating_sub(self.max_tokens.saturating_sub(self.current_tokens));
390 if needed == 0 {
391 break;
392 }
393 let region = &self.regions[i];
394 if !matches!(
395 region.kind,
396 RegionKind::Custom {
397 persistent: false,
398 ..
399 }
400 ) || region.content.is_empty()
401 {
402 continue;
403 }
404 let Some(script) = self.custom_script_for(®ion.name.clone()) else {
405 continue;
406 };
407 if !script.has_on_overflow() {
408 continue;
409 }
410 let freed = crate::custom_region::apply_overflow(&script, &mut self.regions[i], needed);
411 self.current_tokens = self.current_tokens.saturating_sub(freed);
412 custom_freed += freed;
413 if freed > 0 {
414 tracing::debug!(
415 region = %self.regions[i].name,
416 tokens_freed = freed,
417 "custom region's on_overflow chose its own evictions"
418 );
419 }
420 }
421 // Return early ONLY when a script's own drops satisfied the target -
422 // otherwise phase 2 would immediately evict one more entry (it checks
423 // the target *after* each eviction), overriding the script's
424 // retention choice. Windows with no custom drops (custom_freed == 0)
425 // fall through with phase 2's pre-existing behavior, byte-identical.
426 if custom_freed > 0
427 && self.max_tokens.saturating_sub(self.current_tokens) >= target_free_tokens
428 {
429 return Ok(EvictionResult {
430 tokens_freed: initial_tokens - self.current_tokens,
431 needs_compaction: Vec::new(),
432 });
433 }
434
435 // Phase 2: Evict from Temporary regions (oldest first, one at a time).
436 // Non-persistent Custom regions join this phase: their script's
437 // on_overflow hook (when present) has already had its say in phase
438 // 1.5; oldest-first is the guaranteed-progress fallback.
439 loop {
440 let mut evicted_any = false;
441
442 for region in &mut self.regions {
443 if matches!(
444 region.kind,
445 RegionKind::Temporary
446 | RegionKind::Custom {
447 persistent: false,
448 ..
449 }
450 ) && let Some(entry) = region.remove_oldest()
451 {
452 let freed = entry.tokens;
453 self.current_tokens -= freed;
454 evicted_any = true;
455
456 tracing::debug!(
457 region = %region.name,
458 tokens_freed = freed,
459 "Evicted temporary region entry (oldest first)"
460 );
461
462 if self.max_tokens.saturating_sub(self.current_tokens) >= target_free_tokens {
463 return Ok(EvictionResult {
464 tokens_freed: initial_tokens - self.current_tokens,
465 needs_compaction: Vec::new(),
466 });
467 }
468 }
469 }
470
471 if !evicted_any {
472 break;
473 }
474 }
475
476 // Phase 3: If still need space, identify Compacting regions that need compaction
477 let mut needs_compaction = Vec::new();
478 if self.max_tokens.saturating_sub(self.current_tokens) < target_free_tokens {
479 for region in &self.regions {
480 if region.needs_compaction() {
481 needs_compaction.push(region.name.clone());
482 }
483 }
484 }
485
486 // Phase 4: SlidingWindow regions are NEVER reduced
487 // Phase 5: Pinned and CompactHistory regions are NEVER touched
488
489 // Check for pinned regions over budget
490 let pinned_tokens: usize = self
491 .regions
492 .iter()
493 .filter(|r| {
494 matches!(
495 r.kind,
496 RegionKind::Pinned
497 | RegionKind::CompactHistory { .. }
498 | RegionKind::Custom {
499 persistent: true,
500 ..
501 }
502 )
503 })
504 .map(|r| r.current_tokens)
505 .sum();
506
507 if pinned_tokens > self.max_tokens {
508 return Err(leviath_core::Error::PinnedRegionsOverBudget {
509 pinned_tokens,
510 total_budget: self.max_tokens,
511 });
512 }
513
514 Ok(EvictionResult {
515 tokens_freed: initial_tokens - self.current_tokens,
516 needs_compaction,
517 })
518 }
519
520 /// Result of assembling the context window into system blocks + messages.
521 ///
522 /// System-bound regions become `system_blocks`; the messages region
523 /// becomes `messages` with proper typed entries (no text-prefix parsing).
524 ///
525 /// Thin wrapper over [`assemble_with_meta`](Self::assemble_with_meta) with
526 /// no stage metadata - custom-region scripts see empty stage fields.
527 pub fn assemble(&self) -> AssembledContext {
528 self.assemble_with_meta(&crate::custom_region::AssembleMeta::default())
529 }
530
531 /// [`assemble`](Self::assemble) with stage metadata for custom-region
532 /// `render(ctx)` hooks (stage name, per-stage iteration count, model).
533 /// The inference path (`build_request`) threads real values; other
534 /// callers use the default.
535 pub fn assemble_with_meta(
536 &self,
537 meta: &crate::custom_region::AssembleMeta,
538 ) -> AssembledContext {
539 use leviath_core::{CacheHint, EntryKind};
540
541 let mut system_blocks = Vec::new();
542 let mut messages: Vec<leviath_providers::Message> = Vec::new();
543
544 for region in &self.regions {
545 // A region this stage does not attend to is held but not shown.
546 // Skipped here rather than dropped from the window, so a later
547 // stage that declares it again gets its contents back.
548 if self.hidden.contains(®ion.name) {
549 continue;
550 }
551 // Custom regions render even when empty - a script may emit
552 // static scaffolding. Every other kind skips an empty region.
553 let is_custom = matches!(region.kind, leviath_core::RegionKind::Custom { .. });
554 if region.content.is_empty() && !is_custom {
555 continue;
556 }
557
558 match ®ion.kind {
559 // System-level content → system blocks
560 leviath_core::RegionKind::Pinned => {
561 let text = region
562 .content
563 .iter()
564 .map(|e| e.content.as_str())
565 .collect::<Vec<_>>()
566 .join("\n\n");
567 system_blocks.push(leviath_providers::SystemBlock {
568 text,
569 cache_hint: CacheHint::Always,
570 });
571 }
572 // A checklist renders as instruction rather than history: one
573 // stable block, open items first, so what is left to do is at
574 // the top of what the model reads every turn. The whole value
575 // of the state being real is that this block is derived from
576 // it rather than from whatever prose the model last wrote.
577 leviath_core::RegionKind::Checklist => {
578 let text = region.render_checklist();
579 if !text.is_empty() {
580 system_blocks.push(leviath_providers::SystemBlock {
581 text,
582 cache_hint: CacheHint::UntilChanged,
583 });
584 }
585 }
586 leviath_core::RegionKind::CompactHistory { .. } => {
587 let text = region
588 .content
589 .iter()
590 .map(|e| e.content.as_str())
591 .collect::<Vec<_>>()
592 .join("\n\n");
593 system_blocks.push(leviath_providers::SystemBlock {
594 text,
595 cache_hint: CacheHint::Always,
596 });
597 }
598
599 // Messages region → Vec<Message> with proper typed entries.
600 // Consecutive ToolResult entries are merged into a single user
601 // message with multiple tool_result content blocks (required by
602 // Anthropic: one assistant tool_use msg → one user tool_result msg).
603 leviath_core::RegionKind::SlidingWindow { .. } => {
604 let mut pending_tool_results: Vec<leviath_providers::ContentBlock> = Vec::new();
605
606 for entry in ®ion.content {
607 // Flush any pending tool results when we hit a non-ToolResult entry
608 if !matches!(entry.kind, EntryKind::ToolResult { .. })
609 && !pending_tool_results.is_empty()
610 {
611 messages.push(leviath_providers::Message {
612 role: "user".to_string(),
613 content: leviath_providers::MessageContent::Blocks(std::mem::take(
614 &mut pending_tool_results,
615 )),
616 cache_breakpoint: false,
617 });
618 }
619
620 match &entry.kind {
621 EntryKind::UserMessage => {
622 messages.push(leviath_providers::Message {
623 role: "user".to_string(),
624 content: entry.content.clone().into(),
625 cache_breakpoint: false,
626 });
627 }
628 EntryKind::AssistantTurn { tool_calls } => {
629 if tool_calls.is_empty() {
630 messages.push(leviath_providers::Message {
631 role: "assistant".to_string(),
632 content: entry.content.clone().into(),
633 cache_breakpoint: false,
634 });
635 } else {
636 let mut blocks = Vec::new();
637 if !entry.content.is_empty() {
638 blocks.push(leviath_providers::ContentBlock::Text {
639 text: entry.content.clone(),
640 });
641 }
642 for tc in tool_calls {
643 blocks.push(leviath_providers::ContentBlock::ToolUse {
644 id: tc.id.clone(),
645 name: tc.name.clone(),
646 input: tc.arguments.clone(),
647 thought_signature: tc.thought_signature.clone(),
648 });
649 }
650 messages.push(leviath_providers::Message {
651 role: "assistant".to_string(),
652 content: leviath_providers::MessageContent::Blocks(blocks),
653 cache_breakpoint: false,
654 });
655 }
656 }
657 EntryKind::ToolResult {
658 tool_call_id,
659 is_error,
660 ..
661 } => {
662 // Accumulate - will be flushed on next non-ToolResult or end
663 pending_tool_results.push(
664 leviath_providers::ContentBlock::ToolResult {
665 tool_use_id: tool_call_id.clone(),
666 content: entry.content.clone(),
667 is_error: *is_error,
668 },
669 );
670 }
671 EntryKind::Text => {
672 let trimmed = entry.content.trim();
673 if let Some(rest) = trimmed.strip_prefix("Assistant: ") {
674 messages.push(leviath_providers::Message {
675 role: "assistant".to_string(),
676 content: rest.to_string().into(),
677 cache_breakpoint: false,
678 });
679 } else if let Some(rest) = trimmed.strip_prefix("User: ") {
680 messages.push(leviath_providers::Message {
681 role: "user".to_string(),
682 content: rest.to_string().into(),
683 cache_breakpoint: false,
684 });
685 } else {
686 messages.push(leviath_providers::Message {
687 role: "user".to_string(),
688 content: entry.content.clone().into(),
689 cache_breakpoint: false,
690 });
691 }
692 }
693 }
694 }
695
696 // Flush any remaining tool results at the end of the region
697 if !pending_tool_results.is_empty() {
698 messages.push(leviath_providers::Message {
699 role: "user".to_string(),
700 content: leviath_providers::MessageContent::Blocks(std::mem::take(
701 &mut pending_tool_results,
702 )),
703 cache_breakpoint: false,
704 });
705 }
706 }
707
708 // Compacting / Temporary / Clearable → system blocks
709 leviath_core::RegionKind::Compacting { .. } => {
710 let text = region
711 .content
712 .iter()
713 .map(|e| e.content.as_str())
714 .collect::<Vec<_>>()
715 .join("\n\n");
716 system_blocks.push(leviath_providers::SystemBlock {
717 text: format!("[{}]:\n{}", region.name, text),
718 cache_hint: CacheHint::UntilChanged,
719 });
720 }
721 leviath_core::RegionKind::Temporary => {
722 let text = region
723 .content
724 .iter()
725 .map(|e| e.content.as_str())
726 .collect::<Vec<_>>()
727 .join("\n\n");
728 system_blocks.push(leviath_providers::SystemBlock {
729 text: format!("[{}]:\n{}", region.name, text),
730 cache_hint: CacheHint::Never,
731 });
732 }
733 leviath_core::RegionKind::Clearable => {
734 let text = region
735 .content
736 .iter()
737 .map(|e| e.content.as_str())
738 .collect::<Vec<_>>()
739 .join("\n\n");
740 system_blocks.push(leviath_providers::SystemBlock {
741 text: format!("[{}]:\n{}", region.name, text),
742 cache_hint: CacheHint::Never,
743 });
744 }
745
746 // Custom (script-backed) regions render through their Rhai
747 // hook; a missing script or any hook failure falls back to
748 // the Temporary-style block inside `render_custom_region`,
749 // so a custom region is never silently dropped.
750 leviath_core::RegionKind::Custom { script, persistent } => {
751 crate::custom_region::render_custom_region(
752 crate::custom_region::RegionRender {
753 region,
754 script: self.region_scripts.get(script),
755 persistent: *persistent,
756 meta,
757 window_current: self.current_tokens,
758 window_max: self.max_tokens,
759 },
760 crate::custom_region::RenderSink {
761 system_blocks: &mut system_blocks,
762 messages: &mut messages,
763 },
764 );
765 }
766
767 // HashMap regions → system blocks with key headers
768 leviath_core::RegionKind::HashMap { .. } => {
769 let text = region
770 .content
771 .iter()
772 .map(|e| {
773 if let Some(key) = &e.key {
774 format!("### [{}]\n{}", key, e.content)
775 } else {
776 e.content.clone()
777 }
778 })
779 .collect::<Vec<_>>()
780 .join("\n\n");
781 system_blocks.push(leviath_providers::SystemBlock {
782 text: format!("[{}]:\n{}", region.name, text),
783 cache_hint: CacheHint::UntilChanged,
784 });
785 }
786 }
787 }
788
789 // ── Sort system blocks for optimal prefix caching ────────────────
790 //
791 // Anthropic caches system content based on prefix matching.
792 // Stable blocks (Pinned, CompactHistory) should come first so
793 // they form the cacheable prefix, with volatile blocks
794 // (Compacting, Temporary, Clearable) after.
795 system_blocks.sort_by_key(|block| cache_hint_sort_priority(block.cache_hint));
796
797 // ── Sanitize orphaned tool_use / tool_result blocks ──────────────
798 //
799 // Collect all tool_use IDs from assistant messages and all tool_result
800 // IDs from user messages. Strip any that don't have a matching pair.
801 let mut tool_use_ids = std::collections::HashSet::new();
802 let mut tool_result_ids = std::collections::HashSet::new();
803
804 for msg in &messages {
805 if let leviath_providers::MessageContent::Blocks(blocks) = &msg.content {
806 for block in blocks {
807 match block {
808 leviath_providers::ContentBlock::ToolUse { id, .. } => {
809 tool_use_ids.insert(id.clone());
810 }
811 leviath_providers::ContentBlock::ToolResult { tool_use_id, .. } => {
812 tool_result_ids.insert(tool_use_id.clone());
813 }
814 _ => {}
815 }
816 }
817 }
818 }
819
820 let orphaned_tool_uses: std::collections::HashSet<_> =
821 tool_use_ids.difference(&tool_result_ids).cloned().collect();
822 let orphaned_tool_results: std::collections::HashSet<_> =
823 tool_result_ids.difference(&tool_use_ids).cloned().collect();
824
825 if !orphaned_tool_uses.is_empty() || !orphaned_tool_results.is_empty() {
826 tracing::warn!(
827 orphaned_tool_uses = orphaned_tool_uses.len(),
828 orphaned_tool_results = orphaned_tool_results.len(),
829 "Stripping orphaned tool_use/tool_result blocks from assembled context"
830 );
831
832 messages = messages
833 .into_iter()
834 .filter_map(|msg| {
835 if let leviath_providers::MessageContent::Blocks(blocks) = &msg.content {
836 let filtered: Vec<_> = blocks
837 .iter()
838 .filter(|block| match block {
839 leviath_providers::ContentBlock::ToolUse { id, .. } => {
840 !orphaned_tool_uses.contains(id)
841 }
842 leviath_providers::ContentBlock::ToolResult {
843 tool_use_id, ..
844 } => !orphaned_tool_results.contains(tool_use_id),
845 _ => true,
846 })
847 .cloned()
848 .collect();
849
850 if filtered.is_empty() {
851 // No content left - drop this message entirely
852 None
853 } else {
854 Some(leviath_providers::Message {
855 role: msg.role.clone(),
856 content: leviath_providers::MessageContent::Blocks(filtered),
857 cache_breakpoint: msg.cache_breakpoint,
858 })
859 }
860 } else {
861 Some(msg)
862 }
863 })
864 .collect();
865 }
866
867 // ── Set cache breakpoints on stable message prefix ──────────────
868 //
869 // In an iterative inference loop, only the last few messages change
870 // each iteration (new assistant turn + tool results). Everything
871 // before is stable across iterations and benefits from Anthropic's
872 // prompt caching. We place a cache breakpoint near the end of the
873 // stable prefix to maximize cache hits.
874 //
875 // Anthropic allows up to 4 breakpoints. We use 1 on messages
876 // (system blocks already have cache_control via CacheHint).
877 // Place it on the 4th-from-last message to give a buffer for the
878 // new messages added each iteration (typically 2-3).
879 if messages.len() >= 5 {
880 let bp_idx = messages.len() - 4;
881 messages[bp_idx].cache_breakpoint = true;
882 } else if messages.len() >= 2 {
883 // Small conversation - cache at least the first message
884 messages[0].cache_breakpoint = true;
885 }
886
887 // Ensure there's at least one user message
888 if !messages.iter().any(|m| m.role == "user") {
889 messages.push(leviath_providers::Message {
890 role: "user".to_string(),
891 content: "Begin.".into(),
892 cache_breakpoint: false,
893 });
894 }
895
896 // The conversation must END with a user message: providers reject a
897 // request that ends on an assistant turn as an (unsupported) prefill
898 // ("This model does not support assistant message prefill"). After a
899 // stage transition that carries the conversation, the last message is
900 // the previous stage's final assistant turn - hand the turn back to the
901 // model with a minimal nudge so it acts on the new stage's instructions.
902 if messages.last().map(|m| m.role.as_str()) == Some("assistant") {
903 messages.push(leviath_providers::Message {
904 role: "user".to_string(),
905 content: "Continue.".into(),
906 cache_breakpoint: false,
907 });
908 }
909
910 AssembledContext {
911 system_blocks,
912 messages,
913 }
914 }
915
916 /// Enable taint tracking on all regions in this context window.
917 pub fn enable_taint_tracking(&mut self) {
918 for region in &mut self.regions {
919 region.enable_taint_tracking();
920 }
921 }
922
923 /// Add tainted content to a specific region.
924 pub fn add_tainted_to_region(
925 &mut self,
926 region_name: &str,
927 content: String,
928 tokens: usize,
929 taint_level: leviath_core::TaintLevel,
930 ) -> leviath_core::Result<()> {
931 let Some((content, tokens)) =
932 self.on_write_outcome(region_name, content, tokens, &leviath_core::EntryKind::Text)
933 else {
934 return Ok(());
935 };
936 self.write_to_region(region_name, tokens, &mut |region, tokens| {
937 region.add_tainted_entry(content.clone(), tokens, taint_level)
938 })
939 }
940
941 /// Add a typed entry to a region with a specific taint level.
942 ///
943 /// The typed+tainted counterpart of [`add_typed_entry`](Self::add_typed_entry)
944 /// and [`add_tainted_to_region`](Self::add_tainted_to_region): the entry keeps
945 /// its `EntryKind` (so turn-group eviction stays intact) while contributing
946 /// the given taint level (so the taint gate sees sensitive tool output).
947 pub fn add_typed_tainted_to_region(
948 &mut self,
949 region_name: &str,
950 kind: leviath_core::EntryKind,
951 content: String,
952 tokens: usize,
953 taint_level: leviath_core::TaintLevel,
954 ) -> leviath_core::Result<()> {
955 let Some((content, tokens)) = self.on_write_outcome(region_name, content, tokens, &kind)
956 else {
957 return Ok(());
958 };
959 self.write_to_region(region_name, tokens, &mut |region, tokens| {
960 region.add_typed_tainted_entry(content.clone(), tokens, kind.clone(), taint_level)
961 })
962 }
963
964 /// Get the overall taint level (max across all regions).
965 /// Returns None if no region has taint tracking enabled.
966 pub fn overall_taint(&self) -> Option<leviath_core::TaintLevel> {
967 let mut max_taint = None;
968 for region in &self.regions {
969 if let Some(level) = region.taint_level() {
970 max_taint = Some(match max_taint {
971 Some(current) => level.max(current),
972 None => level,
973 });
974 }
975 }
976 max_taint
977 }
978
979 /// Get a summary of taint levels across all regions (for dashboard/audit).
980 pub fn taint_summary(&self) -> Vec<(String, leviath_core::TaintLevel)> {
981 self.regions
982 .iter()
983 .filter_map(|r| r.taint_level().map(|t| (r.name.clone(), t)))
984 .collect()
985 }
986}