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