use crate::traits::{ConversationSummary, ModelProvider, StateStore};
use crate::utils::floor_char_boundary;
use chrono::Utc;
use serde_json::{json, Value};
use std::sync::Arc;
use tracing::warn;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CompactionTrigger {
WindowOverflow { aging_pair_user_msg_id: String },
IdleGap,
FileUpload,
}
const REFERENTIAL_PHRASES: &[&str] = &[
"mentioned",
"discussed",
"we were working on",
"the file from",
"here's the",
];
pub(crate) fn detect_compaction_trigger(
total_pairs: usize,
window_size: usize,
idle_gap_seconds: u64,
user_text: &str,
) -> Option<CompactionTrigger> {
if idle_gap_seconds > 7200 {
return Some(CompactionTrigger::IdleGap);
}
if user_text.contains("[File received:") {
let lower = user_text.to_lowercase();
let has_reference = REFERENTIAL_PHRASES
.iter()
.any(|phrase| lower.contains(phrase));
if !has_reference {
return Some(CompactionTrigger::FileUpload);
}
}
if total_pairs > window_size {
return Some(CompactionTrigger::WindowOverflow {
aging_pair_user_msg_id: String::new(), });
}
None
}
#[allow(dead_code)]
pub(crate) struct PendingCompaction {
pub pair_ids: Vec<String>,
}
#[allow(dead_code)]
impl PendingCompaction {
pub fn new() -> Self {
Self {
pair_ids: Vec::new(),
}
}
pub fn add(&mut self, user_msg_id: String) -> Option<String> {
self.pair_ids.push(user_msg_id);
if self.pair_ids.len() > 3 {
Some(self.pair_ids.remove(0))
} else {
None
}
}
pub fn drain_completed(&mut self, last_compacted_msg_id: &str) {
self.pair_ids.retain(|id| id != last_compacted_msg_id);
}
pub fn is_empty(&self) -> bool {
self.pair_ids.is_empty()
}
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.pair_ids.len()
}
}
fn format_messages_for_prompt(messages: &[Value]) -> String {
messages
.iter()
.filter_map(|msg| {
let role = msg.get("role")?.as_str()?;
match role {
"user" => {
let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
Some(format!("User: {}", content))
}
"assistant" => {
let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
if content.is_empty() {
None
} else {
Some(format!("Assistant: {}", content))
}
}
"tool" => {
let tool_name = msg
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("unknown");
let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
let summary = if content.len() > 200 {
format!("{}...", &content[..floor_char_boundary(content, 200)])
} else {
content.to_string()
};
Some(format!("Tool: {} -> {}", tool_name, summary))
}
_ => None,
}
})
.collect::<Vec<_>>()
.join("\n")
}
const COMPACTION_SYSTEM_PROMPT: &str =
"You are a conversation summarizer. Produce concise summaries that preserve specific details.";
const INITIAL_COMPACTION_USER_TEMPLATE: &str = "\
Summarize this conversation in 1500 tokens or fewer.
Preserve with exact values:
- Names, IDs, numbers, file paths, URLs
- Decisions made and their reasoning
- Task outcomes (success/failure and what was produced)
- Identity-critical information about the user
Drop:
- Conversational filler and politeness
- Detailed reasoning that led to decisions (keep the decision, drop the deliberation)
- Failed attempts that were later corrected
Conversation:
";
const INCREMENTAL_COMPACTION_USER_TEMPLATE: &str = "\
Here is the existing conversation summary and new conversation turns.
Update the summary to include the new information. Stay under 1500 tokens.
Preserve names, IDs, numbers, decisions, outcomes.
Existing summary:
";
pub(crate) fn build_compaction_prompt(
existing_summary: Option<&str>,
messages_to_compact: &[Value],
) -> Vec<Value> {
let formatted = format_messages_for_prompt(messages_to_compact);
let user_content = match existing_summary {
Some(summary) => {
format!(
"{}{}\n\nNew turns:\n{}",
INCREMENTAL_COMPACTION_USER_TEMPLATE, summary, formatted
)
}
None => {
format!("{}{}", INITIAL_COMPACTION_USER_TEMPLATE, formatted)
}
};
vec![
json!({ "role": "system", "content": COMPACTION_SYSTEM_PROMPT }),
json!({ "role": "user", "content": user_content }),
]
}
#[allow(dead_code)]
pub(crate) async fn run_compaction(
provider: Arc<dyn ModelProvider>,
model: &str,
existing_summary: Option<&str>,
messages_to_compact: &[Value],
) -> anyhow::Result<String> {
let messages = build_compaction_prompt(existing_summary, messages_to_compact);
let result = match tokio::time::timeout(
std::time::Duration::from_secs(15),
provider.chat(model, &messages, &[]),
)
.await
{
Ok(Ok(response)) => response,
Ok(Err(e)) => {
warn!(error = %e, "Compaction LLM call failed");
return Err(e);
}
Err(_) => {
warn!("Compaction LLM call timed out (15s)");
return Err(anyhow::anyhow!("Compaction LLM call timed out"));
}
};
let content = result.content.unwrap_or_default().trim().to_string();
if content.is_empty() {
return Err(anyhow::anyhow!("Compaction LLM returned empty content"));
}
Ok(content)
}
#[allow(dead_code, clippy::too_many_arguments)]
pub(crate) async fn run_and_store_compaction(
provider: Arc<dyn ModelProvider>,
model: &str,
state: &dyn StateStore,
session_id: &str,
existing_summary: Option<ConversationSummary>,
messages_to_compact: &[Value],
new_message_count: usize,
last_message_id: &str,
) -> anyhow::Result<()> {
let existing_text = existing_summary.as_ref().map(|s| s.summary.as_str());
let summary_text = run_compaction(provider, model, existing_text, messages_to_compact).await?;
let summary = ConversationSummary {
session_id: session_id.to_string(),
summary: summary_text,
message_count: new_message_count,
last_message_id: last_message_id.to_string(),
updated_at: Utc::now(),
};
state.upsert_conversation_summary(&summary).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_idle_gap() {
let result = detect_compaction_trigger(3, 5, 7201, "hello");
assert_eq!(result, Some(CompactionTrigger::IdleGap));
}
#[test]
fn test_detect_file_upload_no_reference() {
let result =
detect_compaction_trigger(3, 5, 0, "[File received: photo.jpg] check this out");
assert_eq!(result, Some(CompactionTrigger::FileUpload));
}
#[test]
fn test_detect_file_upload_with_reference() {
let result = detect_compaction_trigger(
3,
5,
0,
"[File received: config.yml] here's the file we discussed",
);
assert_eq!(result, None);
}
#[test]
fn test_detect_window_overflow() {
let result = detect_compaction_trigger(6, 5, 0, "hello");
assert!(matches!(
result,
Some(CompactionTrigger::WindowOverflow { .. })
));
}
#[test]
fn test_detect_no_trigger() {
let result = detect_compaction_trigger(3, 5, 0, "hello");
assert_eq!(result, None);
}
#[test]
fn test_idle_gap_takes_priority_over_overflow() {
let result = detect_compaction_trigger(6, 5, 7201, "hello");
assert_eq!(result, Some(CompactionTrigger::IdleGap));
}
#[test]
fn test_pending_cap_at_3() {
let mut pending = PendingCompaction::new();
assert!(pending.add("msg-1".to_string()).is_none());
assert!(pending.add("msg-2".to_string()).is_none());
assert!(pending.add("msg-3".to_string()).is_none());
let dropped = pending.add("msg-4".to_string());
assert_eq!(dropped, Some("msg-1".to_string()));
assert_eq!(pending.len(), 3);
assert_eq!(pending.pair_ids, vec!["msg-2", "msg-3", "msg-4"]);
}
#[test]
fn test_pending_drain_completed() {
let mut pending = PendingCompaction::new();
pending.add("msg-1".to_string());
pending.add("msg-2".to_string());
pending.add("msg-3".to_string());
pending.drain_completed("msg-2");
assert_eq!(pending.len(), 2);
assert_eq!(pending.pair_ids, vec!["msg-1", "msg-3"]);
}
#[test]
fn test_build_compaction_prompt_initial() {
let messages = vec![
json!({ "role": "user", "content": "What time is it?" }),
json!({ "role": "assistant", "content": "It is 3pm." }),
];
let prompt = build_compaction_prompt(None, &messages);
assert_eq!(prompt.len(), 2);
let system = prompt[0]["content"].as_str().unwrap();
assert!(system.contains("conversation summarizer"));
let user = prompt[1]["content"].as_str().unwrap();
assert!(user.contains("Summarize this conversation"));
assert!(user.contains("User: What time is it?"));
assert!(user.contains("Assistant: It is 3pm."));
assert!(!user.contains("Existing summary"));
}
#[test]
fn test_build_compaction_prompt_incremental() {
let messages = vec![
json!({ "role": "user", "content": "Now deploy it." }),
json!({ "role": "assistant", "content": "Deploying now." }),
];
let existing = "User asked about project setup. Created config.toml.";
let prompt = build_compaction_prompt(Some(existing), &messages);
assert_eq!(prompt.len(), 2);
let system = prompt[0]["content"].as_str().unwrap();
assert!(system.contains("conversation summarizer"));
let user = prompt[1]["content"].as_str().unwrap();
assert!(user.contains("existing conversation summary"));
assert!(user.contains("Existing summary:"));
assert!(user.contains(existing));
assert!(user.contains("New turns:"));
assert!(user.contains("User: Now deploy it."));
assert!(user.contains("Assistant: Deploying now."));
assert!(!user.contains("Summarize this conversation"));
}
}