1use std::collections::VecDeque;
9
10use thiserror::Error;
11
12use crate::{
13 Message, ToolSpec,
14 history::{char_tail, truncate_chars},
15};
16
17#[derive(Debug, Clone)]
20pub struct ContextConfig {
21 pub max_tokens: usize,
23 pub reserve_output_tokens: usize,
25 pub safety_margin_tokens: usize,
27 pub bytes_per_token: usize,
29 pub summary_max_chars: usize,
31}
32
33impl Default for ContextConfig {
34 fn default() -> Self {
35 Self {
36 max_tokens: 128_000,
37 reserve_output_tokens: 8_192,
38 safety_margin_tokens: 2_048,
39 bytes_per_token: 3,
40 summary_max_chars: 6_000,
41 }
42 }
43}
44
45#[derive(Debug, Clone)]
47pub struct ContextSelection {
48 pub messages: Vec<Message>,
49 pub before_tokens: usize,
51 pub after_tokens: usize,
53 pub removed_messages: usize,
55}
56
57#[derive(Debug, Error)]
60#[error("{0}")]
61pub struct ContextError(pub String);
62
63pub trait ContextPolicy: Send + Sync {
65 fn select(
68 &self,
69 history: &[Message],
70 system_prompt: &str,
71 tools: &[ToolSpec],
72 ) -> Result<ContextSelection, ContextError>;
73}
74
75pub struct BudgetContextPolicy {
82 config: ContextConfig,
83}
84
85impl BudgetContextPolicy {
86 pub fn new(config: ContextConfig) -> Result<Self, ContextError> {
88 if config.bytes_per_token == 0 {
89 return Err(ContextError(
90 "context.bytes_per_token must be positive".into(),
91 ));
92 }
93 if config
94 .reserve_output_tokens
95 .saturating_add(config.safety_margin_tokens)
96 >= config.max_tokens
97 {
98 return Err(ContextError(
99 "context reserve and safety margin consume the model window".into(),
100 ));
101 }
102 Ok(Self { config })
103 }
104
105 fn string_tokens(&self, value: &str) -> usize {
106 value.len().div_ceil(self.config.bytes_per_token)
107 }
108
109 fn cost(&self, messages: &[Message]) -> usize {
111 messages
112 .iter()
113 .map(|message| message.estimated_tokens(self.config.bytes_per_token))
114 .sum()
115 }
116
117 fn group_messages(history: &[Message]) -> Vec<&[Message]> {
120 let mut groups = Vec::new();
121 let mut start = 0;
122 for (index, message) in history.iter().enumerate() {
123 if index > start && matches!(message, Message::User { .. }) {
124 groups.push(&history[start..index]);
125 start = index;
126 }
127 }
128 if start < history.len() {
129 groups.push(&history[start..]);
130 }
131 groups
132 }
133
134 fn summarize(&self, messages: &[Message]) -> String {
135 let mut output = format!(
136 "[SCV compacted {} earlier messages. Bounded extracts follow.]\n",
137 messages.len()
138 );
139 for message in messages {
140 let (label, content) = match message {
141 Message::User { content, .. } => ("user", content.as_str()),
142 Message::Assistant { content, .. } => ("assistant", content.as_str()),
143 Message::Tool {
144 name,
145 content,
146 is_error,
147 ..
148 } => {
149 let status = if *is_error { "failed" } else { "ok" };
150 output.push_str(&format!("tool {name} ({status}): "));
151 ("", content.as_str())
152 }
153 Message::HistoryNote { content } => ("earlier", content.as_str()),
154 };
155 if !label.is_empty() {
156 output.push_str(label);
157 output.push_str(": ");
158 }
159 let tail = char_tail(content, 240);
160 output.push_str(&tail.replace('\n', " "));
161 output.push('\n');
162 if output.chars().count() >= self.config.summary_max_chars {
163 break;
164 }
165 }
166 truncate_chars(&output, self.config.summary_max_chars)
167 }
168}
169
170impl ContextPolicy for BudgetContextPolicy {
171 fn select(
184 &self,
185 history: &[Message],
186 system_prompt: &str,
187 tools: &[ToolSpec],
188 ) -> Result<ContextSelection, ContextError> {
189 if history.is_empty() {
190 return Ok(ContextSelection {
191 messages: Vec::new(),
192 before_tokens: 0,
193 after_tokens: 0,
194 removed_messages: 0,
195 });
196 }
197 let tools_bytes = serde_json::to_vec(tools).map_or(0, |value| value.len());
198 let static_tokens = self
199 .string_tokens(system_prompt)
200 .saturating_add(tools_bytes.div_ceil(self.config.bytes_per_token))
201 .saturating_add(self.config.reserve_output_tokens)
202 .saturating_add(self.config.safety_margin_tokens);
203 if static_tokens >= self.config.max_tokens {
204 return Err(ContextError(
205 "system prompt and tool schemas exceed context budget".into(),
206 ));
207 }
208 let budget = self.config.max_tokens - static_tokens;
209 let before_tokens = static_tokens.saturating_add(self.cost(history));
210
211 let groups = Self::group_messages(history);
212 let (newest, older) = groups
213 .split_last()
214 .expect("a non-empty history has at least one group");
215 let mut selected_cost = self.cost(newest);
216 if selected_cost > budget {
217 return Err(ContextError("newest turn exceeds context budget".into()));
218 }
219 let mut selected: VecDeque<&[Message]> = VecDeque::from([*newest]);
220 for group in older.iter().rev() {
221 let cost = self.cost(group);
222 if selected_cost.saturating_add(cost) > budget {
223 break;
224 }
225 selected.push_front(group);
226 selected_cost += cost;
227 }
228
229 let kept_messages: usize = selected.iter().map(|group| group.len()).sum();
230 let mut removed_messages = history.len() - kept_messages;
231 let selection = |note: Option<Message>,
232 selected: VecDeque<&[Message]>,
233 cost: usize,
234 removed_messages: usize| ContextSelection {
235 messages: note
236 .into_iter()
237 .chain(selected.into_iter().flatten().cloned())
238 .collect(),
239 before_tokens,
240 after_tokens: static_tokens.saturating_add(cost),
241 removed_messages,
242 };
243 if removed_messages == 0 {
244 return Ok(selection(None, selected, selected_cost, 0));
245 }
246 loop {
247 let summary = self.summarize(&history[..removed_messages]);
248 let note = Message::HistoryNote {
249 content: summary.clone(),
250 };
251 let note_cost = note.estimated_tokens(self.config.bytes_per_token);
252 if selected_cost.saturating_add(note_cost) <= budget {
253 return Ok(selection(
254 Some(note),
255 selected,
256 selected_cost + note_cost,
257 removed_messages,
258 ));
259 }
260 if selected.len() == 1 {
261 let available_tokens = budget.saturating_sub(selected_cost);
262 let note =
263 fit_history_note(&summary, available_tokens, self.config.bytes_per_token)
264 .ok_or_else(|| {
265 ContextError("compaction note cannot fit context budget".into())
266 })?;
267 let note_cost = note.estimated_tokens(self.config.bytes_per_token);
268 return Ok(selection(
269 Some(note),
270 selected,
271 selected_cost + note_cost,
272 removed_messages,
273 ));
274 }
275 let dropped = selected
276 .pop_front()
277 .expect("more than one group is selected");
278 selected_cost = selected_cost.saturating_sub(self.cost(dropped));
279 removed_messages += dropped.len();
280 }
281 }
282}
283
284fn fit_history_note(
287 content: &str,
288 available_tokens: usize,
289 bytes_per_token: usize,
290) -> Option<Message> {
291 let chars: Vec<char> = content.chars().collect();
292 let mut low = 0usize;
293 let mut high = chars.len();
294 let mut best = None;
295 while low <= high {
296 let middle = low + (high - low) / 2;
297 let candidate = Message::HistoryNote {
298 content: chars[..middle].iter().collect(),
299 };
300 if candidate.estimated_tokens(bytes_per_token) <= available_tokens {
301 best = Some(candidate);
302 low = middle.saturating_add(1);
303 } else if middle == 0 {
304 break;
305 } else {
306 high = middle - 1;
307 }
308 }
309 best
310}
311
312#[cfg(test)]
313mod tests;