Skip to main content

leviath_runtime/components/
context_window.rs

1//! The agent's context window: what it remembers, and what it forgets first.
2//!
3//! Every region an agent holds lives here, along with the assembly that turns
4//! them into a request and the eviction that keeps them inside a budget. Split
5//! out of `components` because it was two thirds of that file on its own, and
6//! because "what an agent *is*" and "what an agent *remembers*" are different
7//! questions to arrive with.
8
9use super::*;
10
11/// Result of an eviction attempt, including tokens freed and regions needing LLM compaction.
12#[derive(Debug, Clone)]
13pub struct EvictionResult {
14    /// Number of tokens freed by eviction phases 1-2 (Clearable + Temporary).
15    pub tokens_freed: usize,
16    /// Region names that need LLM-based compaction (phase 3).
17    pub needs_compaction: Vec<String>,
18}
19
20/// Per-stage inference configuration overrides.
21///
22/// Set on the agent entity before each stage to override default inference
23/// parameters like temperature and max output tokens. When absent, defaults
24/// are used (temperature 0.7, max output 4096).
25#[derive(Component, Debug, Clone, Default)]
26pub struct InferenceConfig {
27    /// Temperature override. If None, uses 0.7 (or 0.0 if model doesn't support it).
28    pub temperature: Option<f32>,
29    /// Max output tokens override. If None, caps at model's max_output_tokens capability.
30    pub max_output_tokens: Option<usize>,
31    /// Extra provider parameters from `[stages.<name>.model.parameters]` beyond
32    /// `temperature`/`max_output_tokens` (e.g. `top_p`, `stop`, `seed`,
33    /// `frequency_penalty`). Passed through to the provider request so models can
34    /// be tuned from the manifest. Empty when none are set.
35    pub extra_params: serde_json::Map<String, serde_json::Value>,
36    /// Whether to prepend the batch-tool-calls hint to this stage's system
37    /// prompt. Resolved from the global config → agent → stage cascade at spawn
38    /// (see [`leviath_core::taint::resolve_batch_tool_hint`]); `false` by default
39    /// so an unset config is a no-op.
40    pub batch_tool_hint: bool,
41    /// Whether this stage is eligible for the platform shell hint. Resolved from
42    /// the global config → agent → stage cascade at spawn (see
43    /// [`leviath_core::taint::resolve_shell_hint`]); `false` by default so an
44    /// unset config is a no-op. Eligibility is not emission: the hint also needs
45    /// a platform worth describing and a stage that advertises the shell tool.
46    pub shell_hint: bool,
47    /// Per-stage cap on the wall-clock time (in seconds) one inference for this
48    /// stage may run (the whole call including retries). Sourced from
49    /// `[stages.<name>.model] request_timeout_secs`. When `Some`, it overrides the
50    /// default inference job timeout at dispatch; when `None`, the default applies.
51    pub request_timeout_secs: Option<u64>,
52}
53
54/// Per-entity tool result routing configuration.
55///
56/// When present on an entity, tool results are routed to the specified region(s)
57/// instead of the default "conversation" region.
58#[derive(Component, Debug, Clone)]
59pub struct ToolResultRoutingComponent {
60    /// The routing configuration.
61    pub routing: leviath_core::ToolResultRouting,
62}
63
64/// Result of assembling a context window into system blocks and conversation messages.
65///
66/// Produced by [`ContextWindow::assemble()`]. System-bound regions (Pinned,
67/// CompactHistory, etc.) become `system_blocks`; the messages region
68/// (SlidingWindow) becomes typed `messages`.
69#[derive(Debug, Clone)]
70pub struct AssembledContext {
71    /// System prompt blocks (from Pinned, CompactHistory, etc. regions).
72    pub system_blocks: Vec<leviath_providers::SystemBlock>,
73    /// Conversation messages with proper role typing.
74    pub messages: Vec<leviath_providers::Message>,
75}
76
77/// Sort priority for a system block's cache hint.
78///
79/// Anthropic caches system content by prefix matching, so the most stable
80/// blocks must sort first to form the cacheable prefix. Lower value = earlier.
81pub(super) fn cache_hint_sort_priority(hint: leviath_core::CacheHint) -> u8 {
82    use leviath_core::CacheHint;
83    match hint {
84        CacheHint::Always => 0,               // Pinned, CompactHistory - most stable
85        CacheHint::SlidingPrefix { .. } => 1, // Partially stable
86        CacheHint::UntilChanged => 2,         // Compacting - changes on compaction
87        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, .. } = &region.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(&region.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(&region.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 &region.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 &region.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}