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 project_media_for_summarization(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 project_messages_for_summarization(messages: &[Message]) -> Vec<Message> {
messages
.iter()
.map(|msg| match msg {
Message::User(user) => {
let content = project_media_for_summarization(&user.content);
let mut user = user.clone();
user.content = content;
Message::User(user)
}
Message::ToolResults {
results,
created_at,
} => {
let results = results
.iter()
.map(|r| {
let content = project_media_for_summarization(&r.content);
meerkat_core::types::ToolResult::with_blocks(
r.tool_use_id.clone(),
content,
r.is_error,
)
})
.collect();
Message::ToolResults {
results,
created_at: *created_at,
}
}
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;
}
let input_trigger = ctx.last_input_tokens >= self.config.auto_compact_threshold;
let history_trigger = ctx.estimated_history_tokens >= self.config.auto_compact_threshold;
if input_trigger || history_trigger {
tracing::trace!(
input_tokens = ctx.last_input_tokens,
estimated_history_tokens = ctx.estimated_history_tokens,
threshold = self.config.auto_compact_threshold,
branch = if input_trigger {
"last_input_tokens"
} else {
"estimated_history_tokens_fallback"
},
"compaction trigger fired",
);
}
input_trigger || history_trigger
}
fn prepare_for_summarization(&self, messages: &[Message]) -> Vec<Message> {
project_messages_for_summarization(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(history[retain_from..].iter().cloned());
CompactionResult {
messages: rebuilt,
discarded,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use meerkat_core::BlobId;
use meerkat_core::types::{ImageData, SystemMessage, UserMessage, VideoData};
fn make_config() -> CompactionConfig {
CompactionConfig {
auto_compact_threshold: 100_000,
recent_turn_budget: 2,
max_summary_tokens: 4096,
min_turns_between_compactions: 3,
}
}
fn inline_image_block(media_type: &str, data: &str) -> ContentBlock {
ContentBlock::Image {
media_type: media_type.to_string(),
data: ImageData::Inline {
data: data.to_string(),
},
}
}
fn blob_image_block(media_type: &str, blob_id: &str) -> ContentBlock {
ContentBlock::Image {
media_type: media_type.to_string(),
data: ImageData::Blob {
blob_id: BlobId::new(blob_id),
},
}
}
fn inline_video_block(media_type: &str, duration_ms: u64, data: &str) -> ContentBlock {
ContentBlock::Video {
media_type: media_type.to_string(),
duration_ms,
data: VideoData::Inline {
data: data.to_string(),
},
}
}
fn assert_blob_image(block: &ContentBlock, expected_media_type: &str, expected_blob_id: &str) {
match block {
ContentBlock::Image {
media_type,
data: ImageData::Blob { blob_id },
} => {
assert_eq!(media_type, expected_media_type);
assert_eq!(blob_id.as_str(), expected_blob_id);
}
other => panic!("expected blob image block, got {other:?}"),
}
}
fn assert_inline_image(block: &ContentBlock, expected_media_type: &str, expected_data: &str) {
match block {
ContentBlock::Image {
media_type,
data: ImageData::Inline { data },
} => {
assert_eq!(media_type, expected_media_type);
assert_eq!(data, expected_data);
}
other => panic!("expected inline image block, got {other:?}"),
}
}
fn assert_inline_video(
block: &ContentBlock,
expected_media_type: &str,
expected_duration_ms: u64,
expected_data: &str,
) {
match block {
ContentBlock::Video {
media_type,
duration_ms,
data: VideoData::Inline { data },
} => {
assert_eq!(media_type, expected_media_type);
assert_eq!(*duration_ms, expected_duration_ms);
assert_eq!(data, expected_data);
}
other => panic!("expected inline video block, got {other:?}"),
}
}
#[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_voice_only_session_compacts_via_estimated_history_fallback() {
let c = DefaultCompactor::new(make_config());
let ctx = CompactionContext {
last_input_tokens: 0,
message_count: 200,
estimated_history_tokens: 150_000,
last_compaction_boundary_index: None,
session_boundary_index: 42,
};
assert!(
c.should_compact(&ctx),
"voice-only session must compact via estimated_history_tokens \
when last_input_tokens is zero",
);
}
#[test]
fn test_should_not_compact_when_neither_threshold_met() {
let c = DefaultCompactor::new(make_config());
let ctx = CompactionContext {
last_input_tokens: 50_000,
message_count: 20,
estimated_history_tokens: 50_000,
last_compaction_boundary_index: None,
session_boundary_index: 5,
};
assert!(!c.should_compact(&ctx));
}
#[test]
fn test_rebuild_preserves_system_prompt() {
let c = DefaultCompactor::new(make_config());
let messages = vec![
Message::System(SystemMessage::new("system")),
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::new("You are helpful.")),
Message::User(UserMessage::text("What is the weather?")),
Message::BlockAssistant(BlockAssistantMessage::new(
vec![AssistantBlock::ToolUse {
id: "tc_1".to_string(),
name: "get_weather".to_string(),
args: args_raw,
meta: None,
}],
StopReason::ToolUse,
)),
Message::tool_results(vec![ToolResult::new(
"tc_1".to_string(),
"Sunny, 25C".to_string(),
false,
)]),
Message::BlockAssistant(BlockAssistantMessage::new(
vec![AssistantBlock::Text {
text: "It's sunny in Tokyo!".to_string(),
meta: None,
}],
StopReason::EndTurn,
)),
Message::User(UserMessage::text("Thanks!")),
Message::BlockAssistant(BlockAssistantMessage::new(
vec![AssistantBlock::Text {
text: "You're welcome!".to_string(),
meta: None,
}],
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 summary_projection_replaces_media_preserves_text() {
let blocks = vec![
ContentBlock::Text {
text: "hello".to_string(),
},
inline_image_block("image/png", "base64data"),
inline_video_block("video/mp4", 5_000, "videodata"),
ContentBlock::Text {
text: "world".to_string(),
},
];
let result = project_media_for_summarization(&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![inline_image_block("image/png", "base64data")];
let result = project_media_for_summarization(&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 = project_media_for_summarization(&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_projects_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(),
},
inline_image_block("image/jpeg", "bigdata"),
inline_video_block("video/mp4", 5_000, "video"),
])),
Message::tool_results(vec![ToolResult::with_blocks(
"tc_1".to_string(),
vec![
ContentBlock::Text {
text: "screenshot captured".to_string(),
},
inline_image_block("image/png", "screenshotdata"),
inline_video_block("video/webm", 7_000, "toolvideo"),
],
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 prepare_for_summarization_projects_media_without_mutating_source_history() {
use meerkat_core::types::ToolResult;
let c = DefaultCompactor::new(make_config());
let messages = vec![
Message::User(UserMessage::with_blocks(vec![
ContentBlock::Text {
text: "keep source typed".to_string(),
},
blob_image_block("image/png", "sha256:source-image"),
inline_video_block("video/webm", 3_000, "source-video"),
])),
Message::tool_results(vec![ToolResult::with_blocks(
"tool_1".to_string(),
vec![
ContentBlock::Text {
text: "tool media".to_string(),
},
inline_image_block("image/jpeg", "tool-image"),
],
true,
)]),
];
let prepared = c.prepare_for_summarization(&messages);
match &prepared[0] {
Message::User(user) => {
assert_eq!(user.content.len(), 3);
assert!(
matches!(&user.content[1], ContentBlock::Text { text } if text == "[image: image/png]")
);
assert!(
matches!(&user.content[2], ContentBlock::Text { text } if text == "[video: video/webm]")
);
}
other => panic!("expected projected user message, got {other:?}"),
}
match &prepared[1] {
Message::ToolResults { results, .. } => {
assert_eq!(results.len(), 1);
assert_eq!(results[0].tool_use_id, "tool_1");
assert!(results[0].is_error);
assert!(
matches!(&results[0].content[1], ContentBlock::Text { text } if text == "[image: image/jpeg]")
);
}
other => panic!("expected projected tool results, got {other:?}"),
}
match &messages[0] {
Message::User(user) => {
assert_blob_image(&user.content[1], "image/png", "sha256:source-image");
assert_inline_video(&user.content[2], "video/webm", 3_000, "source-video");
}
other => panic!("expected original user message, got {other:?}"),
}
match &messages[1] {
Message::ToolResults { results, .. } => {
assert_inline_image(&results[0].content[1], "image/jpeg", "tool-image");
}
other => panic!("expected original tool results, got {other:?}"),
}
}
#[test]
fn rebuild_history_preserves_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(),
},
inline_video_block("video/mp4", 5_000, "video-data"),
])),
];
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_inline_video(&user.content[1], "video/mp4", 5_000, "video-data");
}
other => panic!("expected retained user turn, got {other:?}"),
}
}
#[test]
fn rebuild_history_preserves_blob_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(),
},
blob_image_block("image/png", "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_blob_image(&user.content[1], "image/png", "sha256:test");
}
other => panic!("expected retained user turn, got {other:?}"),
}
}
#[test]
fn rebuild_history_preserves_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::tool_results(vec![ToolResult::with_blocks(
"tool_1".to_string(),
vec![
ContentBlock::Text {
text: "saw this".to_string(),
},
inline_image_block("image/jpeg", "abc"),
],
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_inline_image(&results[0].content[1], "image/jpeg", "abc");
}
other => panic!("expected retained tool results, got {other:?}"),
}
}
#[test]
fn rebuild_history_retained_multimodal_shape_survives_json_roundtrip() {
use meerkat_core::types::ToolResult;
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 1,
..make_config()
});
let messages = vec![
Message::System(SystemMessage::new("system")),
Message::User(UserMessage::text("discarded turn")),
Message::User(UserMessage::with_blocks(vec![
ContentBlock::Text {
text: "latest media".to_string(),
},
blob_image_block("image/png", "sha256:latest-image"),
inline_video_block("video/mp4", 8_000, "latest-video"),
])),
Message::tool_results(vec![ToolResult::with_blocks(
"tool_2".to_string(),
vec![
ContentBlock::Text {
text: "tool image".to_string(),
},
inline_image_block("image/jpeg", "tool-image"),
],
false,
)]),
];
let result = c.rebuild_history(&messages, "summary");
let json = serde_json::to_string(&result.messages).expect("serialize rebuilt transcript");
let round_tripped: Vec<Message> =
serde_json::from_str(&json).expect("deserialize rebuilt transcript");
assert_eq!(
round_tripped.len(),
4,
"system + summary + retained user + retained tool results"
);
match &round_tripped[2] {
Message::User(user) => {
assert_eq!(user.content.len(), 3);
assert!(matches!(
&user.content[0],
ContentBlock::Text { text } if text == "latest media"
));
assert_blob_image(&user.content[1], "image/png", "sha256:latest-image");
assert_inline_video(&user.content[2], "video/mp4", 8_000, "latest-video");
}
other => panic!("expected retained user message after roundtrip, got {other:?}"),
}
match &round_tripped[3] {
Message::ToolResults { results, .. } => {
assert_eq!(results.len(), 1);
assert_eq!(results[0].tool_use_id, "tool_2");
assert_inline_image(&results[0].content[1], "image/jpeg", "tool-image");
}
other => panic!("expected retained tool results after roundtrip, got {other:?}"),
}
assert!(
!json.contains("[image:") && !json.contains("[video:"),
"retained transcript JSON must keep typed media blocks, not summary placeholders: {json}"
);
}
}