use crate::agent::inference::ChatMessage;
use std::collections::{BTreeSet, HashSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CompactionConfig {
pub preserve_recent_messages: usize,
pub max_estimated_tokens: usize,
}
impl Default for CompactionConfig {
fn default() -> Self {
Self {
preserve_recent_messages: 10,
max_estimated_tokens: 15_000,
}
}
}
impl CompactionConfig {
pub fn adaptive(context_length: usize, vram_ratio: f64) -> Self {
let vram = vram_ratio.clamp(0.0, 1.0);
let effective = (context_length as f64 * 0.40 * (1.0 - vram * 0.5)) as usize;
let max_estimated_tokens = effective.max(4_000).min(60_000);
let preserve_recent_messages = (context_length / 3_000).clamp(8, 20);
Self {
preserve_recent_messages,
max_estimated_tokens,
}
}
}
pub struct CompactionResult {
pub messages: Vec<ChatMessage>,
pub summary: Option<String>,
}
const DEFAULT_MAX_SUMMARY_CHARS: usize = 2_000;
const DEFAULT_MAX_SUMMARY_LINES: usize = 40;
const DEFAULT_MAX_SUMMARY_LINE_CHARS: usize = 200;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SummaryCompressionBudget {
pub max_chars: usize,
pub max_lines: usize,
pub max_line_chars: usize,
}
impl Default for SummaryCompressionBudget {
fn default() -> Self {
Self {
max_chars: DEFAULT_MAX_SUMMARY_CHARS,
max_lines: DEFAULT_MAX_SUMMARY_LINES,
max_line_chars: DEFAULT_MAX_SUMMARY_LINE_CHARS,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SummaryCompressionResult {
pub summary: String,
pub original_chars: usize,
pub compressed_chars: usize,
pub original_lines: usize,
pub compressed_lines: usize,
pub removed_duplicate_lines: usize,
pub omitted_lines: usize,
pub truncated: bool,
}
pub fn compress_summary(
summary: &str,
budget: SummaryCompressionBudget,
) -> SummaryCompressionResult {
let original_chars = summary.chars().count();
let original_lines = summary.lines().count();
let normalized = normalize_summary_lines(summary, budget.max_line_chars);
if normalized.lines.is_empty() || budget.max_chars == 0 || budget.max_lines == 0 {
return SummaryCompressionResult {
summary: String::new(),
original_chars,
compressed_chars: 0,
original_lines,
compressed_lines: 0,
removed_duplicate_lines: normalized.removed_duplicate_lines,
omitted_lines: normalized.lines.len(),
truncated: original_chars > 0,
};
}
let selected = select_summary_line_indexes(&normalized.lines, budget);
let mut compressed_lines = selected
.iter()
.map(|index| normalized.lines[*index].clone())
.collect::<Vec<_>>();
if compressed_lines.is_empty() {
compressed_lines.push(truncate_summary_line(
&normalized.lines[0],
budget.max_chars,
));
}
let omitted_lines = normalized
.lines
.len()
.saturating_sub(compressed_lines.len());
if omitted_lines > 0 {
push_summary_line_with_budget(
&mut compressed_lines,
format!("- ... {omitted_lines} additional line(s) omitted."),
budget,
);
}
let compressed_summary = compressed_lines.join("\n");
SummaryCompressionResult {
summary: compressed_summary.clone(),
original_chars,
compressed_chars: compressed_summary.chars().count(),
original_lines,
compressed_lines: compressed_lines.len(),
removed_duplicate_lines: normalized.removed_duplicate_lines,
omitted_lines,
truncated: compressed_summary != summary.trim(),
}
}
pub fn compress_summary_text(summary: &str) -> String {
compress_summary(summary, SummaryCompressionBudget::default()).summary
}
const COMPACT_PREAMBLE: &str = "## CONTEXT SUMMARY (RECURSIVE CHAIN)\n\
This session is being continued from a previous conversation. The summary below covers the earlier portion.\n\n";
const COMPACT_INSTRUCTION: &str = "\n\nIMPORTANT: Resume directly from the last message. Do not recap or acknowledge this summary.";
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct SessionCheckpoint {
pub state: String,
pub summary: String,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct SessionVerification {
pub successful: bool,
pub summary: String,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct SessionCompactionLedger {
pub count: u32,
pub removed_message_count: usize,
pub summary: String,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct SessionMemory {
pub current_task: String,
pub working_set: std::collections::HashSet<String>,
pub learnings: Vec<String>,
#[serde(default)]
pub current_plan: Option<crate::tools::plan::PlanHandoff>,
#[serde(default)]
pub last_checkpoint: Option<SessionCheckpoint>,
#[serde(default)]
pub last_blocker: Option<SessionCheckpoint>,
#[serde(default)]
pub last_recovery: Option<SessionCheckpoint>,
#[serde(default)]
pub last_verification: Option<SessionVerification>,
#[serde(default)]
pub last_compaction: Option<SessionCompactionLedger>,
}
impl SessionMemory {
pub fn has_signal(&self) -> bool {
let task = self.current_task.trim();
(!task.is_empty() && task != "Ready for new mission.")
|| !self.working_set.is_empty()
|| !self.learnings.is_empty()
|| self.last_checkpoint.is_some()
|| self.last_blocker.is_some()
|| self.last_recovery.is_some()
|| self.last_verification.is_some()
|| self.last_compaction.is_some()
|| self
.current_plan
.as_ref()
.map(|plan| plan.has_signal())
.unwrap_or(false)
}
pub fn to_prompt(&self) -> String {
let mut s = format!("- **Active Task**: {}\n", self.current_task);
if let Some(plan) = &self.current_plan {
if plan.has_signal() {
s.push_str("- **Active Plan Handoff**:\n");
s.push_str(&plan.to_prompt());
}
}
if !self.working_set.is_empty() {
let files: Vec<_> = self.working_set.iter().cloned().collect();
s.push_str(&format!("- **Working Set**: {}\n", files.join(", ")));
}
if !self.learnings.is_empty() {
s.push_str("- **Key Learnings**:\n");
for l in &self.learnings {
s.push_str(&format!(" - {l}\n"));
}
}
if let Some(checkpoint) = &self.last_checkpoint {
if checkpoint.summary.trim().is_empty() {
s.push_str(&format!("- **Latest Checkpoint**: {}\n", checkpoint.state));
} else {
s.push_str(&format!(
"- **Latest Checkpoint**: {} - {}\n",
checkpoint.state, checkpoint.summary
));
}
}
if let Some(blocker) = &self.last_blocker {
if blocker.summary.trim().is_empty() {
s.push_str(&format!("- **Latest Blocker**: {}\n", blocker.state));
} else {
s.push_str(&format!(
"- **Latest Blocker**: {} - {}\n",
blocker.state, blocker.summary
));
}
}
if let Some(recovery) = &self.last_recovery {
if recovery.summary.trim().is_empty() {
s.push_str(&format!("- **Latest Recovery**: {}\n", recovery.state));
} else {
s.push_str(&format!(
"- **Latest Recovery**: {} - {}\n",
recovery.state, recovery.summary
));
}
}
if let Some(verification) = &self.last_verification {
let status = if verification.successful {
"passed"
} else {
"failed"
};
s.push_str(&format!(
"- **Latest Verification**: {} - {}\n",
status, verification.summary
));
}
if let Some(compaction) = &self.last_compaction {
s.push_str(&format!(
"- **Latest Compaction**: pass {} removed {} message(s) - {}\n",
compaction.count, compaction.removed_message_count, compaction.summary
));
}
s
}
pub fn inherit_runtime_ledger_from(&mut self, other: &Self) {
self.last_checkpoint = other.last_checkpoint.clone();
self.last_blocker = other.last_blocker.clone();
self.last_recovery = other.last_recovery.clone();
self.last_verification = other.last_verification.clone();
self.last_compaction = other.last_compaction.clone();
}
pub fn record_checkpoint(&mut self, state: impl Into<String>, summary: impl Into<String>) {
let checkpoint = SessionCheckpoint {
state: state.into(),
summary: summary.into(),
};
let state_name = checkpoint.state.as_str();
if state_name == "recovering_provider" {
self.last_recovery = Some(checkpoint.clone());
}
if state_name.starts_with("blocked_") {
self.last_blocker = Some(checkpoint.clone());
}
self.last_checkpoint = Some(checkpoint);
}
pub fn record_verification(&mut self, successful: bool, summary: impl Into<String>) {
self.last_verification = Some(SessionVerification {
successful,
summary: summary.into(),
});
}
pub fn record_recovery(&mut self, state: impl Into<String>, summary: impl Into<String>) {
let checkpoint = SessionCheckpoint {
state: state.into(),
summary: summary.into(),
};
self.last_recovery = Some(checkpoint.clone());
self.last_checkpoint = Some(checkpoint);
}
pub fn record_compaction(&mut self, removed_message_count: usize, summary: impl Into<String>) {
let count = self
.last_compaction
.as_ref()
.map_or(1, |entry| entry.count.saturating_add(1));
self.last_compaction = Some(SessionCompactionLedger {
count,
removed_message_count,
summary: summary.into(),
});
}
pub fn clear(&mut self) {
self.current_task = "Ready for new mission.".to_string();
self.working_set.clear();
self.learnings.clear();
self.current_plan = None;
self.last_checkpoint = None;
self.last_blocker = None;
self.last_recovery = None;
self.last_verification = None;
self.last_compaction = None;
}
}
pub fn should_compact(history: &[ChatMessage], context_length: usize, vram_ratio: f64) -> bool {
let config = CompactionConfig::adaptive(context_length, vram_ratio);
history.len().saturating_sub(1) > config.preserve_recent_messages + 5
|| estimate_compactable_tokens(history) > config.max_estimated_tokens
}
pub fn compact_history(
history: &[ChatMessage],
existing_summary: Option<&str>,
config: CompactionConfig,
anchor_index: Option<usize>,
) -> CompactionResult {
if history.len() <= config.preserve_recent_messages + 5 {
return CompactionResult {
messages: history.to_vec(),
summary: existing_summary.map(|s| s.to_string()),
};
}
let anchor = anchor_index.unwrap_or(1).max(1).min(history.len() - 1);
let keep_from = history
.len()
.saturating_sub(config.preserve_recent_messages);
let mut messages_to_summarize = Vec::new();
let mut preserved_messages = Vec::new();
if anchor > 1 {
messages_to_summarize.extend(history[1..anchor].iter().cloned());
}
preserved_messages.push(history[anchor].clone());
if keep_from > anchor + 1 {
messages_to_summarize.extend(history[anchor + 1..keep_from].iter().cloned());
preserved_messages.extend(history[keep_from..].iter().cloned());
} else {
preserved_messages.extend(history[anchor + 1..].iter().cloned());
}
let new_summary_txt = build_technical_summary(&messages_to_summarize);
let merged_summary = match existing_summary {
Some(existing) => merge_summaries(existing, &new_summary_txt),
None => new_summary_txt,
};
let summary_content = format!(
"{}{}{}",
COMPACT_PREAMBLE, merged_summary, COMPACT_INSTRUCTION
);
let summary_msg = ChatMessage::system(&summary_content);
let mut new_history = vec![history[0].clone()];
new_history.push(summary_msg);
new_history.extend(preserved_messages);
CompactionResult {
messages: new_history,
summary: Some(merged_summary),
}
}
pub fn extract_memory(messages: &[ChatMessage]) -> SessionMemory {
let mut mem = SessionMemory::default();
let last_user_idx = messages.iter().rposition(|m| m.role == "user");
if let Some(idx) = last_user_idx {
let m = &messages[idx];
let content_str = m.content.as_str();
let limit = 250;
mem.current_task = content_str.chars().take(limit).collect();
if content_str.len() > limit {
mem.current_task.push_str("...");
}
}
let mut all_files: Vec<String> = Vec::new();
for msg in messages {
if let Some(calls) = &msg.tool_calls {
for call in calls {
let args = call.function.arguments.clone();
if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
all_files.push(path.to_string());
}
}
}
}
let mut seen = HashSet::new();
for path in all_files.into_iter().rev() {
if seen.insert(path.clone()) {
mem.working_set.insert(path);
if mem.working_set.len() >= 12 {
break;
}
}
}
if let Some(idx) = last_user_idx {
for turn_msg in &messages[idx..] {
if turn_msg.role == "tool" {
let content_str = turn_msg.content.as_str();
if content_str.contains("Error:")
|| content_str.contains("Finished")
|| content_str.contains("Complete")
{
let lines: Vec<_> = content_str.lines().take(2).collect();
mem.learnings.push(lines.join(" "));
}
}
}
}
mem.learnings.dedup();
if mem.learnings.len() > 5 {
mem.learnings.truncate(5);
}
mem
}
pub fn estimate_tokens(messages: &[ChatMessage]) -> usize {
messages
.iter()
.map(|m| m.content.as_str().len() / 4 + 1)
.sum()
}
pub fn estimate_compactable_tokens(history: &[ChatMessage]) -> usize {
if history.len() <= 1 {
0
} else {
estimate_tokens(&history[1..])
}
}
fn build_technical_summary(messages: &[ChatMessage]) -> String {
let mut lines = vec![format!(
"- Scope: {} earlier turns compacted.",
messages.len()
)];
let mut files: IndexedSet = IndexedSet::default();
let mut tools: HashSet<String> = HashSet::new();
let mut requests: Vec<String> = Vec::new();
let mut assistant_notes: Vec<String> = Vec::new();
let mut verify_outcome: Option<bool> = None;
let mut error_snippets: Vec<String> = Vec::new();
for m in messages {
if let Some(calls) = &m.tool_calls {
for call in calls {
tools.insert(call.function.name.clone());
let args = call.function.arguments.clone();
if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
files.insert(path.to_string());
}
}
}
if m.role == "tool" {
let text = m.content.as_str();
if text.contains("BUILD OK") || text.contains("BUILD SUCCESS") {
verify_outcome = Some(true);
} else if text.contains("BUILD FAIL") || text.contains("error[") {
verify_outcome = Some(false);
}
if text.contains("Error:") || text.contains("error:") {
if let Some(err_line) = text.lines().find(|l| {
l.trim_start().starts_with("Error:") || l.trim_start().starts_with("error:")
}) {
let snippet: String = err_line.chars().take(100).collect();
error_snippets.push(snippet);
}
}
}
if m.role == "user" && !m.content.as_str().trim().is_empty() && requests.len() < 4 {
let text = m
.content
.as_str()
.trim_start_matches("/think\n")
.trim_start_matches("/no_think\n")
.trim();
requests.push(truncate_summary_line(
&collapse_inline_whitespace(text),
140,
));
}
if m.role == "assistant"
&& !m.content.as_str().trim().is_empty()
&& m.tool_calls.as_ref().map_or(true, |tc| tc.is_empty())
&& assistant_notes.len() < 3
{
let text = m.content.as_str().trim();
if text.len() > 20 {
assistant_notes.push(truncate_summary_line(
&collapse_inline_whitespace(text),
120,
));
}
}
for word in m.content.as_str().split_whitespace() {
let clean = word.trim_matches(|c: char| {
matches!(c, ',' | '.' | ':' | ';' | ')' | '(' | '"' | '\'' | '`')
});
if clean.len() > 4
&& clean.contains('.')
&& (clean.contains('/') || clean.contains('\\'))
{
files.insert(clean.to_string());
}
}
}
if !files.0.is_empty() {
let list: Vec<String> = files.0.into_iter().take(10).collect();
lines.push(format!("- Key files: {}.", list.join(", ")));
}
if !tools.is_empty() {
let list: Vec<String> = tools.into_iter().take(8).collect();
lines.push(format!("- Tools used: {}.", list.join(", ")));
}
if let Some(ok) = verify_outcome {
lines.push(format!(
"- Last verify_build: {}.",
if ok { "BUILD OK" } else { "BUILD FAILED" }
));
}
error_snippets.dedup();
for snippet in error_snippets.into_iter().take(2) {
lines.push(format!("- Error seen: {}", snippet));
}
if !assistant_notes.is_empty() {
lines.push("- Assistant decisions/responses (oldest→newest):".to_string());
for note in &assistant_notes {
lines.push(format!(" - {}", note));
}
}
if !requests.is_empty() {
lines.push("- User requests (oldest→newest):".to_string());
for request in &requests {
lines.push(format!(" - {}", request));
}
}
lines.push("- Compacted context:".to_string());
for m in messages.iter().rev().take(6).rev() {
let content_str = m.content.as_str();
let preview = if content_str.len() > 120 {
let mut s: String = content_str.chars().take(117).collect();
s.push_str("...");
s
} else if content_str.is_empty()
&& m.tool_calls
.as_ref()
.map(|c| !c.is_empty())
.unwrap_or(false)
{
format!(
"Executing: {}",
m.tool_calls
.as_ref()
.unwrap()
.iter()
.map(|c| c.function.name.as_str())
.collect::<Vec<_>>()
.join(", ")
)
} else {
content_str.to_string()
};
lines.push(format!(
" - {}: {}",
m.role,
preview.replace('\n', " ").trim()
));
}
compress_summary_text(&lines.join("\n"))
}
#[derive(Default)]
struct IndexedSet(Vec<String>);
impl IndexedSet {
fn insert(&mut self, s: String) {
if !self.0.contains(&s) {
self.0.push(s);
}
}
}
fn merge_summaries(existing: &str, new: &str) -> String {
compress_summary_text(&format!(
"Conversation summary:\n- Previously compacted context:\n{}\n- Newly compacted context:\n{}",
existing.trim(),
new.trim()
))
}
#[derive(Debug, Default)]
struct NormalizedSummary {
lines: Vec<String>,
removed_duplicate_lines: usize,
}
fn normalize_summary_lines(summary: &str, max_line_chars: usize) -> NormalizedSummary {
let mut seen = BTreeSet::new();
let mut lines = Vec::new();
let mut removed_duplicate_lines = 0;
for raw_line in summary.lines() {
let normalized = collapse_inline_whitespace(raw_line);
if normalized.is_empty() {
continue;
}
let truncated = truncate_summary_line(&normalized, max_line_chars);
let dedupe_key = truncated.to_ascii_lowercase();
if !seen.insert(dedupe_key) {
removed_duplicate_lines += 1;
continue;
}
lines.push(truncated);
}
NormalizedSummary {
lines,
removed_duplicate_lines,
}
}
fn select_summary_line_indexes(lines: &[String], budget: SummaryCompressionBudget) -> Vec<usize> {
let mut selected = BTreeSet::<usize>::new();
for priority in 0..=3 {
for (index, line) in lines.iter().enumerate() {
if selected.contains(&index) || summary_line_priority(line) != priority {
continue;
}
let candidate = selected
.iter()
.map(|selected_index| lines[*selected_index].as_str())
.chain(std::iter::once(line.as_str()))
.collect::<Vec<_>>();
if candidate.len() > budget.max_lines {
continue;
}
if joined_summary_char_count(&candidate) > budget.max_chars {
continue;
}
selected.insert(index);
}
}
selected.into_iter().collect()
}
fn push_summary_line_with_budget(
lines: &mut Vec<String>,
line: String,
budget: SummaryCompressionBudget,
) {
let candidate = lines
.iter()
.map(String::as_str)
.chain(std::iter::once(line.as_str()))
.collect::<Vec<_>>();
if candidate.len() <= budget.max_lines
&& joined_summary_char_count(&candidate) <= budget.max_chars
{
lines.push(line);
}
}
fn joined_summary_char_count(lines: &[&str]) -> usize {
lines.iter().map(|line| line.chars().count()).sum::<usize>() + lines.len().saturating_sub(1)
}
fn summary_line_priority(line: &str) -> usize {
if line == "Conversation summary:" || is_core_summary_detail(line) {
0
} else if line.ends_with(':') {
1
} else if line.starts_with("- ") || line.starts_with(" - ") {
2
} else {
3
}
}
fn is_core_summary_detail(line: &str) -> bool {
[
"- Scope:",
"- Key files referenced:",
"- Tools mentioned:",
"- Recent user requests:",
"- Previously compacted context:",
"- Newly compacted context:",
]
.iter()
.any(|prefix| line.starts_with(prefix))
}
fn collapse_inline_whitespace(line: &str) -> String {
line.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn truncate_summary_line(line: &str, max_chars: usize) -> String {
if max_chars == 0 || line.chars().count() <= max_chars {
return line.to_string();
}
if max_chars == 1 {
return ".".to_string();
}
let mut truncated = line
.chars()
.take(max_chars.saturating_sub(3))
.collect::<String>();
truncated.push_str("...");
truncated
}