1use 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#[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
37pub 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
53fn 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
101pub 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 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 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
183pub 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 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
265pub 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#[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 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 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 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
428fn 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 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 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 assert!(should_advance(1000, 800, 100_000));
539 assert!(!should_advance(1000, 950, 100_000));
541 assert!(should_advance(1000, 990, 900));
543 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}