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//! **Legacy**: production now uses retrieval-style context control
7//! (`agent::retrieval`): the full history is kept and the model view starts at
8//! a frozen cutoff, with omitted messages retrievable via the history tools.
9//! No summary is generated anymore.
10//!
11//! This module is kept for the shared [`estimate_messages_tokens`] helper and
12//! for the old summary-based tests; `react_loop` no longer calls
13//! `build_compact_view_cached`.
14
15use crate::config::ContextConfig;
16use crate::llm::types::ChatMessage;
17use crate::session::estimate_tokens;
18use std::borrow::Cow;
19
20/// Estimate tokens of a whole message list.
21pub 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 // per-message overhead
35        })
36        .sum()
37}
38
39/// Split messages into rounds. The leading system message(s) form round 0's
40/// prefix; each subsequent `user` message begins a new round.
41///
42/// Returns (system_prefix, rounds) where rounds is a Vec of message slices.
43fn 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    // Leading system messages belong to the prefix.
49    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
67/// Count how many rounds from the end contain a real user message, returning
68/// the index of the round that is the Nth-from-last user round.
69fn 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/// Cached compaction state — keeps the compact-view **prefix byte-stable**
86/// across iterations so the provider's KV/prefix cache keeps hitting.
87///
88/// Without this, every iteration would re-derive the middle boundary and
89/// re-generate the summary from a shifting round list: the request prefix
90/// changes every turn and the entire KV cache is invalidated exactly in the
91/// long-context scenario where it matters most. (Mirrors the Python
92/// `_compact_summary` / `_compact_summary_msg_count` caching.)
93#[derive(Debug, Clone)]
94pub struct CompactCache {
95    /// The frozen summary text of the compacted middle.
96    pub summary: String,
97    /// Round index (in the stable round numbering) where the tail begins —
98    /// rounds `[protect_first, tail_start)` are covered by `summary`.
99    pub tail_start: usize,
100    /// Re-compact (recompute boundary + summary, accepting one cache miss)
101    /// when the view grows beyond this many tokens again.
102    pub recompact_at: usize,
103}
104
105/// Build a compact view using (and maintaining) a [`CompactCache`] so the
106/// view prefix stays stable across iterations. Returns
107/// (view, was_compacted, token_count).
108///
109/// On the fast path (no compaction needed) the view borrows from `messages`
110/// via `Cow::Borrowed`, avoiding a full clone.
111pub 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    // Fast path: under budget and no compaction has happened yet.
119    if cache.is_none() && total <= cfg.compact_trigger_tokens {
120        return (Cow::Borrowed(messages), false, total);
121    }
122
123    // Reuse the frozen boundary + summary while the compact view fits.
124    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            // Tail grew too large again → fall through to re-compact
141            // (one intentional cache miss, then stable again).
142        }
143    }
144
145    // (Re)compute the boundary + summary and freeze them.
146    let (view, compacted, tokens) = build_compact_view(messages, cfg, None);
147    if compacted {
148        // Recover the frozen boundary from the freshly built view: the
149        // summary sits right after system prefix + protected rounds.
150        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            // Re-freeze when the frozen view grows 50% beyond its size at
168            // freeze time (relative to the actual view, not the trigger —
169            // the compacted view may legitimately exceed a small trigger).
170            recompact_at: (tokens + tokens / 2).max(cfg.compact_trigger_tokens),
171        });
172    }
173    (Cow::Owned(view), compacted, tokens)
174}
175
176/// The summary system message (byte-stable formatting).
177fn 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
183/// Where the tail would start for the current messages (same logic as
184/// build_compact_view's boundary selection).
185fn 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
200/// Build a compact view of the messages if over budget. Returns
201/// (view, was_compacted, token_count). Does not mutate the input.
202///
203/// `summary` (if provided) is the cached AI-generated summary of the middle.
204pub 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    // Determine the boundary: summarize rounds before the last-N-user round.
220    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            // summarize rounds [protect_first, user_idx)
225            if user_idx <= protect_first {
226                // nothing to compact
227                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    // protected first rounds
244    for r in rounds.iter().take(protect_first) {
245        view.extend(r.iter().cloned());
246    }
247    // summary system message for the middle
248    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    // tail rounds
258    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
266/// A cheap fallback summary of the middle rounds (used when no AI summary is
267/// cached): lists tool names invoked and truncated user asks.
268pub 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        // system prefix preserved
361        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        // every assistant-with-tool_calls must be followed by a tool result
370        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    // ───────── KV/prefix-cache stability (provider prefix caching) ─────────
394
395    /// Serialize a view for byte-stable comparison.
396    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        // Below the trigger the view is a passthrough: each iteration's
413        // request must be a prefix-extension of the previous one.
414        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); // release borrow before mutating msgs
421        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        // THE regression this cache exists for: once compaction triggers,
430        // the summary + boundary must FREEZE so later iterations only append
431        // — otherwise every long-context request is a full KV-cache miss.
432        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); // release borrow before mutating msgs
441
442        // Simulate two more agent iterations (append-only growth).
443        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        // When the tail outgrows recompact_at, the boundary re-freezes (one
467        // intentional cache miss) instead of letting the view grow unbounded.
468        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        // Grow the tail far beyond recompact_at (trigger=10 → recompact_at=15).
475        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        // And the re-frozen view must again be smaller than the raw messages.
486        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        // The fast-path view should be Cow::Borrowed, avoiding a full clone.
493        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        // Content should be identical.
498        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        // Every assistant-with-tool_calls must be directly followed by its
510        // tool result (sanitize invariant holds through cached compaction).
511        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}