use lellm_core::{Message, text_block};
use super::budget::ContextBudget;
use super::compactor::{CompactionResult, ContextCompactor};
#[derive(Debug, Default)]
pub struct LocalCompactor;
impl LocalCompactor {
pub fn new() -> Self {
Self
}
}
impl ContextCompactor for LocalCompactor {
fn compact(&self, messages: &[Message], budget: &ContextBudget) -> CompactionResult {
let before_tokens = super::estimation::estimate_tokens(messages);
let before_count = messages.len();
let (system_msgs, conversation): (Vec<_>, Vec<_>) = messages
.iter()
.partition(|m| matches!(m, Message::System { .. }));
let turns = extract_turns(&conversation);
let keep = budget.keep_recent_turns;
if turns.len() <= keep {
return CompactionResult {
messages: messages.to_vec(),
before_tokens,
after_tokens: before_tokens,
removed_messages: 0,
};
}
let recent_turns: Vec<_> = turns.iter().skip(turns.len() - keep).collect();
let old_turns: Vec<_> = turns.iter().take(turns.len() - keep).collect();
let summary = summarize_turns(&old_turns);
let mut result = system_msgs.into_iter().cloned().collect::<Vec<_>>();
if !summary.is_empty() {
result.push(Message::System {
content: text_block(format!("[Previous conversation summary]\n{summary}")),
});
}
for turn in recent_turns {
for msg in turn {
result.push((*msg).clone());
}
}
let after_tokens = super::estimation::estimate_tokens(&result);
let removed = before_count.saturating_sub(result.len());
if removed > 0 {
tracing::debug!(
before_tokens,
after_tokens,
removed_messages = removed,
before_count,
after_count = result.len(),
"LocalCompactor: context compressed"
);
}
CompactionResult {
messages: result,
before_tokens,
after_tokens,
removed_messages: removed,
}
}
}
fn extract_turns<'a>(messages: &[&'a Message]) -> Vec<Vec<&'a Message>> {
let mut turns: Vec<Vec<&Message>> = Vec::new();
let mut current_turn: Vec<&Message> = Vec::new();
for msg in messages {
match msg {
Message::Assistant { .. } => {
if !current_turn.is_empty() {
turns.push(current_turn);
current_turn = Vec::new();
}
current_turn.push(msg);
}
Message::ToolResult { .. } => {
current_turn.push(msg);
}
Message::User { .. } => {
if current_turn.is_empty() {
current_turn.push(msg);
} else {
turns.push(current_turn);
current_turn = vec![msg];
}
}
Message::System { .. } => {
}
}
}
if !current_turn.is_empty() {
turns.push(current_turn);
}
turns
}
fn summarize_turns(turns: &[&Vec<&Message>]) -> String {
let mut lines = Vec::new();
for (idx, turn) in turns.iter().enumerate() {
let _prefix = format!("Turn {}:", idx + 1);
for msg in *turn {
match msg {
Message::Assistant { content } => {
let texts: Vec<_> = content.iter().filter_map(|b| b.as_text()).collect();
let tool_calls = msg.extract_tool_calls();
if !texts.is_empty() {
let text = texts.join(" ");
let summary = truncate_chars(&text, 200);
lines.push(format!(" Assistant: {}", summary));
}
if !tool_calls.is_empty() {
for tc in &tool_calls {
let args_summary = truncate_chars(&tc.arguments.to_string(), 100);
lines.push(format!(" Tool({}): {}", tc.name, args_summary));
}
}
}
Message::ToolResult {
is_error, content, ..
} => {
let status = if *is_error { "ERROR" } else { "OK" };
let text: String = content
.iter()
.filter_map(|b| b.as_text())
.collect::<Vec<_>>()
.join(" ");
let summary = truncate_chars(&text, 100);
lines.push(format!(" {} Result: {}", status, summary));
}
Message::User { content } => {
let text: String = content
.iter()
.filter_map(|b| b.as_text())
.collect::<Vec<_>>()
.join(" ");
let summary = truncate_chars(&text, 200);
lines.push(format!(" User: {}", summary));
}
_ => {}
}
}
}
if lines.is_empty() {
return String::new();
}
let total_turns = turns.len();
format!("[Compressed {} turns]\n{}", total_turns, lines.join("\n"))
}
fn truncate_chars(s: &str, max: usize) -> String {
let count = s.chars().count();
if count <= max {
return s.to_string();
}
let truncated: String = s.chars().take(max).collect();
format!("{}… ({} chars)", truncated, count)
}
#[cfg(test)]
mod tests {
use super::*;
use lellm_core::ContentBlock;
#[test]
fn test_extract_turns_atomic() {
let assistant = Message::Assistant {
content: vec![ContentBlock::ToolCall(lellm_core::ToolCall {
id: "call_1".into(),
name: "test".into(),
arguments: serde_json::json!({}),
})],
};
let tool_result = Message::ToolResult {
tool_call_id: "call_1".to_string(),
is_error: false,
content: text_block("ok".to_string()),
};
let messages = vec![&assistant, &tool_result];
let turns = extract_turns(&messages);
assert_eq!(turns.len(), 1);
assert_eq!(turns[0].len(), 2);
}
#[test]
fn test_extract_turns_multiple() {
let user = Message::User {
content: text_block("hello".to_string()),
};
let assistant = Message::Assistant {
content: vec![ContentBlock::ToolCall(lellm_core::ToolCall {
id: "call_1".into(),
name: "test".into(),
arguments: serde_json::json!({}),
})],
};
let tool_result = Message::ToolResult {
tool_call_id: "call_1".to_string(),
is_error: false,
content: text_block("ok".to_string()),
};
let assistant2 = Message::Assistant {
content: text_block("final answer".to_string()),
};
let messages = vec![&user, &assistant, &tool_result, &assistant2];
let turns = extract_turns(&messages);
assert_eq!(turns.len(), 3);
assert_eq!(turns[0].len(), 1); assert_eq!(turns[1].len(), 2); assert_eq!(turns[2].len(), 1); }
}