#[cfg(feature = "native")]
use std::sync::Arc;
use async_trait::async_trait;
use tracing::warn;
use super::tokenizer::count_tokens;
use crate::pipeline::traits::LlmMessage;
#[cfg(feature = "native")]
use clawft_llm::{ChatMessage, ChatRequest, Provider};
#[derive(Debug, Clone)]
pub struct CompressionConfig {
pub max_context_tokens: usize,
pub recent_message_count: usize,
pub compression_enabled: bool,
}
impl Default for CompressionConfig {
fn default() -> Self {
Self {
max_context_tokens: 8192,
recent_message_count: 10,
compression_enabled: true,
}
}
}
#[derive(Debug, Clone)]
pub struct CompressionMetadata {
pub original_tokens: usize,
pub compressed_tokens: usize,
pub compression_ratio: f64,
pub messages_summarized: usize,
}
#[derive(Debug, Clone)]
pub struct CompressedContext {
pub messages: Vec<LlmMessage>,
pub metadata: CompressionMetadata,
}
#[derive(Debug, thiserror::Error)]
pub enum SummarizeError {
#[error("summarizer backend failed: {0}")]
Backend(String),
#[error("summarizer returned no usable summary")]
Empty,
}
#[async_trait]
pub trait Summarizer: Send + Sync {
async fn summarize(&self, messages: &[LlmMessage]) -> Result<String, SummarizeError>;
}
#[cfg(feature = "native")]
pub struct LlmSummarizer {
provider: Arc<dyn Provider>,
model: String,
max_tokens: i32,
}
#[cfg(feature = "native")]
impl LlmSummarizer {
const SYSTEM_PROMPT: &'static str = "You compress conversation history. \
Summarize the following turns into a concise block that preserves facts, \
decisions, names, numbers, file paths, and open questions. Do not add new \
information, opinions, or pleasantries. Output only the summary.";
pub fn new(provider: Arc<dyn Provider>, model: impl Into<String>, max_tokens: i32) -> Self {
Self {
provider,
model: model.into(),
max_tokens,
}
}
fn render_turns(messages: &[LlmMessage]) -> String {
let mut out = String::new();
for msg in messages {
out.push('[');
out.push_str(&msg.role);
out.push_str("]: ");
out.push_str(&msg.content);
out.push('\n');
}
out
}
}
#[cfg(feature = "native")]
#[async_trait]
impl Summarizer for LlmSummarizer {
async fn summarize(&self, messages: &[LlmMessage]) -> Result<String, SummarizeError> {
if messages.is_empty() {
return Ok(String::new());
}
let request = ChatRequest {
model: self.model.clone(),
messages: vec![
ChatMessage::system(Self::SYSTEM_PROMPT),
ChatMessage::user(Self::render_turns(messages)),
],
max_tokens: Some(self.max_tokens),
temperature: Some(0.0),
tools: Vec::new(),
stream: None,
tool_choice: None,
};
let response = self
.provider
.complete(&request)
.await
.map_err(|e| SummarizeError::Backend(e.to_string()))?;
let summary = response
.choices
.into_iter()
.next()
.and_then(|c| c.message.content)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or(SummarizeError::Empty)?;
Ok(summary)
}
}
struct Partition {
system: Vec<LlmMessage>,
old: Vec<LlmMessage>,
recent: Vec<LlmMessage>,
}
fn partition(messages: Vec<LlmMessage>, config: &CompressionConfig) -> Partition {
let mut system = Vec::new();
let mut conversation = Vec::new();
for msg in messages {
if msg.role == "system" {
system.push(msg);
} else {
conversation.push(msg);
}
}
let recent_count = config.recent_message_count.min(conversation.len());
let split = conversation.len() - recent_count;
let recent = conversation.split_off(split);
Partition {
system,
old: conversation,
recent,
}
}
fn assemble(
part: Partition,
summary: Option<String>,
original_tokens: usize,
messages_summarized: usize,
) -> CompressedContext {
let mut result = Vec::with_capacity(part.system.len() + 1 + part.recent.len());
result.extend(part.system);
if let Some(summary_text) = summary {
result.push(LlmMessage {
role: "system".into(),
content: format!(
"# Conversation Summary (compressed)\n\n\
The following is a summary of {messages_summarized} earlier messages:\n\n{summary_text}"
),
tool_call_id: None,
tool_calls: None,
});
}
result.extend(part.recent);
let compressed_tokens: usize = result.iter().map(|m| count_tokens(&m.content)).sum();
let compression_ratio = if original_tokens > 0 {
compressed_tokens as f64 / original_tokens as f64
} else {
1.0
};
CompressedContext {
messages: result,
metadata: CompressionMetadata {
original_tokens,
compressed_tokens,
compression_ratio,
messages_summarized,
},
}
}
fn fits(original_tokens: usize, config: &CompressionConfig) -> bool {
!config.compression_enabled || original_tokens <= config.max_context_tokens
}
fn no_op(messages: Vec<LlmMessage>, original_tokens: usize) -> CompressedContext {
CompressedContext {
messages,
metadata: CompressionMetadata {
original_tokens,
compressed_tokens: original_tokens,
compression_ratio: 1.0,
messages_summarized: 0,
},
}
}
fn first_sentence(text: &str) -> &str {
for (i, ch) in text.char_indices() {
if matches!(ch, '.' | '!' | '?') {
let end = i + ch.len_utf8();
if end >= text.len() || text[end..].starts_with(char::is_whitespace) {
return &text[..end];
}
}
}
let limit = text
.char_indices()
.nth(120)
.map(|(i, _)| i)
.unwrap_or(text.len());
&text[..limit]
}
fn extractive_summary(old: &[LlmMessage]) -> Option<String> {
if old.is_empty() {
return None;
}
let mut lines = Vec::with_capacity(old.len());
for msg in old {
lines.push(format!("[{}]: {}", msg.role, first_sentence(&msg.content)));
}
Some(lines.join("\n"))
}
pub fn compress_context(
messages: Vec<LlmMessage>,
config: &CompressionConfig,
) -> CompressedContext {
let original_tokens: usize = messages.iter().map(|m| count_tokens(&m.content)).sum();
if fits(original_tokens, config) {
return no_op(messages, original_tokens);
}
let part = partition(messages, config);
let messages_summarized = part.old.len();
let summary = extractive_summary(&part.old);
assemble(part, summary, original_tokens, messages_summarized)
}
pub async fn compress_context_with(
messages: Vec<LlmMessage>,
config: &CompressionConfig,
summarizer: &dyn Summarizer,
) -> CompressedContext {
let original_tokens: usize = messages.iter().map(|m| count_tokens(&m.content)).sum();
if fits(original_tokens, config) {
return no_op(messages, original_tokens);
}
let part = partition(messages, config);
let messages_summarized = part.old.len();
let summary = if part.old.is_empty() {
None
} else {
match summarizer.summarize(&part.old).await {
Ok(s) if !s.trim().is_empty() => Some(s),
Ok(_) | Err(SummarizeError::Empty) => {
warn!("summarizer produced no summary; using extractive fallback");
extractive_summary(&part.old)
}
Err(e) => {
warn!(error = %e, "summarizer failed; using extractive fallback");
extractive_summary(&part.old)
}
}
};
assemble(part, summary, original_tokens, messages_summarized)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_msg(role: &str, content: &str) -> LlmMessage {
LlmMessage {
role: role.into(),
content: content.into(),
tool_call_id: None,
tool_calls: None,
}
}
#[test]
fn first_sentence_extracts_correctly() {
assert_eq!(
first_sentence("Hello world. More text here."),
"Hello world."
);
assert_eq!(first_sentence("No period here"), "No period here");
assert_eq!(first_sentence("Question? Yes."), "Question?");
assert_eq!(first_sentence("Exclaim! Done."), "Exclaim!");
}
#[test]
fn compress_context_no_op_when_within_budget() {
let messages = vec![
make_msg("system", "You are helpful."),
make_msg("user", "Hi"),
make_msg("assistant", "Hello!"),
];
let config = CompressionConfig {
max_context_tokens: 100_000,
recent_message_count: 10,
compression_enabled: true,
};
let result = compress_context(messages.clone(), &config);
assert_eq!(result.messages.len(), 3);
assert_eq!(result.metadata.compression_ratio, 1.0);
assert_eq!(result.metadata.messages_summarized, 0);
}
#[test]
fn compress_context_no_op_when_disabled() {
let messages = vec![
make_msg("system", "sys"),
make_msg("user", "a long message that exceeds budget"),
];
let config = CompressionConfig {
max_context_tokens: 1,
recent_message_count: 10,
compression_enabled: false,
};
let result = compress_context(messages.clone(), &config);
assert_eq!(result.messages.len(), 2);
assert_eq!(result.metadata.messages_summarized, 0);
}
#[test]
fn compress_context_preserves_system_prompt() {
let mut messages = vec![make_msg(
"system",
"You are a helpful assistant with many capabilities.",
)];
for i in 0..20 {
messages.push(make_msg("user", &format!("User message number {i}. This is a fairly long message to inflate token count significantly.")));
messages.push(make_msg("assistant", &format!("Assistant response number {i}. Also fairly long to push tokens over the budget limit.")));
}
let config = CompressionConfig {
max_context_tokens: 50,
recent_message_count: 4,
compression_enabled: true,
};
let result = compress_context(messages, &config);
assert_eq!(result.messages[0].role, "system");
assert!(result.messages[0].content.contains("helpful assistant"));
let summary = result
.messages
.iter()
.find(|m| m.content.contains("Conversation Summary"));
assert!(summary.is_some(), "should have a summary message");
let non_system: Vec<_> = result
.messages
.iter()
.filter(|m| m.role != "system")
.collect();
assert_eq!(non_system.len(), 4);
let last = result.messages.last().unwrap();
assert_eq!(last.role, "assistant");
assert!(last.content.contains("response number 19"));
}
#[test]
fn compress_context_recent_messages_intact() {
let mut messages = vec![make_msg("system", "sys prompt")];
for i in 0..15 {
messages.push(make_msg(
"user",
&format!("msg {i} with enough words to make the token count go over budget easily"),
));
}
let config = CompressionConfig {
max_context_tokens: 10,
recent_message_count: 5,
compression_enabled: true,
};
let result = compress_context(messages, &config);
let user_msgs: Vec<_> = result
.messages
.iter()
.filter(|m| m.role == "user")
.collect();
assert_eq!(user_msgs.len(), 5);
for (idx, msg) in user_msgs.iter().enumerate() {
let expected_num = 10 + idx;
assert!(msg.content.contains(&format!("msg {expected_num}")));
}
}
#[test]
fn compress_context_metadata_accuracy() {
let mut messages = vec![make_msg("system", "short system")];
for i in 0..10 {
messages.push(make_msg(
"user",
&format!("Message number {i} with several words to inflate the count."),
));
}
let original_tokens: usize = messages.iter().map(|m| count_tokens(&m.content)).sum();
let config = CompressionConfig {
max_context_tokens: 10,
recent_message_count: 2,
compression_enabled: true,
};
let result = compress_context(messages, &config);
assert_eq!(result.metadata.original_tokens, original_tokens);
assert_eq!(result.metadata.messages_summarized, 8);
assert!(result.metadata.compression_ratio > 0.0);
let actual_compressed: usize = result
.messages
.iter()
.map(|m| count_tokens(&m.content))
.sum();
assert_eq!(result.metadata.compressed_tokens, actual_compressed);
}
struct MockSummarizer {
canned: String,
}
#[async_trait]
impl Summarizer for MockSummarizer {
async fn summarize(&self, messages: &[LlmMessage]) -> Result<String, SummarizeError> {
Ok(format!("{} ({} turns)", self.canned, messages.len()))
}
}
struct FailingSummarizer;
#[async_trait]
impl Summarizer for FailingSummarizer {
async fn summarize(&self, _messages: &[LlmMessage]) -> Result<String, SummarizeError> {
Err(SummarizeError::Backend("boom".into()))
}
}
fn over_budget_messages() -> Vec<LlmMessage> {
let mut messages = vec![make_msg("system", "sys prompt")];
for i in 0..20 {
messages.push(make_msg(
"user",
&format!("User turn {i} discussing an important topic at length with detail."),
));
messages.push(make_msg(
"assistant",
&format!("Assistant turn {i} with a thorough and helpful response body."),
));
}
messages
}
#[tokio::test]
async fn compress_with_summarizer_uses_summary_and_respects_budget() {
let config = CompressionConfig {
max_context_tokens: 80,
recent_message_count: 4,
compression_enabled: true,
};
let summarizer = MockSummarizer {
canned: "SUMMARY_OF_OLD".into(),
};
let messages = over_budget_messages();
let result = compress_context_with(messages, &config, &summarizer).await;
let summary = result
.messages
.iter()
.find(|m| m.content.contains("Conversation Summary"))
.expect("summary present");
assert!(
summary.content.contains("SUMMARY_OF_OLD"),
"summary should carry the summarizer output, got: {}",
summary.content
);
let non_system: Vec<_> = result
.messages
.iter()
.filter(|m| m.role != "system")
.collect();
assert_eq!(non_system.len(), 4);
assert!(result.metadata.messages_summarized > 0);
}
#[tokio::test]
async fn compress_with_summarizer_no_op_within_budget() {
let config = CompressionConfig {
max_context_tokens: 1_000_000,
recent_message_count: 10,
compression_enabled: true,
};
let summarizer = MockSummarizer {
canned: "unused".into(),
};
let messages = over_budget_messages();
let n = messages.len();
let result = compress_context_with(messages, &config, &summarizer).await;
assert_eq!(result.messages.len(), n);
assert_eq!(result.metadata.compression_ratio, 1.0);
}
#[tokio::test]
async fn compress_with_summarizer_falls_back_on_error() {
let config = CompressionConfig {
max_context_tokens: 80,
recent_message_count: 4,
compression_enabled: true,
};
let messages = over_budget_messages();
let result = compress_context_with(messages, &config, &FailingSummarizer).await;
let summary = result
.messages
.iter()
.find(|m| m.content.contains("Conversation Summary"));
assert!(summary.is_some(), "extractive fallback should summarize");
assert!(result.metadata.messages_summarized > 0);
}
#[cfg(feature = "native")]
#[tokio::test]
#[ignore = "requires live Hermes endpoint (ADR-060 0.1, blocked on model download)"]
async fn live_llm_summarizer_compresses() {
use clawft_llm::LocalProvider;
let provider: Arc<dyn Provider> = Arc::new(LocalProvider::hermes_serving());
let summarizer = LlmSummarizer::new(provider, "hermes-4.3-36b", 256);
let config = CompressionConfig {
max_context_tokens: 80,
recent_message_count: 4,
compression_enabled: true,
};
let result = compress_context_with(over_budget_messages(), &config, &summarizer).await;
let summary = result
.messages
.iter()
.find(|m| m.content.contains("Conversation Summary"))
.expect("summary present");
assert!(summary.content.len() > "# Conversation Summary (compressed)".len());
}
}