Skip to main content

aacode_rs/agent/
compact.rs

1// Copyright (c) 2026 xiefujin <490021684@qq.com>
2// Licensed under GPL-3.0, see LICENSE file for full license terms.
3
4//! Context compaction — round-aware compact view.
5//!
6//! Ported (simplified) from Python `utils/message_utils.py` +
7//! `react_loop._compact_context`. A "round" starts at a user message and runs
8//! until the next user message. When token budget is exceeded we keep the
9//! protected first rounds + the last rounds, and replace the middle with a
10//! single summary system message. tool_calls/tool pairs are never split
11//! because they always live inside one round.
12
13use crate::config::ContextConfig;
14use crate::llm::types::ChatMessage;
15use crate::session::estimate_tokens;
16use std::borrow::Cow;
17
18/// Estimate tokens of a whole message list.
19pub 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 // per-message overhead
33        })
34        .sum()
35}
36
37/// Split messages into rounds. The leading system message(s) form round 0's
38/// prefix; each subsequent `user` message begins a new round.
39///
40/// Returns (system_prefix, rounds) where rounds is a Vec of message slices.
41fn 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    // Leading system messages belong to the prefix.
47    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
65/// Count how many rounds from the end contain a real user message, returning
66/// the index of the round that is the Nth-from-last user round.
67fn 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/// Cached compaction state — keeps the compact-view **prefix byte-stable**
84/// across iterations so the provider's KV/prefix cache keeps hitting.
85///
86/// Without this, every iteration would re-derive the middle boundary and
87/// re-generate the summary from a shifting round list: the request prefix
88/// changes every turn and the entire KV cache is invalidated exactly in the
89/// long-context scenario where it matters most. (Mirrors the Python
90/// `_compact_summary` / `_compact_summary_msg_count` caching.)
91#[derive(Debug, Clone)]
92pub struct CompactCache {
93    /// The frozen summary text of the compacted middle.
94    pub summary: String,
95    /// Round index (in the stable round numbering) where the tail begins —
96    /// rounds `[protect_first, tail_start)` are covered by `summary`.
97    pub tail_start: usize,
98    /// Re-compact (recompute boundary + summary, accepting one cache miss)
99    /// when the view grows beyond this many tokens again.
100    pub recompact_at: usize,
101}
102
103/// Build a compact view using (and maintaining) a [`CompactCache`] so the
104/// view prefix stays stable across iterations. Returns
105/// (view, was_compacted, token_count).
106///
107/// On the fast path (no compaction needed) the view borrows from `messages`
108/// via `Cow::Borrowed`, avoiding a full clone.
109pub 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    // Fast path: under budget and no compaction has happened yet.
117    if cache.is_none() && total <= cfg.compact_trigger_tokens {
118        return (Cow::Borrowed(messages), false, total);
119    }
120
121    // Reuse the frozen boundary + summary while the compact view fits.
122    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            // Tail grew too large again → fall through to re-compact
139            // (one intentional cache miss, then stable again).
140        }
141    }
142
143    // (Re)compute the boundary + summary and freeze them.
144    let (view, compacted, tokens) = build_compact_view(messages, cfg, None);
145    if compacted {
146        // Recover the frozen boundary from the freshly built view: the
147        // summary sits right after system prefix + protected rounds.
148        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            // Re-freeze when the frozen view grows 50% beyond its size at
166            // freeze time (relative to the actual view, not the trigger —
167            // the compacted view may legitimately exceed a small trigger).
168            recompact_at: (tokens + tokens / 2).max(cfg.compact_trigger_tokens),
169        });
170    }
171    (Cow::Owned(view), compacted, tokens)
172}
173
174/// The summary system message (byte-stable formatting).
175fn 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
181/// Where the tail would start for the current messages (same logic as
182/// build_compact_view's boundary selection).
183fn 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
198/// Build a compact view of the messages if over budget. Returns
199/// (view, was_compacted, token_count). Does not mutate the input.
200///
201/// `summary` (if provided) is the cached AI-generated summary of the middle.
202pub 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    // Determine the boundary: summarize rounds before the last-N-user round.
218    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            // summarize rounds [protect_first, user_idx)
223            if user_idx <= protect_first {
224                // nothing to compact
225                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    // protected first rounds
242    for r in rounds.iter().take(protect_first) {
243        view.extend(r.iter().cloned());
244    }
245    // summary system message for the middle
246    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    // tail rounds
256    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
264/// A cheap fallback summary of the middle rounds (used when no AI summary is
265/// cached): lists tool names invoked and truncated user asks.
266pub 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        // system prefix preserved
359        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        // every assistant-with-tool_calls must be followed by a tool result
368        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    // ───────── KV/prefix-cache stability (provider prefix caching) ─────────
392
393    /// Serialize a view for byte-stable comparison.
394    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        // Below the trigger the view is a passthrough: each iteration's
411        // request must be a prefix-extension of the previous one.
412        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); // release borrow before mutating msgs
419        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        // THE regression this cache exists for: once compaction triggers,
428        // the summary + boundary must FREEZE so later iterations only append
429        // — otherwise every long-context request is a full KV-cache miss.
430        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); // release borrow before mutating msgs
439
440        // Simulate two more agent iterations (append-only growth).
441        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        // When the tail outgrows recompact_at, the boundary re-freezes (one
465        // intentional cache miss) instead of letting the view grow unbounded.
466        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        // Grow the tail far beyond recompact_at (trigger=10 → recompact_at=15).
473        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        // And the re-frozen view must again be smaller than the raw messages.
484        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        // The fast-path view should be Cow::Borrowed, avoiding a full clone.
491        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        // Content should be identical.
496        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        // Every assistant-with-tool_calls must be directly followed by its
508        // tool result (sanitize invariant holds through cached compaction).
509        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}