use meerkat_core::compact::{CompactionConfig, CompactionContext, CompactionResult, Compactor};
use meerkat_core::types::{ContentBlock, Message};
const COMPACTION_PROMPT: &str = "\
You are performing a CONTEXT COMPACTION. Your job is to create a handoff summary so work can continue seamlessly.
Include:
- Current progress and key decisions made
- Important context, constraints, or user preferences discovered
- What remains to be done (clear next steps)
- Any critical data, file paths, examples, or references needed to continue
- Tool call patterns that worked or failed
Be concise and structured. Prioritize information the next context needs to act, not narrate.";
const SUMMARY_PREFIX: &str = "\
[Context compacted] A previous context produced the following summary of work so far. \
The current tool and session state is preserved. Use this summary to continue without \
duplicating work:\n\n";
pub struct DefaultCompactor {
config: CompactionConfig,
}
impl DefaultCompactor {
pub fn new(config: CompactionConfig) -> Self {
Self { config }
}
}
fn strip_media_for_compaction(blocks: &[ContentBlock]) -> Vec<ContentBlock> {
blocks
.iter()
.map(|block| match block {
ContentBlock::Image { media_type, .. } => {
ContentBlock::Text {
text: format!("[image: {media_type}]"),
}
}
ContentBlock::Video { media_type, .. } => ContentBlock::Text {
text: format!("[video: {media_type}]"),
},
other => other.clone(),
})
.collect()
}
fn strip_media_from_messages(messages: &[Message]) -> Vec<Message> {
messages
.iter()
.map(|msg| match msg {
Message::User(user) => {
let content = strip_media_for_compaction(&user.content);
Message::User(meerkat_core::types::UserMessage::with_blocks(content))
}
Message::ToolResults { results } => {
let results = results
.iter()
.map(|r| {
let content = strip_media_for_compaction(&r.content);
meerkat_core::types::ToolResult::with_blocks(
r.tool_use_id.clone(),
content,
r.is_error,
)
})
.collect();
Message::ToolResults { results }
}
other => other.clone(),
})
.collect()
}
impl Compactor for DefaultCompactor {
fn should_compact(&self, ctx: &CompactionContext) -> bool {
if ctx.session_boundary_index == 0 {
return false;
}
if let Some(last) = ctx.last_compaction_boundary_index
&& ctx.session_boundary_index.saturating_sub(last)
< u64::from(self.config.min_turns_between_compactions)
{
return false;
}
ctx.last_input_tokens >= self.config.auto_compact_threshold
|| ctx.estimated_history_tokens >= self.config.auto_compact_threshold
}
fn prepare_for_summarization(&self, messages: &[Message]) -> Vec<Message> {
strip_media_from_messages(messages)
}
fn compaction_prompt(&self) -> &str {
COMPACTION_PROMPT
}
fn max_summary_tokens(&self) -> u32 {
self.config.max_summary_tokens
}
fn rebuild_history(&self, messages: &[Message], summary: &str) -> CompactionResult {
let mut rebuilt = Vec::new();
let mut discarded = Vec::new();
if let Some(Message::System(sys)) = messages.first() {
rebuilt.push(Message::System(sys.clone()));
}
let summary_content = format!("{SUMMARY_PREFIX}{summary}");
rebuilt.push(Message::User(meerkat_core::types::UserMessage::text(
summary_content,
)));
let non_system_start = messages
.iter()
.position(|m| !matches!(m, Message::System(_)))
.unwrap_or(0);
let history = &messages[non_system_start..];
let mut turn_starts: Vec<usize> = Vec::new();
for (i, msg) in history.iter().enumerate() {
if matches!(msg, Message::User(_)) {
turn_starts.push(i);
}
}
let retain_from = if self.config.recent_turn_budget == 0 {
history.len()
} else if turn_starts.len() > self.config.recent_turn_budget {
let idx = turn_starts.len() - self.config.recent_turn_budget;
turn_starts[idx]
} else {
0
};
for msg in &history[..retain_from] {
discarded.push(msg.clone());
}
rebuilt.extend(strip_media_from_messages(&history[retain_from..]));
CompactionResult {
messages: rebuilt,
discarded,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use meerkat_core::types::{SystemMessage, UserMessage};
fn make_config() -> CompactionConfig {
CompactionConfig {
auto_compact_threshold: 100_000,
recent_turn_budget: 2,
max_summary_tokens: 4096,
min_turns_between_compactions: 3,
}
}
#[test]
fn test_should_compact_first_turn_never() {
let c = DefaultCompactor::new(make_config());
let ctx = CompactionContext {
last_input_tokens: 200_000,
message_count: 100,
estimated_history_tokens: 200_000,
last_compaction_boundary_index: None,
session_boundary_index: 0,
};
assert!(!c.should_compact(&ctx));
}
#[test]
fn test_should_compact_loop_guard() {
let c = DefaultCompactor::new(make_config());
let ctx = CompactionContext {
last_input_tokens: 200_000,
message_count: 100,
estimated_history_tokens: 200_000,
last_compaction_boundary_index: Some(5),
session_boundary_index: 7, };
assert!(!c.should_compact(&ctx));
}
#[test]
fn test_should_compact_follow_up_run_boundary_zero_no_longer_special() {
let c = DefaultCompactor::new(make_config());
let ctx = CompactionContext {
last_input_tokens: 200_000,
message_count: 100,
estimated_history_tokens: 200_000,
last_compaction_boundary_index: None,
session_boundary_index: 1,
};
assert!(c.should_compact(&ctx));
}
#[test]
fn test_should_compact_dual_threshold() {
let c = DefaultCompactor::new(make_config());
let ctx = CompactionContext {
last_input_tokens: 100_000,
message_count: 50,
estimated_history_tokens: 50_000,
last_compaction_boundary_index: None,
session_boundary_index: 5,
};
assert!(c.should_compact(&ctx));
let ctx2 = CompactionContext {
last_input_tokens: 50_000,
message_count: 50,
estimated_history_tokens: 100_000,
last_compaction_boundary_index: None,
session_boundary_index: 5,
};
assert!(c.should_compact(&ctx2));
}
#[test]
fn test_rebuild_preserves_system_prompt() {
let c = DefaultCompactor::new(make_config());
let messages = vec![
Message::System(SystemMessage {
content: "system".to_string(),
}),
Message::User(UserMessage::text("turn1")),
Message::User(UserMessage::text("turn2")),
Message::User(UserMessage::text("turn3")),
];
let result = c.rebuild_history(&messages, "summary text");
assert!(matches!(&result.messages[0], Message::System(s) if s.content == "system"));
}
#[test]
fn test_rebuild_keeps_recent_turns_not_just_user() {
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 1,
..make_config()
});
let messages = vec![
Message::User(UserMessage::text("turn1")),
Message::User(UserMessage::text("turn2")),
Message::User(UserMessage::text("turn3")),
];
let result = c.rebuild_history(&messages, "summary");
assert_eq!(result.messages.len(), 2); assert_eq!(result.discarded.len(), 2); }
#[test]
fn test_rebuild_respects_turn_budget() {
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 2,
..make_config()
});
let messages = vec![
Message::User(UserMessage::text("t1")),
Message::User(UserMessage::text("t2")),
Message::User(UserMessage::text("t3")),
Message::User(UserMessage::text("t4")),
];
let result = c.rebuild_history(&messages, "summary");
assert_eq!(result.messages.len(), 3);
assert_eq!(result.discarded.len(), 2); }
#[test]
fn test_rebuild_budget_larger_than_history_keeps_all_turns() {
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 10,
..make_config()
});
let messages = vec![
Message::User(UserMessage::text("t1")),
Message::User(UserMessage::text("t2")),
Message::User(UserMessage::text("t3")),
];
let result = c.rebuild_history(&messages, "summary");
assert_eq!(result.messages.len(), 4);
assert_eq!(result.discarded.len(), 0);
}
#[test]
fn test_rebuild_discarded_messages_in_order() {
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 1,
..make_config()
});
let messages = vec![
Message::User(UserMessage::text("a")),
Message::User(UserMessage::text("b")),
Message::User(UserMessage::text("c")),
];
let result = c.rebuild_history(&messages, "summary");
assert_eq!(result.discarded.len(), 2);
if let Message::User(u) = &result.discarded[0] {
assert_eq!(u.text_content(), "a");
}
if let Message::User(u) = &result.discarded[1] {
assert_eq!(u.text_content(), "b");
}
}
#[test]
fn test_rebuild_zero_budget_discards_all() {
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 0,
..make_config()
});
let messages = vec![
Message::User(UserMessage::text("a")),
Message::User(UserMessage::text("b")),
Message::User(UserMessage::text("c")),
];
let result = c.rebuild_history(&messages, "summary");
assert_eq!(result.messages.len(), 1);
assert_eq!(result.discarded.len(), 3);
}
#[test]
fn test_rebuild_with_block_assistant_and_tool_results() {
use meerkat_core::types::{AssistantBlock, BlockAssistantMessage, StopReason, ToolResult};
use serde_json::value::RawValue;
let args_raw = RawValue::from_string(r#"{"city":"Tokyo"}"#.to_string()).unwrap();
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 1,
..make_config()
});
let messages = vec![
Message::System(SystemMessage {
content: "You are helpful.".to_string(),
}),
Message::User(UserMessage::text("What is the weather?")),
Message::BlockAssistant(BlockAssistantMessage {
blocks: vec![AssistantBlock::ToolUse {
id: "tc_1".to_string(),
name: "get_weather".to_string(),
args: args_raw,
meta: None,
}],
stop_reason: StopReason::ToolUse,
}),
Message::ToolResults {
results: vec![ToolResult::new(
"tc_1".to_string(),
"Sunny, 25C".to_string(),
false,
)],
},
Message::BlockAssistant(BlockAssistantMessage {
blocks: vec![AssistantBlock::Text {
text: "It's sunny in Tokyo!".to_string(),
meta: None,
}],
stop_reason: StopReason::EndTurn,
}),
Message::User(UserMessage::text("Thanks!")),
Message::BlockAssistant(BlockAssistantMessage {
blocks: vec![AssistantBlock::Text {
text: "You're welcome!".to_string(),
meta: None,
}],
stop_reason: StopReason::EndTurn,
}),
];
let result = c.rebuild_history(&messages, "Summary of weather conversation");
assert_eq!(result.messages.len(), 4); assert!(matches!(&result.messages[0], Message::System(_)));
assert_eq!(result.discarded.len(), 4);
}
#[test]
fn compaction_strips_media_preserves_text() {
let blocks = vec![
ContentBlock::Text {
text: "hello".to_string(),
},
ContentBlock::Image {
media_type: "image/png".to_string(),
data: "base64data".into(),
},
ContentBlock::Video {
media_type: "video/mp4".to_string(),
duration_ms: 5_000,
data: meerkat_core::VideoData::Inline {
data: "videodata".to_string(),
},
},
ContentBlock::Text {
text: "world".to_string(),
},
];
let result = strip_media_for_compaction(&blocks);
assert_eq!(result.len(), 4);
assert!(matches!(&result[0], ContentBlock::Text { text } if text == "hello"));
assert!(matches!(&result[1], ContentBlock::Text { text } if text == "[image: image/png]"));
assert!(matches!(&result[2], ContentBlock::Text { text } if text == "[video: video/mp4]"));
assert!(matches!(&result[3], ContentBlock::Text { text } if text == "world"));
}
#[test]
fn compaction_image_placeholder_excludes_source_path() {
let blocks = vec![ContentBlock::Image {
media_type: "image/png".to_string(),
data: "base64data".into(),
}];
let result = strip_media_for_compaction(&blocks);
assert_eq!(result.len(), 1);
assert!(matches!(&result[0], ContentBlock::Text { text } if text == "[image: image/png]"));
if let ContentBlock::Text { text } = &result[0] {
assert!(
!text.contains("/tmp/x.png"),
"source_path must not leak into placeholder"
);
}
}
#[test]
fn compaction_text_only_unchanged() {
let blocks = vec![
ContentBlock::Text {
text: "one".to_string(),
},
ContentBlock::Text {
text: "two".to_string(),
},
];
let result = strip_media_for_compaction(&blocks);
assert_eq!(result.len(), 2);
assert!(matches!(&result[0], ContentBlock::Text { text } if text == "one"));
assert!(matches!(&result[1], ContentBlock::Text { text } if text == "two"));
}
#[test]
fn prepare_for_summarization_strips_user_and_tool_media() {
use meerkat_core::types::ToolResult;
let c = DefaultCompactor::new(make_config());
let messages = vec![
Message::User(UserMessage::with_blocks(vec![
ContentBlock::Text {
text: "Look at this".to_string(),
},
ContentBlock::Image {
media_type: "image/jpeg".to_string(),
data: "bigdata".into(),
},
ContentBlock::Video {
media_type: "video/mp4".to_string(),
duration_ms: 5_000,
data: meerkat_core::VideoData::Inline {
data: "video".to_string(),
},
},
])),
Message::ToolResults {
results: vec![ToolResult::with_blocks(
"tc_1".to_string(),
vec![
ContentBlock::Text {
text: "screenshot captured".to_string(),
},
ContentBlock::Image {
media_type: "image/png".to_string(),
data: "screenshotdata".into(),
},
ContentBlock::Video {
media_type: "video/webm".to_string(),
duration_ms: 7_000,
data: meerkat_core::VideoData::Inline {
data: "toolvideo".to_string(),
},
},
],
false,
)],
},
];
let prepared = c.prepare_for_summarization(&messages);
assert_eq!(prepared.len(), 2);
if let Message::User(u) = &prepared[0] {
assert_eq!(u.content.len(), 3);
assert!(matches!(&u.content[0], ContentBlock::Text { text } if text == "Look at this"));
assert!(
matches!(&u.content[1], ContentBlock::Text { text } if text == "[image: image/jpeg]")
);
assert!(
matches!(&u.content[2], ContentBlock::Text { text } if text == "[video: video/mp4]")
);
} else {
panic!("expected User message");
}
if let Message::ToolResults { results } = &prepared[1] {
assert_eq!(results.len(), 1);
assert_eq!(results[0].content.len(), 3);
assert!(
matches!(&results[0].content[0], ContentBlock::Text { text } if text == "screenshot captured")
);
assert!(
matches!(&results[0].content[1], ContentBlock::Text { text } if text == "[image: image/png]")
);
assert!(
matches!(&results[0].content[2], ContentBlock::Text { text } if text == "[video: video/webm]")
);
} else {
panic!("expected ToolResults message");
}
}
#[test]
fn rebuild_history_nukes_videos_from_retained_turns() {
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 1,
..make_config()
});
let messages = vec![
Message::User(UserMessage::text("old text turn")),
Message::User(UserMessage::with_blocks(vec![
ContentBlock::Text {
text: "latest with video".to_string(),
},
ContentBlock::Video {
media_type: "video/mp4".to_string(),
duration_ms: 5_000,
data: meerkat_core::VideoData::Inline {
data: "video-data".to_string(),
},
},
])),
];
let result = c.rebuild_history(&messages, "summary");
assert_eq!(result.messages.len(), 2, "summary + retained turn");
let retained = result.messages.last().expect("retained turn");
match retained {
Message::User(user) => {
assert_eq!(user.content.len(), 2);
assert!(matches!(
&user.content[0],
ContentBlock::Text { text } if text == "latest with video"
));
assert!(matches!(
&user.content[1],
ContentBlock::Text { text } if text == "[video: video/mp4]"
));
}
other => panic!("expected retained user turn, got {other:?}"),
}
}
#[test]
fn rebuild_history_nukes_images_from_retained_turns() {
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 1,
..make_config()
});
let messages = vec![
Message::User(UserMessage::text("old text turn")),
Message::User(UserMessage::with_blocks(vec![
ContentBlock::Text {
text: "latest with image".to_string(),
},
ContentBlock::Image {
media_type: "image/png".to_string(),
data: meerkat_core::types::ImageData::Blob {
blob_id: meerkat_core::BlobId::new("sha256:test"),
},
},
])),
];
let result = c.rebuild_history(&messages, "summary");
assert_eq!(result.messages.len(), 2, "summary + retained turn");
let retained = result.messages.last().expect("retained turn");
match retained {
Message::User(user) => {
assert_eq!(user.content.len(), 2);
assert!(matches!(
&user.content[0],
ContentBlock::Text { text } if text == "latest with image"
));
assert!(matches!(
&user.content[1],
ContentBlock::Text { text } if text == "[image: image/png]"
));
}
other => panic!("expected retained user turn, got {other:?}"),
}
}
#[test]
fn rebuild_history_nukes_tool_result_images_from_retained_turns() {
use meerkat_core::types::ToolResult;
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 1,
..make_config()
});
let messages = vec![
Message::User(UserMessage::text("old turn")),
Message::User(UserMessage::text("latest turn")),
Message::ToolResults {
results: vec![ToolResult::with_blocks(
"tool_1".to_string(),
vec![
ContentBlock::Text {
text: "saw this".to_string(),
},
ContentBlock::Image {
media_type: "image/jpeg".to_string(),
data: "abc".into(),
},
],
false,
)],
},
];
let result = c.rebuild_history(&messages, "summary");
assert_eq!(
result.messages.len(),
3,
"summary + retained user + tool results"
);
match &result.messages[2] {
Message::ToolResults { results } => {
assert_eq!(results.len(), 1);
assert!(matches!(
&results[0].content[1],
ContentBlock::Text { text } if text == "[image: image/jpeg]"
));
}
other => panic!("expected retained tool results, got {other:?}"),
}
}
}