Skip to main content

leviath_core/region/
evict.rs

1//! Making room in a [`Region`]: what leaves when a write does not fit, and
2//! how a sliding window keeps to its item cap. Split out of `mod.rs` for
3//! size; a child module so it stays on the struct's private fields.
4
5use super::*;
6
7impl Region {
8    /// Roll off the oldest entries until `tokens` more would fit, and report
9    /// how many were dropped.
10    ///
11    /// This is what [`Admission::Evict`] has always claimed to do. Stops as
12    /// soon as the write fits, and does not start at all when it never can:
13    /// an entry larger than `max_tokens` does not fit an *empty* region either,
14    /// so evicting for it would destroy everything held and still fail. The
15    /// caller truncates instead, which is the right answer and needs the region
16    /// intact to have anywhere to put the truncation.
17    ///
18    /// Goes through [`remove_oldest`](Self::remove_oldest) rather than touching
19    /// `content` directly because that method is turn-group aware: an
20    /// `AssistantTurn` carrying tool calls leaves together with the
21    /// `ToolResult` entries that answer it, so eviction never strands a
22    /// `tool_use` that a provider would then reject.
23    pub fn make_room(&mut self, tokens: usize) -> usize {
24        if tokens > self.max_tokens {
25            return 0;
26        }
27        let mut evicted = 0;
28        while self.current_tokens + tokens > self.max_tokens && self.remove_oldest().is_some() {
29            evicted += 1;
30        }
31        evicted
32    }
33
34    /// Whether one more entry would push a sliding window past its item cap.
35    ///
36    /// Only sliding windows roll off by count; every other kind is bounded by
37    /// tokens alone and is answered by the budget check above.
38    pub(super) fn would_roll_off(&self) -> bool {
39        match &self.kind {
40            RegionKind::SlidingWindow { max_items, .. } => self.content.len() + 1 > *max_items,
41            _ => false,
42        }
43    }
44
45    /// Drop the `n` oldest entries, returning how many were actually removed.
46    ///
47    /// Fewer than `n` when the region holds fewer, which is not an error: the
48    /// agent asked for room and got as much as there was.
49    pub fn release_oldest(&mut self, n: usize) -> usize {
50        let count = n.min(self.content.len());
51        for _ in 0..count {
52            self.remove_oldest();
53        }
54        count
55    }
56
57    /// Evict the least-recently-updated entry (LRU) for HashMap regions.
58    pub(super) fn evict_lru_entry(&mut self) {
59        if self.content.is_empty() {
60            return;
61        }
62        let oldest_idx = self
63            .content
64            .iter()
65            .enumerate()
66            .min_by_key(|(_, e)| e.timestamp)
67            .map(|(i, _)| i)
68            .unwrap_or(0);
69        let tokens = self.content[oldest_idx].tokens;
70        self.content.remove(oldest_idx);
71        self.current_tokens -= tokens;
72        if let Some(taint) = &mut self.taint {
73            taint.remove_at(oldest_idx);
74        }
75    }
76
77    /// Enforce the SlidingWindow max_items limit by removing oldest entries.
78    ///
79    /// Behaviour depends on the configured [`EvictionStrategy`]:
80    /// - **PerItem** - evict one turn group at a time; the default.
81    /// - **Bulk** - only evict when `len > max_items + overflow`, then evict
82    ///   down to `max_items`. Between bulk evictions the prefix is stable,
83    ///   which preserves Anthropic prompt-cache keys.
84    /// - **Compact** - set `needs_message_compaction` when `len > max_items + compact_count`.
85    ///   If the runtime hasn't compacted and `len > max_items + compact_count * 2`,
86    ///   fall back to bulk eviction to prevent unbounded growth.
87    pub(super) fn enforce_sliding_window(&mut self) {
88        if let RegionKind::SlidingWindow {
89            max_items,
90            eviction_strategy,
91        } = &self.kind
92        {
93            let max = *max_items;
94            match *eviction_strategy {
95                EvictionStrategy::PerItem => {
96                    // `remove_oldest` only returns None when empty, which the
97                    // `len > max >= 0` guard already precludes; folding it into
98                    // the condition keeps the guard without a dead break arm.
99                    while self.content.len() > max && self.remove_oldest().is_some() {}
100                }
101                EvictionStrategy::Bulk { overflow } => {
102                    if self.content.len() > max.saturating_add(overflow) {
103                        while self.content.len() > max && self.remove_oldest().is_some() {}
104                    }
105                }
106                EvictionStrategy::Compact { compact_count } => {
107                    if self.content.len() > max.saturating_add(compact_count.saturating_mul(2)) {
108                        // Fallback: runtime hasn't compacted, bulk-evict to prevent
109                        // unbounded growth.
110                        while self.content.len() > max && self.remove_oldest().is_some() {}
111                        self.needs_message_compaction = false;
112                    } else if self.content.len() > max.saturating_add(compact_count) {
113                        self.needs_message_compaction = true;
114                    }
115                }
116            }
117        }
118    }
119
120    /// Returns the number of entries in the turn group starting at `idx`.
121    ///
122    /// A turn group is:
123    /// - A single Text or UserMessage entry (group size = 1)
124    /// - An AssistantTurn followed by consecutive ToolResult entries
125    ///   (group size = 1 + number of following ToolResults)
126    /// - A lone ToolResult (shouldn't happen, but size = 1 for safety)
127    pub(super) fn turn_group_size_at(&self, idx: usize) -> usize {
128        if idx >= self.content.len() {
129            return 0;
130        }
131        match &self.content[idx].kind {
132            EntryKind::AssistantTurn { .. } => {
133                let mut size = 1;
134                while idx + size < self.content.len() {
135                    if matches!(self.content[idx + size].kind, EntryKind::ToolResult { .. }) {
136                        size += 1;
137                    } else {
138                        break;
139                    }
140                }
141                size
142            }
143            _ => 1,
144        }
145    }
146
147    /// Remove the oldest entry (for Temporary regions).
148    pub fn remove_oldest(&mut self) -> Option<RegionEntry> {
149        if self.content.is_empty() {
150            return None;
151        }
152        // Respect turn groups: an AssistantTurn with tool_calls must be
153        // evicted together with its following ToolResult entries to avoid
154        // orphaned tool_use/tool_result blocks that providers reject.
155        let group_size = self.turn_group_size_at(0);
156        let mut first = None;
157        let mut extra_tokens = 0usize;
158        // `group_size <= content.len()`, so the window never empties mid-group;
159        // the `!is_empty()` guard lives in the loop condition (no dead break arm).
160        let mut i = 0;
161        while i < group_size && !self.content.is_empty() {
162            let entry_tokens = self.content[0].tokens;
163            self.current_tokens -= entry_tokens;
164            let removed = self.content.remove(0);
165            if let Some(taint) = &mut self.taint {
166                taint.remove_oldest();
167            }
168            if i == 0 {
169                first = Some(removed);
170            } else {
171                extra_tokens += entry_tokens;
172            }
173            i += 1;
174        }
175        // Embed extra group tokens in the returned entry so callers that use
176        // `entry.tokens` to adjust their own totals account for the full group.
177        // `first` is `Some` whenever we removed anything (guaranteed by the
178        // non-empty early return), so `map` always runs; `extra_tokens` is 0
179        // for a single-entry group, making the add a no-op there.
180        first.map(|mut entry| {
181            entry.tokens += extra_tokens;
182            entry
183        })
184    }
185}