use std::sync::{Arc, Mutex};
#[allow(deprecated)]
use swink_agent::sliding_window;
use swink_agent::{AgentMessage, AssistantMessage, ContentBlock, LlmMessage};
#[non_exhaustive]
#[derive(Debug)]
pub struct CompactionResult {
pub messages: Vec<AgentMessage>,
pub removed_count: usize,
pub summary: Option<String>,
}
impl CompactionResult {
#[must_use]
pub fn new(messages: Vec<AgentMessage>, removed_count: usize, summary: Option<String>) -> Self {
Self {
messages,
removed_count,
summary,
}
}
}
pub struct SummarizingCompactor {
summary: Arc<Mutex<Option<String>>>,
normal_budget: usize,
overflow_budget: usize,
anchor: usize,
}
impl SummarizingCompactor {
pub fn new(normal_budget: usize, overflow_budget: usize, anchor: usize) -> Self {
Self {
summary: Arc::new(Mutex::new(None)),
normal_budget,
overflow_budget,
anchor,
}
}
pub fn compaction_fn(&self) -> impl Fn(&mut Vec<AgentMessage>, bool) + Send + Sync {
let summary = Arc::clone(&self.summary);
#[allow(deprecated)]
let base = sliding_window(self.normal_budget, self.overflow_budget, self.anchor);
let anchor = self.anchor;
move |messages: &mut Vec<AgentMessage>, overflow: bool| {
let len_before = messages.len();
base(messages, overflow);
let len_after = messages.len();
if len_after < len_before {
let mut guard = summary
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(text) = guard.take() {
let summary_msg = AgentMessage::Llm(LlmMessage::Assistant(
AssistantMessage::new(
vec![ContentBlock::Text {
text: format!("[Context summary of earlier conversation]\n{text}"),
}],
String::new(),
String::new(),
)
.with_timestamp(0),
));
let insert_pos = anchor.min(messages.len());
messages.insert(insert_pos, summary_msg);
}
}
}
}
pub fn set_summary(&self, text: impl Into<String>) {
let mut guard = self
.summary
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*guard = Some(text.into());
}
pub fn clear_summary(&self) {
let mut guard = self
.summary
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*guard = None;
}
pub fn has_summary(&self) -> bool {
let guard = self
.summary
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
use swink_agent::{ContentBlock, LlmMessage, UserMessage};
fn text_message(text: &str) -> AgentMessage {
AgentMessage::Llm(LlmMessage::User(
UserMessage::new(vec![ContentBlock::Text {
text: text.to_owned(),
}])
.with_timestamp(0),
))
}
#[test]
fn without_summary_behaves_like_sliding_window() {
let compactor = SummarizingCompactor::new(250, 100, 1);
let compact = compactor.compaction_fn();
let body = "x".repeat(400);
let mut messages = vec![
text_message(&body),
text_message(&body),
text_message(&body),
text_message(&body),
];
compact(&mut messages, false);
assert_eq!(messages.len(), 2);
}
#[test]
fn with_summary_injects_after_anchor() {
let compactor = SummarizingCompactor::new(250, 100, 1);
compactor.set_summary("Earlier we discussed testing strategies.");
let compact = compactor.compaction_fn();
let body = "x".repeat(400);
let mut messages = vec![
text_message(&body),
text_message(&body),
text_message(&body),
text_message(&body),
];
compact(&mut messages, false);
assert_eq!(messages.len(), 3);
if let AgentMessage::Llm(LlmMessage::Assistant(a)) = &messages[1] {
let text = ContentBlock::extract_text(&a.content);
assert!(
text.contains("[Context summary"),
"expected summary prefix, got: {text}"
);
assert!(text.contains("testing strategies"));
} else {
panic!("expected assistant message at index 1");
}
}
#[test]
fn no_compaction_needed_no_summary_injected() {
let compactor = SummarizingCompactor::new(10_000, 5_000, 1);
compactor.set_summary("This should not appear.");
let compact = compactor.compaction_fn();
let mut messages = vec![text_message("hello"), text_message("world")];
compact(&mut messages, false);
assert_eq!(messages.len(), 2);
}
#[test]
fn clear_summary_removes_stored_text() {
let compactor = SummarizingCompactor::new(100, 50, 1);
compactor.set_summary("some summary");
assert!(compactor.has_summary());
compactor.clear_summary();
assert!(!compactor.has_summary());
}
#[test]
fn summary_injected_as_assistant_message() {
let compactor = SummarizingCompactor::new(250, 100, 1);
compactor.set_summary("Key discussion points here.");
let compact = compactor.compaction_fn();
let body = "x".repeat(400);
let mut messages = vec![
text_message(&body),
text_message(&body),
text_message(&body),
];
compact(&mut messages, false);
let summary_msg = messages.iter().find(|m| {
if let AgentMessage::Llm(LlmMessage::Assistant(a)) = m {
ContentBlock::extract_text(&a.content).contains("[Context summary")
} else {
false
}
});
assert!(
summary_msg.is_some(),
"summary should be an AssistantMessage"
);
}
#[test]
fn compaction_with_single_message_returns_unchanged() {
let compactor = SummarizingCompactor::new(10_000, 5_000, 1);
let compact = compactor.compaction_fn();
let mut messages = vec![text_message("single message")];
compact(&mut messages, false);
assert_eq!(messages.len(), 1);
}
#[test]
fn summary_consumed_after_injection() {
let compactor = SummarizingCompactor::new(250, 100, 1);
compactor.set_summary("Consumed summary.");
let compact = compactor.compaction_fn();
let body = "x".repeat(400);
let mut messages = vec![
text_message(&body),
text_message(&body),
text_message(&body),
text_message(&body),
];
compact(&mut messages, false);
assert!(
!compactor.has_summary(),
"summary should be consumed after injection"
);
}
}