1use crate::config::ContextConfig;
14use crate::llm::types::ChatMessage;
15use crate::session::estimate_tokens;
16use std::borrow::Cow;
17
18pub fn estimate_messages_tokens(messages: &[ChatMessage]) -> usize {
20 messages
21 .iter()
22 .map(|m| {
23 let mut t = estimate_tokens(&m.content);
24 if let Some(rc) = &m.reasoning_content {
25 t += estimate_tokens(rc);
26 }
27 if let Some(tcs) = &m.tool_calls {
28 for tc in tcs {
29 t += estimate_tokens(&tc.arguments) + estimate_tokens(&tc.name);
30 }
31 }
32 t + 4 })
34 .sum()
35}
36
37fn split_into_rounds(messages: &[ChatMessage]) -> (Vec<ChatMessage>, Vec<Vec<ChatMessage>>) {
42 let mut system_prefix = Vec::new();
43 let mut rounds: Vec<Vec<ChatMessage>> = Vec::new();
44 let mut idx = 0;
45
46 while idx < messages.len() && messages[idx].role == "system" {
48 system_prefix.push(messages[idx].clone());
49 idx += 1;
50 }
51
52 let mut current: Vec<ChatMessage> = Vec::new();
53 for m in &messages[idx..] {
54 if m.role == "user" && !current.is_empty() {
55 rounds.push(std::mem::take(&mut current));
56 }
57 current.push(m.clone());
58 }
59 if !current.is_empty() {
60 rounds.push(current);
61 }
62 (system_prefix, rounds)
63}
64
65fn last_n_user_round_index(rounds: &[Vec<ChatMessage>], n: usize) -> Option<usize> {
68 if n == 0 {
69 return None;
70 }
71 let mut seen = 0;
72 for i in (0..rounds.len()).rev() {
73 if rounds[i].iter().any(|m| m.role == "user") {
74 seen += 1;
75 if seen == n {
76 return Some(i);
77 }
78 }
79 }
80 None
81}
82
83#[derive(Debug, Clone)]
92pub struct CompactCache {
93 pub summary: String,
95 pub tail_start: usize,
98 pub recompact_at: usize,
101}
102
103pub fn build_compact_view_cached<'a>(
110 messages: &'a [ChatMessage],
111 cfg: &ContextConfig,
112 cache: &mut Option<CompactCache>,
113) -> (Cow<'a, [ChatMessage]>, bool, usize) {
114 let total = estimate_messages_tokens(messages);
115
116 if cache.is_none() && total <= cfg.compact_trigger_tokens {
118 return (Cow::Borrowed(messages), false, total);
119 }
120
121 if let Some(c) = cache.as_ref() {
123 let (system_prefix, rounds) = split_into_rounds(messages);
124 if c.tail_start <= rounds.len() {
125 let mut view: Vec<ChatMessage> = Vec::new();
126 view.extend(system_prefix.iter().cloned());
127 for r in rounds.iter().take(cfg.protect_first_rounds) {
128 view.extend(r.iter().cloned());
129 }
130 view.push(summary_message(&c.summary));
131 for r in rounds.iter().skip(c.tail_start) {
132 view.extend(r.iter().cloned());
133 }
134 let view_total = estimate_messages_tokens(&view);
135 if view_total <= c.recompact_at {
136 return (Cow::Owned(view), true, view_total);
137 }
138 }
141 }
142
143 let (view, compacted, tokens) = build_compact_view(messages, cfg, None);
145 if compacted {
146 let (_, rounds) = split_into_rounds(messages);
149 let tail_start = tail_start_for(messages, cfg).unwrap_or(rounds.len());
150 let summary = view
151 .iter()
152 .find(|m| m.role == "system" && m.content.starts_with("## 🧠 History Summary"))
153 .map(|m| {
154 m.content
155 .trim_start_matches("## 🧠 History Summary (compacted)\n")
156 .trim_end_matches(
157 "\n\n(Older turns compacted; continue based on recent context.)",
158 )
159 .to_string()
160 })
161 .unwrap_or_default();
162 *cache = Some(CompactCache {
163 summary,
164 tail_start,
165 recompact_at: (tokens + tokens / 2).max(cfg.compact_trigger_tokens),
169 });
170 }
171 (Cow::Owned(view), compacted, tokens)
172}
173
174fn summary_message(summary: &str) -> ChatMessage {
176 ChatMessage::system(format!(
177 "## 🧠 History Summary (compacted)\n{summary}\n\n(Older turns compacted; continue based on recent context.)"
178 ))
179}
180
181fn tail_start_for(messages: &[ChatMessage], cfg: &ContextConfig) -> Option<usize> {
184 let (_, rounds) = split_into_rounds(messages);
185 match last_n_user_round_index(&rounds, cfg.protect_last_user_rounds) {
186 Some(user_idx) if user_idx > cfg.protect_first_rounds => Some(user_idx),
187 Some(_) => None,
188 None => {
189 if rounds.len() <= cfg.protect_first_rounds + cfg.keep_last_rounds {
190 None
191 } else {
192 Some(rounds.len().saturating_sub(cfg.keep_last_rounds))
193 }
194 }
195 }
196}
197
198pub fn build_compact_view(
203 messages: &[ChatMessage],
204 cfg: &ContextConfig,
205 summary: Option<&str>,
206) -> (Vec<ChatMessage>, bool, usize) {
207 let total = estimate_messages_tokens(messages);
208 if total <= cfg.compact_trigger_tokens {
209 return (messages.to_vec(), false, total);
210 }
211
212 let (system_prefix, rounds) = split_into_rounds(messages);
213 let protect_first = cfg.protect_first_rounds;
214 let keep_last = cfg.keep_last_rounds;
215 let protect_user = cfg.protect_last_user_rounds;
216
217 let cut_end = last_n_user_round_index(&rounds, protect_user);
219
220 let (middle_range, tail_start) = match cut_end {
221 Some(user_idx) => {
222 if user_idx <= protect_first {
224 return (messages.to_vec(), false, total);
226 }
227 ((protect_first, user_idx), user_idx)
228 }
229 None => {
230 if rounds.len() <= protect_first + keep_last {
231 return (messages.to_vec(), false, total);
232 }
233 let start = protect_first;
234 let end = rounds.len().saturating_sub(keep_last);
235 ((start, end), end)
236 }
237 };
238
239 let mut view: Vec<ChatMessage> = Vec::new();
240 view.extend(system_prefix.iter().cloned());
241 for r in rounds.iter().take(protect_first) {
243 view.extend(r.iter().cloned());
244 }
245 let (ms, me) = middle_range;
247 if me > ms {
248 let summarized = summary
249 .map(|s| s.to_string())
250 .unwrap_or_else(|| heuristic_summary(&rounds[ms..me]));
251 view.push(ChatMessage::system(format!(
252 "## 🧠 History Summary (compacted)\n{summarized}\n\n(Older turns compacted; continue based on recent context.)"
253 )));
254 }
255 for r in rounds.iter().skip(tail_start) {
257 view.extend(r.iter().cloned());
258 }
259
260 let new_total = estimate_messages_tokens(&view);
261 (view, true, new_total)
262}
263
264pub fn heuristic_summary(middle: &[Vec<ChatMessage>]) -> String {
267 let mut tools = Vec::new();
268 let mut asks = Vec::new();
269 for round in middle {
270 for m in round {
271 if m.role == "user" {
272 let a: String = m.content.chars().take(80).collect();
273 if !a.trim().is_empty() {
274 asks.push(a);
275 }
276 }
277 if let Some(tcs) = &m.tool_calls {
278 for tc in tcs {
279 tools.push(tc.name.clone());
280 }
281 }
282 }
283 }
284 let mut out = String::new();
285 if !asks.is_empty() {
286 out.push_str("Earlier requests:\n");
287 for a in asks.iter().take(10) {
288 out.push_str(&format!("- {a}\n"));
289 }
290 }
291 if !tools.is_empty() {
292 out.push_str(&format!("Tools used: {}\n", dedup_join(&tools)));
293 }
294 if out.is_empty() {
295 out.push_str("(prior conversation compacted)");
296 }
297 out
298}
299
300fn dedup_join(items: &[String]) -> String {
301 let mut seen = std::collections::BTreeMap::new();
302 for i in items {
303 *seen.entry(i.clone()).or_insert(0usize) += 1;
304 }
305 seen.into_iter()
306 .map(|(k, v)| format!("{k}×{v}"))
307 .collect::<Vec<_>>()
308 .join(", ")
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314 use crate::llm::types::ToolCall;
315
316 fn cfg(trigger: usize) -> ContextConfig {
317 ContextConfig {
318 compact_trigger_tokens: trigger,
319 protect_first_rounds: 1,
320 keep_last_rounds: 2,
321 protect_last_user_rounds: 1,
322 ..Default::default()
323 }
324 }
325
326 fn convo(rounds: usize) -> Vec<ChatMessage> {
327 let mut v = vec![ChatMessage::system("SYS")];
328 for i in 0..rounds {
329 v.push(ChatMessage::user(format!("ask {i} {}", "x".repeat(200))));
330 v.push(ChatMessage::assistant_with_tools(
331 "",
332 vec![ToolCall {
333 id: format!("c{i}"),
334 name: "run_shell".into(),
335 arguments: "{}".into(),
336 }],
337 ));
338 v.push(ChatMessage::tool_result(format!("c{i}"), "y".repeat(200)));
339 }
340 v
341 }
342
343 #[test]
344 fn no_compaction_below_trigger() {
345 let msgs = convo(2);
346 let (view, compacted, _) = build_compact_view(&msgs, &cfg(1_000_000), None);
347 assert!(!compacted);
348 assert_eq!(view.len(), msgs.len());
349 }
350
351 #[test]
352 fn compaction_reduces_tokens() {
353 let msgs = convo(10);
354 let before = estimate_messages_tokens(&msgs);
355 let (view, compacted, after) = build_compact_view(&msgs, &cfg(100), None);
356 assert!(compacted);
357 assert!(after < before);
358 assert_eq!(view[0].role, "system");
360 assert_eq!(view[0].content, "SYS");
361 }
362
363 #[test]
364 fn tool_pairs_not_split() {
365 let msgs = convo(10);
366 let (view, _, _) = build_compact_view(&msgs, &cfg(100), None);
367 for i in 0..view.len() {
369 if view[i].tool_calls.is_some() {
370 let has_result = view.iter().skip(i + 1).any(|m| m.role == "tool");
371 assert!(has_result, "tool_calls without following tool result");
372 }
373 }
374 }
375
376 #[test]
377 fn cached_summary_used() {
378 let msgs = convo(10);
379 let (view, _, _) = build_compact_view(&msgs, &cfg(100), Some("MY SUMMARY"));
380 assert!(view.iter().any(|m| m.content.contains("MY SUMMARY")));
381 }
382
383 #[test]
384 fn heuristic_summary_lists_tools() {
385 let msgs = convo(4);
386 let (_, rounds) = split_into_rounds(&msgs);
387 let s = heuristic_summary(&rounds);
388 assert!(s.contains("run_shell"));
389 }
390
391 fn fingerprint(view: &[ChatMessage]) -> Vec<String> {
395 view.iter()
396 .map(|m| {
397 format!(
398 "{}|{}|{:?}|{:?}",
399 m.role,
400 m.content,
401 m.tool_calls.as_ref().map(|t| t.len()),
402 m.tool_call_id
403 )
404 })
405 .collect()
406 }
407
408 #[test]
409 fn kv_prefix_stable_without_compaction() {
410 let mut cache = None;
413 let mut msgs = convo(2);
414 let (v1, c1, _) = build_compact_view_cached(&msgs, &cfg(1_000_000), &mut cache);
415 assert!(!c1);
416 let v1_fp = fingerprint(&v1);
417 let v1_len = v1.len();
418 drop(v1); msgs.push(ChatMessage::user("next question"));
420 let (v2, _, _) = build_compact_view_cached(&msgs, &cfg(1_000_000), &mut cache);
421 let v2_fp = fingerprint(&v2);
422 assert_eq!(v1_fp, v2_fp[..v1_len], "prefix must be stable");
423 }
424
425 #[test]
426 fn kv_prefix_stable_after_compaction() {
427 let mut cache = None;
431 let mut msgs = convo(12);
432 let (v1, c1, _) = build_compact_view_cached(&msgs, &cfg(10), &mut cache);
433 assert!(c1, "should compact");
434 assert!(cache.is_some(), "cache must be populated");
435
436 let v1_fp = fingerprint(&v1);
437 let v1_len = v1.len();
438 drop(v1); msgs.push(ChatMessage::user("follow-up 1"));
442 let (v2, _, _) = build_compact_view_cached(&msgs, &cfg(10), &mut cache);
443 assert!(v2.len() > v1_len);
444 let v2_fp = fingerprint(&v2);
445 assert_eq!(
446 v1_fp,
447 v2_fp[..v1_len],
448 "compacted view prefix must be byte-stable"
449 );
450 let v2_len = v2.len();
451 drop(v2);
452
453 msgs.push(ChatMessage::user("follow-up 2"));
454 let (v3, _, _) = build_compact_view_cached(&msgs, &cfg(10), &mut cache);
455 assert_eq!(
456 v2_fp,
457 fingerprint(&v3[..v2_len]),
458 "second append must also extend the prefix"
459 );
460 }
461
462 #[test]
463 fn recompacts_when_tail_overflows() {
464 let mut cache = None;
467 let mut msgs = convo(12);
468 let (_, c1, _) = build_compact_view_cached(&msgs, &cfg(10), &mut cache);
469 assert!(c1);
470 let first_tail = cache.as_ref().unwrap().tail_start;
471
472 for i in 0..30 {
474 msgs.push(ChatMessage::user(format!("grow {i} {}", "z".repeat(300))));
475 }
476 let (view, c2, tokens) = build_compact_view_cached(&msgs, &cfg(10), &mut cache);
477 assert!(c2);
478 let new_tail = cache.as_ref().unwrap().tail_start;
479 assert!(
480 new_tail > first_tail,
481 "boundary must advance on re-compaction ({first_tail} → {new_tail})"
482 );
483 assert!(tokens < estimate_messages_tokens(&msgs));
485 assert!(view.iter().any(|m| m.content.contains("History Summary")));
486 }
487
488 #[test]
489 fn fast_path_avoids_clone_with_cow() {
490 let mut cache = None;
492 let msgs = convo(3);
493 let (view, compacted, _) = build_compact_view_cached(&msgs, &cfg(1_000_000), &mut cache);
494 assert!(!compacted);
495 assert_eq!(view.len(), msgs.len());
497 assert_eq!(view[0].content, msgs[0].content);
498 }
499
500 #[test]
501 fn tool_pairs_not_split_in_cached_view() {
502 let mut cache = None;
503 let mut msgs = convo(12);
504 let _ = build_compact_view_cached(&msgs, &cfg(10), &mut cache);
505 msgs.push(ChatMessage::user("more"));
506 let (view, _, _) = build_compact_view_cached(&msgs, &cfg(10), &mut cache);
507 for i in 0..view.len() {
510 if view[i].tool_calls.is_some() {
511 assert!(
512 i + 1 < view.len() && view[i + 1].role == "tool",
513 "tool pair split at index {i}"
514 );
515 }
516 }
517 }
518}