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}