use aegis_provider::Provider;
use aegis_types::message::{Message, Role};
use regex::Regex;
use std::sync::Arc;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompactionTrigger {
Soft,
Hard,
Emergency,
}
#[derive(Debug, PartialEq)]
pub enum CompactionAction {
None,
SoftCompacted {
trigger: String,
summarized: usize,
},
HardCompacted {
dropped: usize,
},
EmergencyCompacted {
kept: usize,
dropped: usize,
},
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CompressionTurn {
pub is_summary: bool,
pub text: String,
pub approx_tokens: u32,
pub messages_replaced: u32,
pub started_at: chrono::DateTime<chrono::Utc>,
pub ended_at: chrono::DateTime<chrono::Utc>,
}
impl CompressionTurn {
pub fn summary(text: String, approx_tokens: u32, messages_replaced: u32) -> Self {
let now = chrono::Utc::now();
Self {
is_summary: true,
text,
approx_tokens,
messages_replaced,
started_at: now,
ended_at: now,
}
}
pub fn message(text: String, approx_tokens: u32) -> Self {
let now = chrono::Utc::now();
Self {
is_summary: false,
text,
approx_tokens,
messages_replaced: 1,
started_at: now,
ended_at: now,
}
}
}
pub trait SummarizeAdapter: Send + Sync {
fn summarize(&self, messages: &[Message]) -> String;
fn summarize_rolling(&self, existing_summary: Option<&str>, messages: &[Message]) -> String;
}
pub struct NoopSummarizer;
impl SummarizeAdapter for NoopSummarizer {
fn summarize(&self, messages: &[Message]) -> String {
build_emergency_summary(messages)
}
fn summarize_rolling(&self, existing_summary: Option<&str>, messages: &[Message]) -> String {
let new_summary = build_emergency_summary(messages);
match existing_summary {
Some(existing) => format!("{}\n{}", existing, new_summary),
None => new_summary,
}
}
}
pub struct ProviderSummarizer {
provider: Arc<dyn Provider>,
timeout: Duration,
}
impl ProviderSummarizer {
pub fn new(provider: Arc<dyn Provider>, timeout: Duration) -> Self {
Self { provider, timeout }
}
fn run(&self, existing: Option<&str>, messages: &[Message]) -> String {
let convo = messages
.iter()
.map(|m| format!("{:?}: {}", m.role, m.text()))
.collect::<Vec<_>>()
.join("\n");
let instruction = match existing {
Some(prev) => format!(
"Update the running summary with the new messages. Keep it terse: \
key facts, decisions, file paths, and open threads only.\n\n\
Existing summary:\n{prev}\n\nNew messages:\n{convo}\n\nUpdated summary:"
),
None => format!(
"Summarize this conversation tersely — keep key facts, decisions, \
file paths, and open threads; drop chatter.\n\n{convo}\n\nSummary:"
),
};
let req = vec![
Message::system("You compress conversation history into a terse summary."),
Message::user(instruction),
];
let provider = self.provider.clone();
let timeout = self.timeout;
let result = tokio::task::block_in_place(move || {
tokio::runtime::Handle::current().block_on(async move {
tokio::time::timeout(timeout, provider.chat(&req, None)).await
})
});
match result {
Ok(Ok(resp)) => {
let text = resp.message.text();
if text.trim().is_empty() {
build_emergency_summary(messages)
} else {
text
}
}
_ => build_emergency_summary(messages),
}
}
}
impl SummarizeAdapter for ProviderSummarizer {
fn summarize(&self, messages: &[Message]) -> String {
self.run(None, messages)
}
fn summarize_rolling(&self, existing_summary: Option<&str>, messages: &[Message]) -> String {
self.run(existing_summary, messages)
}
}
fn tier_rank_from_str(s: &str) -> u8 {
match s {
"soft" => 0,
"emergency" => 2,
_ => 1,
}
}
pub fn safe_compaction_cutoff(messages: &[Message]) -> usize {
for i in (0..messages.len()).rev() {
if messages[i].role == Role::Tool {
return i + 1;
}
}
messages.len() / 2
}
pub fn build_emergency_summary(messages: &[Message]) -> String {
let mut paths: Vec<String> = Vec::new();
let mut tool_names: Vec<String> = Vec::new();
let mut error_lines: Vec<String> = Vec::new();
let path_re = Regex::new(r"[~/.]\S+\.\w+").expect("hardcoded regex is valid");
for msg in messages {
let text = msg.text();
let lines: Vec<&str> = text.lines().collect();
for cap in path_re.find_iter(&text) {
let p = cap.as_str().to_string();
if !paths.contains(&p) {
paths.push(p);
}
}
if let Some(tcs) = &msg.tool_calls {
for tc in tcs {
if !tool_names.contains(&tc.name) {
tool_names.push(tc.name.clone());
}
}
}
for (i, line) in lines.iter().enumerate() {
if line.contains("error") || line.contains("Error") || line.contains("failed") {
if i > 0 {
error_lines.push(lines[i - 1].to_string());
}
error_lines.push(line.to_string());
if i + 1 < lines.len() {
error_lines.push(lines[i + 1].to_string());
}
}
}
}
let mut summary = String::new();
if !paths.is_empty() {
summary.push_str(&format!("Files: {}\n", paths.join(", ")));
}
if !tool_names.is_empty() {
summary.push_str(&format!("Tools used: {}\n", tool_names.join(", ")));
}
if !error_lines.is_empty() {
summary.push_str("Errors:\n");
for l in error_lines.iter().take(10) {
summary.push_str(&format!(" {l}\n"));
}
}
if summary.len() > 500 {
summary.truncate(500);
summary.push_str("...");
}
summary
}
pub struct CompactionManager {
pub soft_threshold: f32,
pub hard_threshold: f32,
pub emergency_threshold: f32,
pub emergency_keep_recent: usize,
pub rolling_batch_size: usize,
pub model_from_tier: u8,
pub summarizer: Box<dyn SummarizeAdapter>,
pub summaries: Vec<CompressionTurn>,
}
impl Default for CompactionManager {
fn default() -> Self {
Self {
soft_threshold: 0.80,
hard_threshold: 0.90,
emergency_threshold: 0.95,
emergency_keep_recent: 6,
rolling_batch_size: 2,
model_from_tier: 1,
summarizer: Box::new(NoopSummarizer),
summaries: Vec::new(),
}
}
}
impl CompactionManager {
pub fn with_summarizer(summarizer: Box<dyn SummarizeAdapter>) -> Self {
Self {
summarizer,
..Default::default()
}
}
pub fn from_config(cfg: &crate::config::CompactionConfig, provider: Arc<dyn Provider>) -> Self {
let summarizer: Box<dyn SummarizeAdapter> = if cfg.summarizer == "model" {
Box::new(ProviderSummarizer::new(
provider,
Duration::from_millis(cfg.summarize_timeout_ms),
))
} else {
Box::new(NoopSummarizer)
};
Self {
soft_threshold: cfg.soft,
hard_threshold: cfg.hard,
emergency_threshold: cfg.emergency,
emergency_keep_recent: 6,
rolling_batch_size: 2,
model_from_tier: tier_rank_from_str(&cfg.model_from_tier),
summarizer,
summaries: Vec::new(),
}
}
pub fn check_and_compact(
&mut self,
messages: &mut Vec<Message>,
summary: &mut Option<String>,
used_tokens: usize,
budget: usize,
) -> CompactionAction {
if budget == 0 {
return CompactionAction::None;
}
let usage = used_tokens as f32 / budget as f32;
if usage < self.soft_threshold {
return CompactionAction::None;
}
if usage >= self.emergency_threshold {
let keep = self.emergency_keep_recent.min(messages.len());
let drop_count = messages.len() - keep;
let chunk: Vec<Message> = messages.drain(..drop_count).collect();
let es = build_emergency_summary(&chunk);
self.summaries.push(CompressionTurn::summary(
es.clone(),
(es.len() / 4) as u32,
drop_count as u32,
));
match summary {
Some(s) => {
s.push('\n');
s.push_str(&es);
}
None => *summary = Some(es),
}
return CompactionAction::EmergencyCompacted {
kept: keep,
dropped: drop_count,
};
}
if usage >= self.hard_threshold {
let cutoff = safe_compaction_cutoff(messages);
if cutoff == 0 {
return CompactionAction::None;
}
let chunk: Vec<Message> = messages.drain(..cutoff).collect();
let dropped = chunk.len();
let summarized = if self.model_from_tier <= 1 {
self.summarizer.summarize(&chunk)
} else {
build_emergency_summary(&chunk)
};
self.summaries.push(CompressionTurn::summary(
summarized.clone(),
(summarized.len() / 4) as u32,
dropped as u32,
));
match summary {
Some(s) => {
s.push('\n');
s.push_str(&summarized);
}
None => *summary = Some(summarized),
}
return CompactionAction::HardCompacted { dropped };
}
let count = self.rolling_batch_size.min(messages.len());
if count == 0 {
return CompactionAction::None;
}
let chunk: Vec<Message> = messages.drain(..count).collect();
let existing = summary.as_deref();
let rolling = if self.model_from_tier == 0 {
self.summarizer.summarize_rolling(existing, &chunk)
} else {
let s = build_emergency_summary(&chunk);
match existing {
Some(e) => format!("{e}\n{s}"),
None => s,
}
};
self.summaries.push(CompressionTurn::summary(
rolling.clone(),
(rolling.len() / 4) as u32,
count as u32,
));
*summary = Some(rolling);
CompactionAction::SoftCompacted {
trigger: format!(
"usage={:.0}%, rolling summarize {count} messages",
usage * 100.0
),
summarized: count,
}
}
pub fn compression_history(&self) -> &[CompressionTurn] {
&self.summaries
}
pub fn total_messages_replaced(&self) -> u32 {
self.summaries.iter().map(|t| t.messages_replaced).sum()
}
}
pub fn fold_completed_tool_sequences(messages: &[Message], staleness_turns: usize) -> Vec<Message> {
if messages.is_empty() {
return Vec::new();
}
let mut result: Vec<Message> = Vec::with_capacity(messages.len());
let mut i = 0;
while i < messages.len() {
if messages[i].role == Role::Assistant && messages[i].has_tool_calls() {
let seq_start = i;
let mut seq_end = i + 1;
while seq_end < messages.len()
&& (messages[seq_end].role == Role::Tool
|| (messages[seq_end].role == Role::Assistant
&& messages[seq_end].has_tool_calls()))
{
seq_end += 1;
}
let turns_after = messages[seq_end..]
.iter()
.filter(|m| m.role == Role::User || m.role == Role::Assistant)
.count();
if turns_after >= staleness_turns && (seq_end - seq_start) >= 2 {
let tool_count = messages[seq_start..seq_end]
.iter()
.filter(|m| m.role == Role::Tool)
.count();
let tool_names: Vec<String> = messages[seq_start..seq_end]
.iter()
.filter_map(|m| {
m.tool_calls.as_ref().and_then(|tcs| tcs.first().map(|tc| tc.name.clone()))
})
.collect();
let last_result = messages[seq_start..seq_end]
.iter()
.rev()
.find(|m| m.role == Role::Tool)
.map(|m| {
let t = m.text();
t.lines().next().unwrap_or("(done)").to_string()
})
.unwrap_or_else(|| "(done)".to_string());
let name_str = if tool_names.is_empty() {
"tools".to_string()
} else {
tool_names[0].clone()
};
let summary = format!(
"[Completed: ran {} ({} calls) → {}]",
name_str, tool_count, last_result
);
result.push(Message::assistant(summary));
i = seq_end;
} else {
result.push(messages[i].clone());
i += 1;
}
} else {
result.push(messages[i].clone());
i += 1;
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use aegis_types::message::{Message, Role};
fn make_message(role: Role, text: &str) -> Message {
match role {
Role::User => Message::user(text),
Role::Assistant => Message::assistant(text),
Role::Tool => Message::tool_result("call_0", text),
_ => Message::system(text),
}
}
#[test]
fn test_no_compaction_below_threshold() {
let mut mgr = CompactionManager::default();
let mut msgs = vec![
make_message(Role::User, "hello"),
make_message(Role::Assistant, "hi"),
];
let mut summary = None;
let n = msgs.len();
let action = mgr.check_and_compact(&mut msgs, &mut summary, n, 100);
assert_eq!(action, CompactionAction::None);
}
#[test]
fn test_soft_compaction() {
let mut mgr = CompactionManager::default();
let mut msgs: Vec<Message> = (0..85)
.map(|i| make_message(Role::User, &format!("msg {i}")))
.collect();
let mut summary = None;
let n = msgs.len();
let action = mgr.check_and_compact(&mut msgs, &mut summary, n, 100);
match action {
CompactionAction::SoftCompacted { summarized, .. } => {
assert_eq!(summarized, 2);
assert!(summary.is_some());
assert_eq!(msgs.len(), 83);
}
_ => panic!("Expected SoftCompacted, got {:?}", action),
}
}
#[test]
fn test_hard_compaction() {
let mut mgr = CompactionManager::default();
let mut msgs: Vec<Message> = (0..92)
.map(|i| make_message(Role::User, &format!("msg {i}")))
.collect();
msgs.push(make_message(Role::Tool, "tool result"));
let mut summary = None;
let n = msgs.len();
let action = mgr.check_and_compact(&mut msgs, &mut summary, n, 100);
match action {
CompactionAction::HardCompacted { dropped } => {
assert!(dropped > 0);
assert!(summary.is_some());
}
_ => panic!("Expected HardCompacted, got {:?}", action),
}
}
#[test]
fn test_emergency_compaction() {
let mut mgr = CompactionManager::default();
let mut msgs: Vec<Message> = (0..96)
.map(|i| make_message(Role::User, &format!("msg {i}")))
.collect();
let mut summary = None;
let n = msgs.len();
let action = mgr.check_and_compact(&mut msgs, &mut summary, n, 100);
match action {
CompactionAction::EmergencyCompacted { kept, dropped } => {
assert_eq!(kept, 6);
assert_eq!(dropped, 90);
assert!(summary.is_some());
}
_ => panic!("Expected EmergencyCompacted, got {:?}", action),
}
}
#[test]
fn test_total_messages_replaced() {
let mut mgr = CompactionManager::default();
let mut msgs: Vec<Message> = (0..96)
.map(|i| make_message(Role::User, &format!("msg {i}")))
.collect();
let mut summary = None;
let n = msgs.len();
mgr.check_and_compact(&mut msgs, &mut summary, n, 100);
assert_eq!(mgr.total_messages_replaced(), 90);
}
}