1use crate::agent::inference::ChatMessage;
2use std::collections::{BTreeSet, HashSet};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub struct CompactionConfig {
7 pub preserve_recent_messages: usize,
8 pub max_estimated_tokens: usize,
10}
11
12impl Default for CompactionConfig {
13 fn default() -> Self {
14 Self {
15 preserve_recent_messages: 10,
16 max_estimated_tokens: 15_000,
17 }
18 }
19}
20
21impl CompactionConfig {
22 pub fn adaptive(context_length: usize, vram_ratio: f64) -> Self {
31 let vram = vram_ratio.clamp(0.0, 1.0);
32 let effective = (context_length as f64 * 0.40 * (1.0 - vram * 0.5)) as usize;
33 let max_estimated_tokens = effective.max(4_000).min(60_000);
34 let preserve_recent_messages = (context_length / 3_000).clamp(8, 20);
35 Self {
36 preserve_recent_messages,
37 max_estimated_tokens,
38 }
39 }
40}
41
42pub struct CompactionResult {
43 pub messages: Vec<ChatMessage>,
44 pub summary: Option<String>,
45}
46
47const DEFAULT_MAX_SUMMARY_CHARS: usize = 2_000;
48const DEFAULT_MAX_SUMMARY_LINES: usize = 40;
49const DEFAULT_MAX_SUMMARY_LINE_CHARS: usize = 200;
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct SummaryCompressionBudget {
53 pub max_chars: usize,
54 pub max_lines: usize,
55 pub max_line_chars: usize,
56}
57
58impl Default for SummaryCompressionBudget {
59 fn default() -> Self {
60 Self {
61 max_chars: DEFAULT_MAX_SUMMARY_CHARS,
62 max_lines: DEFAULT_MAX_SUMMARY_LINES,
63 max_line_chars: DEFAULT_MAX_SUMMARY_LINE_CHARS,
64 }
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct SummaryCompressionResult {
70 pub summary: String,
71 pub original_chars: usize,
72 pub compressed_chars: usize,
73 pub original_lines: usize,
74 pub compressed_lines: usize,
75 pub removed_duplicate_lines: usize,
76 pub omitted_lines: usize,
77 pub truncated: bool,
78}
79
80pub fn compress_summary(
81 summary: &str,
82 budget: SummaryCompressionBudget,
83) -> SummaryCompressionResult {
84 let original_chars = summary.chars().count();
85 let original_lines = summary.lines().count();
86 let normalized = normalize_summary_lines(summary, budget.max_line_chars);
87
88 if normalized.lines.is_empty() || budget.max_chars == 0 || budget.max_lines == 0 {
89 return SummaryCompressionResult {
90 summary: String::new(),
91 original_chars,
92 compressed_chars: 0,
93 original_lines,
94 compressed_lines: 0,
95 removed_duplicate_lines: normalized.removed_duplicate_lines,
96 omitted_lines: normalized.lines.len(),
97 truncated: original_chars > 0,
98 };
99 }
100
101 let selected = select_summary_line_indexes(&normalized.lines, budget);
102 let mut compressed_lines = selected
103 .iter()
104 .map(|index| normalized.lines[*index].clone())
105 .collect::<Vec<_>>();
106 if compressed_lines.is_empty() {
107 compressed_lines.push(truncate_summary_line(
108 &normalized.lines[0],
109 budget.max_chars,
110 ));
111 }
112 let omitted_lines = normalized
113 .lines
114 .len()
115 .saturating_sub(compressed_lines.len());
116 if omitted_lines > 0 {
117 push_summary_line_with_budget(
118 &mut compressed_lines,
119 format!("- ... {omitted_lines} additional line(s) omitted."),
120 budget,
121 );
122 }
123
124 let compressed_summary = compressed_lines.join("\n");
125 SummaryCompressionResult {
126 summary: compressed_summary.clone(),
127 original_chars,
128 compressed_chars: compressed_summary.chars().count(),
129 original_lines,
130 compressed_lines: compressed_lines.len(),
131 removed_duplicate_lines: normalized.removed_duplicate_lines,
132 omitted_lines,
133 truncated: compressed_summary != summary.trim(),
134 }
135}
136
137pub fn compress_summary_text(summary: &str) -> String {
138 compress_summary(summary, SummaryCompressionBudget::default()).summary
139}
140
141const COMPACT_PREAMBLE: &str = "## CONTEXT SUMMARY (RECURSIVE CHAIN)\n\
142 This session is being continued from a previous conversation. The summary below covers the earlier portion.\n\n";
143const COMPACT_INSTRUCTION: &str = "\n\nIMPORTANT: Resume directly from the last message. Do not recap or acknowledge this summary.";
144
145#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
148pub struct SessionCheckpoint {
149 pub state: String,
150 pub summary: String,
151}
152
153#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
154pub struct SessionVerification {
155 pub successful: bool,
156 pub summary: String,
157}
158
159#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
160pub struct SessionCompactionLedger {
161 pub count: u32,
162 pub removed_message_count: usize,
163 pub summary: String,
164}
165
166#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
167pub struct SessionMemory {
168 pub current_task: String,
169 pub working_set: std::collections::HashSet<String>,
170 pub learnings: Vec<String>,
171 #[serde(default)]
172 pub current_plan: Option<crate::tools::plan::PlanHandoff>,
173 #[serde(default)]
174 pub last_checkpoint: Option<SessionCheckpoint>,
175 #[serde(default)]
176 pub last_blocker: Option<SessionCheckpoint>,
177 #[serde(default)]
178 pub last_recovery: Option<SessionCheckpoint>,
179 #[serde(default)]
180 pub last_verification: Option<SessionVerification>,
181 #[serde(default)]
182 pub last_compaction: Option<SessionCompactionLedger>,
183}
184
185impl SessionMemory {
186 pub fn has_signal(&self) -> bool {
187 let task = self.current_task.trim();
188 (!task.is_empty() && task != "Ready for new mission.")
189 || !self.working_set.is_empty()
190 || !self.learnings.is_empty()
191 || self.last_checkpoint.is_some()
192 || self.last_blocker.is_some()
193 || self.last_recovery.is_some()
194 || self.last_verification.is_some()
195 || self.last_compaction.is_some()
196 || self
197 .current_plan
198 .as_ref()
199 .map(|plan| plan.has_signal())
200 .unwrap_or(false)
201 }
202
203 pub fn to_prompt(&self) -> String {
204 let mut s = format!("- **Active Task**: {}\n", self.current_task);
205 if let Some(plan) = &self.current_plan {
206 if plan.has_signal() {
207 s.push_str("- **Active Plan Handoff**:\n");
208 s.push_str(&plan.to_prompt());
209 }
210 }
211 if !self.working_set.is_empty() {
212 let files: Vec<_> = self.working_set.iter().cloned().collect();
213 s.push_str(&format!("- **Working Set**: {}\n", files.join(", ")));
214 }
215 if !self.learnings.is_empty() {
216 s.push_str("- **Key Learnings**:\n");
217 for l in &self.learnings {
218 s.push_str(&format!(" - {l}\n"));
219 }
220 }
221 if let Some(checkpoint) = &self.last_checkpoint {
222 if checkpoint.summary.trim().is_empty() {
223 s.push_str(&format!("- **Latest Checkpoint**: {}\n", checkpoint.state));
224 } else {
225 s.push_str(&format!(
226 "- **Latest Checkpoint**: {} - {}\n",
227 checkpoint.state, checkpoint.summary
228 ));
229 }
230 }
231 if let Some(blocker) = &self.last_blocker {
232 if blocker.summary.trim().is_empty() {
233 s.push_str(&format!("- **Latest Blocker**: {}\n", blocker.state));
234 } else {
235 s.push_str(&format!(
236 "- **Latest Blocker**: {} - {}\n",
237 blocker.state, blocker.summary
238 ));
239 }
240 }
241 if let Some(recovery) = &self.last_recovery {
242 if recovery.summary.trim().is_empty() {
243 s.push_str(&format!("- **Latest Recovery**: {}\n", recovery.state));
244 } else {
245 s.push_str(&format!(
246 "- **Latest Recovery**: {} - {}\n",
247 recovery.state, recovery.summary
248 ));
249 }
250 }
251 if let Some(verification) = &self.last_verification {
252 let status = if verification.successful {
253 "passed"
254 } else {
255 "failed"
256 };
257 s.push_str(&format!(
258 "- **Latest Verification**: {} - {}\n",
259 status, verification.summary
260 ));
261 }
262 if let Some(compaction) = &self.last_compaction {
263 s.push_str(&format!(
264 "- **Latest Compaction**: pass {} removed {} message(s) - {}\n",
265 compaction.count, compaction.removed_message_count, compaction.summary
266 ));
267 }
268 s
269 }
270
271 pub fn inherit_runtime_ledger_from(&mut self, other: &Self) {
272 self.last_checkpoint = other.last_checkpoint.clone();
273 self.last_blocker = other.last_blocker.clone();
274 self.last_recovery = other.last_recovery.clone();
275 self.last_verification = other.last_verification.clone();
276 self.last_compaction = other.last_compaction.clone();
277 }
278
279 pub fn record_checkpoint(&mut self, state: impl Into<String>, summary: impl Into<String>) {
280 let checkpoint = SessionCheckpoint {
281 state: state.into(),
282 summary: summary.into(),
283 };
284 let state_name = checkpoint.state.as_str();
285 if state_name == "recovering_provider" {
286 self.last_recovery = Some(checkpoint.clone());
287 }
288 if state_name.starts_with("blocked_") {
289 self.last_blocker = Some(checkpoint.clone());
290 }
291 self.last_checkpoint = Some(checkpoint);
292 }
293
294 pub fn record_verification(&mut self, successful: bool, summary: impl Into<String>) {
295 self.last_verification = Some(SessionVerification {
296 successful,
297 summary: summary.into(),
298 });
299 }
300
301 pub fn record_recovery(&mut self, state: impl Into<String>, summary: impl Into<String>) {
302 let checkpoint = SessionCheckpoint {
303 state: state.into(),
304 summary: summary.into(),
305 };
306 self.last_recovery = Some(checkpoint.clone());
307 self.last_checkpoint = Some(checkpoint);
308 }
309
310 pub fn record_compaction(&mut self, removed_message_count: usize, summary: impl Into<String>) {
311 let count = self
312 .last_compaction
313 .as_ref()
314 .map_or(1, |entry| entry.count.saturating_add(1));
315 self.last_compaction = Some(SessionCompactionLedger {
316 count,
317 removed_message_count,
318 summary: summary.into(),
319 });
320 }
321
322 pub fn clear(&mut self) {
323 self.current_task = "Ready for new mission.".to_string();
324 self.working_set.clear();
325 self.learnings.clear();
326 self.current_plan = None;
327 self.last_checkpoint = None;
328 self.last_blocker = None;
329 self.last_recovery = None;
330 self.last_verification = None;
331 self.last_compaction = None;
332 }
333}
334
335pub fn should_compact(history: &[ChatMessage], context_length: usize, vram_ratio: f64) -> bool {
338 let config = CompactionConfig::adaptive(context_length, vram_ratio);
339 history.len().saturating_sub(1) > config.preserve_recent_messages + 5
340 || estimate_compactable_tokens(history) > config.max_estimated_tokens
341}
342
343pub fn compact_history(
344 history: &[ChatMessage],
345 existing_summary: Option<&str>,
346 config: CompactionConfig,
347 anchor_index: Option<usize>,
350) -> CompactionResult {
351 if history.len() <= config.preserve_recent_messages + 5 {
352 return CompactionResult {
353 messages: history.to_vec(),
354 summary: existing_summary.map(|s| s.to_string()),
355 };
356 }
357
358 let anchor = anchor_index.unwrap_or(1).max(1).min(history.len() - 1);
368 let keep_from = history
369 .len()
370 .saturating_sub(config.preserve_recent_messages);
371
372 let mut messages_to_summarize = Vec::new();
373 let mut preserved_messages = Vec::new();
374
375 if anchor > 1 {
378 messages_to_summarize.extend(history[1..anchor].iter().cloned());
379 }
380 preserved_messages.push(history[anchor].clone());
381
382 if keep_from > anchor + 1 {
384 messages_to_summarize.extend(history[anchor + 1..keep_from].iter().cloned());
386 preserved_messages.extend(history[keep_from..].iter().cloned());
387 } else {
388 preserved_messages.extend(history[anchor + 1..].iter().cloned());
390 }
391
392 let new_summary_txt = build_technical_summary(&messages_to_summarize);
393 let merged_summary = match existing_summary {
394 Some(existing) => merge_summaries(existing, &new_summary_txt),
395 None => new_summary_txt,
396 };
397
398 let summary_content = format!(
399 "{}{}{}",
400 COMPACT_PREAMBLE, merged_summary, COMPACT_INSTRUCTION
401 );
402 let summary_msg = ChatMessage::system(&summary_content);
403
404 let mut new_history = vec![history[0].clone()];
405 new_history.push(summary_msg);
406 new_history.extend(preserved_messages);
407
408 CompactionResult {
409 messages: new_history,
410 summary: Some(merged_summary),
411 }
412}
413
414pub fn extract_memory(messages: &[ChatMessage]) -> SessionMemory {
416 let mut mem = SessionMemory::default();
417
418 let last_user_idx = messages.iter().rposition(|m| m.role == "user");
422
423 if let Some(idx) = last_user_idx {
424 let m = &messages[idx];
425 let content_str = m.content.as_str();
426 let limit = 250;
427 mem.current_task = content_str.chars().take(limit).collect();
428 if content_str.len() > limit {
429 mem.current_task.push_str("...");
430 }
431 }
432
433 let mut all_files: Vec<String> = Vec::new();
437 for msg in messages {
438 if let Some(calls) = &msg.tool_calls {
439 for call in calls {
440 let args = call.function.arguments.clone();
441 if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
442 all_files.push(path.to_string());
445 }
446 }
447 }
448 }
449 let mut seen = HashSet::new();
451 for path in all_files.into_iter().rev() {
452 if seen.insert(path.clone()) {
453 mem.working_set.insert(path);
454 if mem.working_set.len() >= 12 {
455 break;
456 }
457 }
458 }
459
460 if let Some(idx) = last_user_idx {
462 for turn_msg in &messages[idx..] {
463 if turn_msg.role == "tool" {
464 let content_str = turn_msg.content.as_str();
465 if content_str.contains("Error:")
466 || content_str.contains("Finished")
467 || content_str.contains("Complete")
468 {
469 let lines: Vec<_> = content_str.lines().take(2).collect();
470 mem.learnings.push(lines.join(" "));
471 }
472 }
473 }
474 }
475
476 mem.learnings.dedup();
478 if mem.learnings.len() > 5 {
479 mem.learnings.truncate(5);
480 }
481
482 mem
483}
484
485pub fn estimate_tokens(messages: &[ChatMessage]) -> usize {
486 messages
487 .iter()
488 .map(|m| m.content.as_str().len() / 4 + 1)
489 .sum()
490}
491
492pub fn estimate_compactable_tokens(history: &[ChatMessage]) -> usize {
493 if history.len() <= 1 {
494 0
495 } else {
496 estimate_tokens(&history[1..])
497 }
498}
499
500fn build_technical_summary(messages: &[ChatMessage]) -> String {
501 let mut lines = vec![format!(
502 "- Scope: {} earlier turns compacted.",
503 messages.len()
504 )];
505
506 let mut files: IndexedSet = IndexedSet::default();
509 let mut tools: HashSet<String> = HashSet::new();
510 let mut requests: Vec<String> = Vec::new();
511 let mut assistant_notes: Vec<String> = Vec::new();
512 let mut verify_outcome: Option<bool> = None;
514 let mut error_snippets: Vec<String> = Vec::new();
515
516 for m in messages {
517 if let Some(calls) = &m.tool_calls {
519 for call in calls {
520 tools.insert(call.function.name.clone());
521 let args = call.function.arguments.clone();
522 if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
523 files.insert(path.to_string());
524 }
525 }
526 }
527
528 if m.role == "tool" {
530 let text = m.content.as_str();
531 if text.contains("BUILD OK") || text.contains("BUILD SUCCESS") {
533 verify_outcome = Some(true);
534 } else if text.contains("BUILD FAIL") || text.contains("error[") {
535 verify_outcome = Some(false);
536 }
537 if text.contains("Error:") || text.contains("error:") {
539 if let Some(err_line) = text.lines().find(|l| {
540 l.trim_start().starts_with("Error:") || l.trim_start().starts_with("error:")
541 }) {
542 let snippet: String = err_line.chars().take(100).collect();
543 error_snippets.push(snippet);
544 }
545 }
546 }
547
548 if m.role == "user" && !m.content.as_str().trim().is_empty() && requests.len() < 4 {
550 let text = m
551 .content
552 .as_str()
553 .trim_start_matches("/think\n")
554 .trim_start_matches("/no_think\n")
555 .trim();
556 requests.push(truncate_summary_line(
557 &collapse_inline_whitespace(text),
558 140,
559 ));
560 }
561
562 if m.role == "assistant"
564 && !m.content.as_str().trim().is_empty()
565 && m.tool_calls.as_ref().map_or(true, |tc| tc.is_empty())
566 && assistant_notes.len() < 3
567 {
568 let text = m.content.as_str().trim();
569 if text.len() > 20 {
570 assistant_notes.push(truncate_summary_line(
571 &collapse_inline_whitespace(text),
572 120,
573 ));
574 }
575 }
576
577 for word in m.content.as_str().split_whitespace() {
579 let clean = word.trim_matches(|c: char| {
580 matches!(c, ',' | '.' | ':' | ';' | ')' | '(' | '"' | '\'' | '`')
581 });
582 if clean.len() > 4
583 && clean.contains('.')
584 && (clean.contains('/') || clean.contains('\\'))
585 {
586 files.insert(clean.to_string());
587 }
588 }
589 }
590
591 if !files.0.is_empty() {
592 let list: Vec<String> = files.0.into_iter().take(10).collect();
593 lines.push(format!("- Key files: {}.", list.join(", ")));
594 }
595 if !tools.is_empty() {
596 let list: Vec<String> = tools.into_iter().take(8).collect();
597 lines.push(format!("- Tools used: {}.", list.join(", ")));
598 }
599 if let Some(ok) = verify_outcome {
600 lines.push(format!(
601 "- Last verify_build: {}.",
602 if ok { "BUILD OK" } else { "BUILD FAILED" }
603 ));
604 }
605 error_snippets.dedup();
607 for snippet in error_snippets.into_iter().take(2) {
608 lines.push(format!("- Error seen: {}", snippet));
609 }
610 if !assistant_notes.is_empty() {
611 lines.push("- Assistant decisions/responses (oldest→newest):".to_string());
612 for note in &assistant_notes {
613 lines.push(format!(" - {}", note));
614 }
615 }
616 if !requests.is_empty() {
617 lines.push("- User requests (oldest→newest):".to_string());
618 for request in &requests {
619 lines.push(format!(" - {}", request));
620 }
621 }
622
623 lines.push("- Compacted context:".to_string());
625 for m in messages.iter().rev().take(6).rev() {
626 let content_str = m.content.as_str();
627 let preview = if content_str.len() > 120 {
628 let mut s: String = content_str.chars().take(117).collect();
629 s.push_str("...");
630 s
631 } else if content_str.is_empty()
632 && m.tool_calls
633 .as_ref()
634 .map(|c| !c.is_empty())
635 .unwrap_or(false)
636 {
637 format!(
638 "Executing: {}",
639 m.tool_calls
640 .as_ref()
641 .unwrap()
642 .iter()
643 .map(|c| c.function.name.as_str())
644 .collect::<Vec<_>>()
645 .join(", ")
646 )
647 } else {
648 content_str.to_string()
649 };
650 lines.push(format!(
651 " - {}: {}",
652 m.role,
653 preview.replace('\n', " ").trim()
654 ));
655 }
656
657 compress_summary_text(&lines.join("\n"))
658}
659
660#[derive(Default)]
663struct IndexedSet(Vec<String>);
664
665impl IndexedSet {
666 fn insert(&mut self, s: String) {
667 if !self.0.contains(&s) {
668 self.0.push(s);
669 }
670 }
671}
672
673fn merge_summaries(existing: &str, new: &str) -> String {
674 compress_summary_text(&format!(
675 "Conversation summary:\n- Previously compacted context:\n{}\n- Newly compacted context:\n{}",
676 existing.trim(),
677 new.trim()
678 ))
679}
680
681#[derive(Debug, Default)]
682struct NormalizedSummary {
683 lines: Vec<String>,
684 removed_duplicate_lines: usize,
685}
686
687fn normalize_summary_lines(summary: &str, max_line_chars: usize) -> NormalizedSummary {
688 let mut seen = BTreeSet::new();
689 let mut lines = Vec::new();
690 let mut removed_duplicate_lines = 0;
691
692 for raw_line in summary.lines() {
693 let normalized = collapse_inline_whitespace(raw_line);
694 if normalized.is_empty() {
695 continue;
696 }
697 let truncated = truncate_summary_line(&normalized, max_line_chars);
698 let dedupe_key = truncated.to_ascii_lowercase();
699 if !seen.insert(dedupe_key) {
700 removed_duplicate_lines += 1;
701 continue;
702 }
703 lines.push(truncated);
704 }
705
706 NormalizedSummary {
707 lines,
708 removed_duplicate_lines,
709 }
710}
711
712fn select_summary_line_indexes(lines: &[String], budget: SummaryCompressionBudget) -> Vec<usize> {
713 let mut selected = BTreeSet::<usize>::new();
714
715 for priority in 0..=3 {
716 for (index, line) in lines.iter().enumerate() {
717 if selected.contains(&index) || summary_line_priority(line) != priority {
718 continue;
719 }
720 let candidate = selected
721 .iter()
722 .map(|selected_index| lines[*selected_index].as_str())
723 .chain(std::iter::once(line.as_str()))
724 .collect::<Vec<_>>();
725 if candidate.len() > budget.max_lines {
726 continue;
727 }
728 if joined_summary_char_count(&candidate) > budget.max_chars {
729 continue;
730 }
731 selected.insert(index);
732 }
733 }
734
735 selected.into_iter().collect()
736}
737
738fn push_summary_line_with_budget(
739 lines: &mut Vec<String>,
740 line: String,
741 budget: SummaryCompressionBudget,
742) {
743 let candidate = lines
744 .iter()
745 .map(String::as_str)
746 .chain(std::iter::once(line.as_str()))
747 .collect::<Vec<_>>();
748 if candidate.len() <= budget.max_lines
749 && joined_summary_char_count(&candidate) <= budget.max_chars
750 {
751 lines.push(line);
752 }
753}
754
755fn joined_summary_char_count(lines: &[&str]) -> usize {
756 lines.iter().map(|line| line.chars().count()).sum::<usize>() + lines.len().saturating_sub(1)
757}
758
759fn summary_line_priority(line: &str) -> usize {
760 if line == "Conversation summary:" || is_core_summary_detail(line) {
761 0
762 } else if line.ends_with(':') {
763 1
764 } else if line.starts_with("- ") || line.starts_with(" - ") {
765 2
766 } else {
767 3
768 }
769}
770
771fn is_core_summary_detail(line: &str) -> bool {
772 [
773 "- Scope:",
774 "- Key files referenced:",
775 "- Tools mentioned:",
776 "- Recent user requests:",
777 "- Previously compacted context:",
778 "- Newly compacted context:",
779 ]
780 .iter()
781 .any(|prefix| line.starts_with(prefix))
782}
783
784fn collapse_inline_whitespace(line: &str) -> String {
785 line.split_whitespace().collect::<Vec<_>>().join(" ")
786}
787
788fn truncate_summary_line(line: &str, max_chars: usize) -> String {
789 if max_chars == 0 || line.chars().count() <= max_chars {
790 return line.to_string();
791 }
792 if max_chars == 1 {
793 return ".".to_string();
794 }
795 let mut truncated = line
796 .chars()
797 .take(max_chars.saturating_sub(3))
798 .collect::<String>();
799 truncated.push_str("...");
800 truncated
801}