Skip to main content

aacode_rs/agent/
retrieval.rs

1// Copyright (c) 2026 xiefujin <490021684@qq.com>
2// Licensed under GPL-3.0, see LICENSE file for full license terms.
3
4//! Retrieval-style context control (replaces summary compaction).
5//!
6//! Instead of summarizing and deleting history, the full `messages` list is
7//! kept intact. The model is sent a *view* that starts at a frozen **cutoff**;
8//! older messages stay retrievable through `search_history` / `read_history`.
9//! The cutoff only moves forward and only when the view exceeds the trigger,
10//! which keeps the request prefix byte-stable for provider prompt caching.
11
12use crate::agent::compact::estimate_messages_tokens;
13use crate::config::ContextConfig;
14use crate::llm::types::ChatMessage;
15use crate::session::now_iso;
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use std::path::Path;
19
20pub const CHECKPOINT_OPEN: &str = "<conversation-checkpoint>";
21pub const CHECKPOINT_CLOSE: &str = "</conversation-checkpoint>";
22
23/// Persisted model-view cutoff for one session.
24#[derive(Debug, Clone, Serialize, Deserialize, Default)]
25pub struct ContextCheckpoint {
26    pub cutoff_index: usize,
27    #[serde(default)]
28    pub stub: String,
29    #[serde(default)]
30    pub session_id: String,
31    #[serde(default)]
32    pub created_at: String,
33    #[serde(default)]
34    pub keep_tokens: usize,
35}
36
37/// A real user turn (excludes host-injected messages and checkpoint stubs).
38pub fn is_real_user_message(m: &ChatMessage) -> bool {
39    m.role == "user"
40        && !m.content.starts_with("[System]")
41        && !m.content.starts_with(CHECKPOINT_OPEN)
42}
43
44fn real_user_indices(messages: &[ChatMessage]) -> Vec<usize> {
45    messages
46        .iter()
47        .enumerate()
48        .filter(|(_, m)| is_real_user_message(m))
49        .map(|(i, _)| i)
50        .collect()
51}
52
53/// A round starts at the system prompt or any non-tool message.
54fn round_start_indices(messages: &[ChatMessage]) -> Vec<usize> {
55    messages
56        .iter()
57        .enumerate()
58        .filter(|(i, m)| *i == 0 || m.role != "tool")
59        .map(|(i, _)| i)
60        .collect()
61}
62
63fn next_index(indices: &[usize], value: usize) -> Option<usize> {
64    indices.iter().copied().find(|i| *i > value)
65}
66
67fn truncate_chars(s: &str, n: usize) -> String {
68    s.chars().take(n).collect()
69}
70
71fn estimate_view_tokens(
72    messages: &[ChatMessage],
73    cutoff: usize,
74    latest_user: Option<usize>,
75    stub_tokens: usize,
76) -> usize {
77    if cutoff <= 0 {
78        return estimate_messages_tokens(messages);
79    }
80    let mut view: Vec<ChatMessage> = Vec::new();
81    if let Some(first) = messages.first() {
82        view.push(first.clone());
83    }
84    if stub_tokens > 0 {
85        view.push(ChatMessage::user("x".repeat(stub_tokens * 4)));
86    }
87    if let Some(lu) = latest_user {
88        if cutoff > lu && lu < messages.len() {
89            view.push(messages[lu].clone());
90            let mut i = lu + 1;
91            while i < cutoff && messages.get(i).map(|m| m.role.as_str()) == Some("system") {
92                view.push(messages[i].clone());
93                i += 1;
94            }
95        }
96    }
97    view.extend_from_slice(&messages[cutoff.min(messages.len())..]);
98    estimate_messages_tokens(&view)
99}
100
101/// Choose where the model view should start. Never moves the cutoff backwards.
102///
103/// Returns `(cutoff, latest_user_index)`.
104pub fn compute_retrieval_cutoff(
105    messages: &[ChatMessage],
106    current_cutoff: usize,
107    keep_tokens: usize,
108    keep_recent_user_messages: usize,
109    keep_min_rounds: usize,
110) -> (usize, Option<usize>) {
111    if messages.is_empty() {
112        return (0, None);
113    }
114    let count = messages.len();
115    let current = current_cutoff.min(count);
116    let users = real_user_indices(messages);
117    let starts = round_start_indices(messages);
118    let latest_user = users.last().copied();
119
120    let Some(latest_user) = latest_user else {
121        let candidate = if starts.len() >= keep_min_rounds {
122            starts[starts.len() - keep_min_rounds]
123        } else {
124            0
125        };
126        return (current.max(candidate), None);
127    };
128
129    let mut candidate = if users.len() >= keep_recent_user_messages {
130        users[users.len() - keep_recent_user_messages]
131    } else {
132        users[0]
133    };
134    candidate = candidate.max(current);
135
136    if starts.len() >= keep_min_rounds {
137        candidate = current.max(candidate.min(starts[starts.len() - keep_min_rounds]));
138    }
139
140    // Drop whole older user rounds until the view fits the budget.
141    while candidate < latest_user {
142        let tokens = estimate_view_tokens(messages, candidate, Some(latest_user), 2000);
143        if tokens <= keep_tokens {
144            break;
145        }
146        candidate = next_index(&users, candidate).unwrap_or(latest_user);
147    }
148
149    if candidate < latest_user {
150        let tokens = estimate_view_tokens(messages, candidate, Some(latest_user), 2000);
151        if tokens <= keep_tokens {
152            return (candidate, Some(latest_user));
153        }
154    }
155
156    // The current task alone is too large: keep the latest request plus the
157    // newest rounds, and make the middle retrievable history.
158    candidate = candidate.max(latest_user);
159    let tail_starts: Vec<usize> = starts
160        .iter()
161        .copied()
162        .filter(|i| *i > latest_user)
163        .collect();
164    let mut chosen_tail = latest_user;
165    for start in tail_starts.iter().rev() {
166        let tokens = estimate_view_tokens(messages, *start, Some(latest_user), 2000);
167        if tokens <= keep_tokens {
168            chosen_tail = *start;
169        } else {
170            break;
171        }
172    }
173    if starts.len() >= keep_min_rounds && starts[starts.len() - keep_min_rounds] > latest_user {
174        let min_start = starts[starts.len() - keep_min_rounds];
175        if chosen_tail > latest_user {
176            chosen_tail = chosen_tail.min(min_start);
177        }
178    }
179
180    (candidate.max(chosen_tail), Some(latest_user))
181}
182
183/// Render the model view without modifying the stored messages.
184pub fn render_retrieval_view(
185    messages: &[ChatMessage],
186    cutoff: usize,
187    latest_user: Option<usize>,
188    stub: &str,
189) -> Vec<ChatMessage> {
190    let mut cutoff = cutoff.min(messages.len());
191    // Never start a view with an orphaned tool result.
192    while cutoff < messages.len() && messages[cutoff].role == "tool" {
193        cutoff += 1;
194    }
195
196    let mut view: Vec<ChatMessage> = Vec::new();
197    if let Some(first) = messages.first() {
198        view.push(first.clone());
199    }
200    view.push(ChatMessage::user(stub));
201
202    if let Some(lu) = latest_user {
203        if cutoff > lu && lu < messages.len() {
204            view.push(messages[lu].clone());
205            let mut i = lu + 1;
206            while i < cutoff && messages.get(i).map(|m| m.role.as_str()) == Some("system") {
207                view.push(messages[i].clone());
208                i += 1;
209            }
210        }
211    }
212    view.extend_from_slice(&messages[cutoff..]);
213    view
214}
215
216fn pending_todos(project: &Path, session_id: &str) -> Vec<String> {
217    let dir = project.join(".aacode").join("todos");
218    let candidates = [
219        dir.join(format!("todo_{session_id}.md")),
220        dir.join("todo.md"),
221    ];
222    for path in candidates {
223        if let Ok(text) = std::fs::read_to_string(&path) {
224            let items: Vec<String> = text
225                .lines()
226                .filter_map(|line| {
227                    line.trim()
228                        .strip_prefix("- [ ]")
229                        .map(|rest| truncate_chars(rest.trim(), 120))
230                })
231                .take(10)
232                .collect();
233            if !items.is_empty() {
234                return items;
235            }
236        }
237    }
238    Vec::new()
239}
240
241fn last_error(messages: &[ChatMessage]) -> Option<String> {
242    const MARKERS: [&str; 6] = [
243        "error",
244        "failed",
245        "traceback",
246        "exception",
247        "❌",
248        "execution error",
249    ];
250    for m in messages.iter().rev() {
251        if m.role != "tool" && m.role != "assistant" {
252            continue;
253        }
254        let lower = m.content.to_lowercase();
255        if MARKERS.iter().any(|k| lower.contains(k)) {
256            return Some(truncate_chars(
257                &m.content.replace('\n', " ").trim().to_string(),
258                300,
259            ));
260        }
261    }
262    None
263}
264
265/// Build the compact checkpoint stub that replaces omitted history.
266pub fn build_checkpoint_stub(
267    messages: &[ChatMessage],
268    cutoff: usize,
269    latest_user: Option<usize>,
270    project: &Path,
271    session_id: &str,
272) -> String {
273    let cutoff = cutoff.min(messages.len());
274    let mut lines = vec![CHECKPOINT_OPEN.to_string()];
275    if cutoff > 0 {
276        lines.push(format!("Older messages omitted: messages 1-{cutoff}."));
277    }
278    lines.push(
279        "Full history is persisted. Use search_history / read_history to retrieve older context. \
280         Never cat the session JSON."
281            .to_string(),
282    );
283    if !session_id.is_empty() {
284        lines.push(format!("Session: {session_id}"));
285    }
286
287    let todos = pending_todos(project, session_id);
288    if !todos.is_empty() {
289        lines.push("Unfinished todos:".to_string());
290        lines.extend(todos.into_iter().map(|t| format!("- {t}")));
291    }
292
293    if let Some(err) = last_error(&messages[..cutoff]) {
294        lines.push(format!("Last error: {err}"));
295    }
296
297    let mut earlier = Vec::new();
298    for (index, message) in messages[..cutoff].iter().enumerate() {
299        if !is_real_user_message(message) {
300            continue;
301        }
302        if Some(index) == latest_user {
303            continue;
304        }
305        let text = message.content.replace('\n', " ");
306        earlier.push(format!("- [{index}] {}", truncate_chars(text.trim(), 80)));
307    }
308    if !earlier.is_empty() {
309        lines.push("Earlier user messages:".to_string());
310        let start = earlier.len().saturating_sub(10);
311        lines.extend(earlier[start..].iter().cloned());
312    }
313
314    lines.push(CHECKPOINT_CLOSE.to_string());
315    let stub = lines.join("\n");
316    if stub.chars().count() > 6000 {
317        format!("{}\n[truncated]", truncate_chars(&stub, 6000))
318    } else {
319        stub
320    }
321}
322
323/// In-memory retrieval state carried across iterations of one run.
324#[derive(Debug, Clone, Default)]
325pub struct RetrievalState {
326    pub checkpoint: Option<ContextCheckpoint>,
327}
328
329impl RetrievalState {
330    pub fn from_value(value: Option<Value>) -> Self {
331        RetrievalState {
332            checkpoint: value.and_then(|v| serde_json::from_value(v).ok()),
333        }
334    }
335
336    pub fn to_value(&self) -> Option<Value> {
337        self.checkpoint
338            .as_ref()
339            .and_then(|c| serde_json::to_value(c).ok())
340    }
341
342    /// Build the model view, advancing the cutoff only when necessary.
343    ///
344    /// Returns `(view, compacted, tokens, changed)`; persist when `changed`.
345    pub fn model_view(
346        &mut self,
347        messages: &[ChatMessage],
348        cfg: &ContextConfig,
349        project: &Path,
350        session_id: &str,
351    ) -> (Vec<ChatMessage>, bool, usize, bool) {
352        let trigger = cfg.retrieval_trigger_tokens.max(1);
353        let keep = cfg.retrieval_keep_tokens.max(1);
354        let recent = cfg.retrieval_keep_recent_user_messages.max(1);
355        let min_rounds = cfg.retrieval_keep_min_rounds.max(1);
356        let count = messages.len();
357
358        let cutoff = self
359            .checkpoint
360            .as_ref()
361            .map(|c| c.cutoff_index)
362            .unwrap_or(0)
363            .min(count);
364        let latest_user = real_user_indices(messages).last().copied();
365        let mut changed = false;
366
367        // A restored checkpoint without a stub (e.g. legacy file) is rebuilt
368        // once; it stays byte-stable afterwards until the cutoff advances.
369        if cutoff > 0
370            && self
371                .checkpoint
372                .as_ref()
373                .map(|c| c.stub.is_empty())
374                .unwrap_or(true)
375        {
376            let stub = build_checkpoint_stub(messages, cutoff, latest_user, project, session_id);
377            self.checkpoint = Some(ContextCheckpoint {
378                cutoff_index: cutoff,
379                stub,
380                session_id: session_id.to_string(),
381                created_at: now_iso(),
382                keep_tokens: keep,
383            });
384            changed = true;
385        }
386
387        let view = if cutoff > 0 {
388            let stub = self
389                .checkpoint
390                .as_ref()
391                .map(|c| c.stub.as_str())
392                .unwrap_or("");
393            render_retrieval_view(messages, cutoff, latest_user, stub)
394        } else {
395            messages.to_vec()
396        };
397        let tokens = estimate_messages_tokens(&view);
398        if tokens <= trigger {
399            return (view, cutoff > 0, tokens, changed);
400        }
401
402        let (new_cutoff, new_latest) =
403            compute_retrieval_cutoff(messages, cutoff, keep, recent, min_rounds);
404        if new_cutoff <= cutoff {
405            return (view, cutoff > 0, tokens, changed);
406        }
407
408        let stub = build_checkpoint_stub(messages, new_cutoff, new_latest, project, session_id);
409        let new_view = render_retrieval_view(messages, new_cutoff, new_latest, &stub);
410        let new_tokens = estimate_messages_tokens(&new_view);
411        // Moving the cutoff changes the request prefix and costs one cache
412        // miss. Only do it when the saving is meaningful (>= 10%) or the view
413        // exceeds the hard context limit.
414        if !should_advance(tokens, new_tokens, cfg.max_context_tokens) {
415            return (view, cutoff > 0, tokens, changed);
416        }
417        self.checkpoint = Some(ContextCheckpoint {
418            cutoff_index: new_cutoff,
419            stub,
420            session_id: session_id.to_string(),
421            created_at: now_iso(),
422            keep_tokens: keep,
423        });
424        (new_view, true, new_tokens, true)
425    }
426}
427
428/// Is advancing the cutoff worth the one-time prefix-cache miss?
429///
430/// Yes when it saves at least 10% of the current view, or when the current
431/// view is over the hard context limit (any reduction helps).
432fn should_advance(current_tokens: usize, new_tokens: usize, hard_limit: usize) -> bool {
433    if new_tokens >= current_tokens {
434        return false;
435    }
436    if current_tokens > hard_limit {
437        return true;
438    }
439    let saved = current_tokens - new_tokens;
440    saved.saturating_mul(10) >= current_tokens
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use crate::config::ContextConfig;
447
448    fn msg(role: &str, content: &str) -> ChatMessage {
449        ChatMessage::simple(role, content)
450    }
451
452    fn cfg(trigger: usize, keep: usize) -> ContextConfig {
453        ContextConfig {
454            retrieval_trigger_tokens: trigger,
455            retrieval_keep_tokens: keep,
456            ..Default::default()
457        }
458    }
459
460    #[test]
461    fn real_user_excludes_injected_messages() {
462        assert!(is_real_user_message(&msg("user", "hello")));
463        assert!(!is_real_user_message(&msg("user", "[System] hint")));
464        assert!(!is_real_user_message(&msg(
465            "user",
466            "<conversation-checkpoint>x"
467        )));
468        assert!(!is_real_user_message(&msg("assistant", "hi")));
469    }
470
471    #[test]
472    fn view_starts_with_system_and_stub_no_orphan_tool() {
473        let mut messages = vec![msg("system", "SYS")];
474        for i in 0..10 {
475            messages.push(msg("user", &format!("task {i}")));
476            messages.push(msg("assistant", &format!("a {i}")));
477            messages.push(ChatMessage::tool_result(format!("c{i}"), "obs"));
478        }
479        // Cutoff landing on a tool message must advance past it.
480        let view = render_retrieval_view(&messages, 2, Some(9), "STUB");
481        assert_eq!(view[0].role, "system");
482        assert_eq!(view[1].role, "user");
483        assert_eq!(view[1].content, "STUB");
484        assert_ne!(view[2].role, "tool");
485    }
486
487    #[test]
488    fn cutoff_never_moves_backwards_and_keeps_latest_user() {
489        let mut messages = vec![msg("system", "SYS")];
490        for i in 0..20 {
491            messages.push(msg("user", &format!("task {i} {}", "x".repeat(400))));
492            messages.push(ChatMessage::assistant("ok"));
493        }
494        let (c1, _) = compute_retrieval_cutoff(&messages, 0, 200, 2, 2);
495        let (c2, _) = compute_retrieval_cutoff(&messages, c1, 200, 2, 2);
496        assert!(c2 >= c1, "cutoff must not move backwards");
497        let (c3, latest) = compute_retrieval_cutoff(&messages, 0, 1, 2, 2);
498        assert!(c3 <= latest.unwrap());
499    }
500
501    #[test]
502    fn no_trigger_below_threshold() {
503        let mut state = RetrievalState::default();
504        let messages = vec![msg("system", "SYS"), msg("user", "small task")];
505        let (view, compacted, _, changed) =
506            state.model_view(&messages, &cfg(100_000, 20_000), Path::new("."), "s1");
507        assert!(!compacted);
508        assert!(!changed);
509        assert_eq!(view.len(), messages.len());
510    }
511
512    #[test]
513    fn triggers_cutoff_and_freezes_while_under_trigger() {
514        let mut messages = vec![msg("system", "SYS")];
515        for i in 0..40 {
516            messages.push(msg("user", &format!("task {i} {}", "x".repeat(500))));
517            messages.push(ChatMessage::assistant("ok"));
518        }
519        let project = std::env::temp_dir();
520        let mut state = RetrievalState::default();
521        let (view1, compacted1, _, changed1) =
522            state.model_view(&messages, &cfg(1000, 500), &project, "s1");
523        assert!(compacted1 && changed1, "should advance the cutoff");
524        assert!(view1.len() < messages.len());
525
526        // Appending more messages must not change the frozen cutoff.
527        messages.push(msg("user", "follow-up"));
528        let before = state.checkpoint.as_ref().unwrap().cutoff_index;
529        let (_, _, _, changed2) = state.model_view(&messages, &cfg(1000, 500), &project, "s1");
530        let after = state.checkpoint.as_ref().unwrap().cutoff_index;
531        assert_eq!(before, after, "cutoff must stay frozen while under trigger");
532        assert!(!changed2);
533    }
534
535    #[test]
536    fn advance_requires_meaningful_saving_or_hard_limit() {
537        // 20% saving → advance.
538        assert!(should_advance(1000, 800, 100_000));
539        // 5% saving → skip (not worth a cache miss).
540        assert!(!should_advance(1000, 950, 100_000));
541        // Over the hard limit: any reduction helps.
542        assert!(should_advance(1000, 990, 900));
543        // No reduction → never.
544        assert!(!should_advance(1000, 1000, 100_000));
545        assert!(!should_advance(1000, 1200, 100_000));
546    }
547
548    #[test]
549    fn checkpoint_survives_serialization() {
550        let cp = ContextCheckpoint {
551            cutoff_index: 5,
552            stub: "STUB".into(),
553            session_id: "s1".into(),
554            created_at: "now".into(),
555            keep_tokens: 100,
556        };
557        let v = serde_json::to_value(&cp).unwrap();
558        let restored = RetrievalState::from_value(Some(v));
559        assert_eq!(restored.checkpoint.unwrap().cutoff_index, 5);
560    }
561}