use std::future::Future;
use parking_lot::Mutex;
use tracing::{debug, warn};
use crate::error::Result;
pub const COMPACTION_TAG: &str = "[compacted prior context]";
pub(crate) const KEEP_RECENT_TURNS: usize = 6;
const MIN_HISTORY_TO_COMPACT: usize = 8;
const SUMMARY_PROMPT: &str = "You maintain a rolling summary of a long agent conversation. \
Below is the PRIOR SUMMARY (already-distilled older context) followed by NEW TURNS that \
just aged out of the live window. Produce an UPDATED rolling summary in 200 words or less \
that folds the new turns into the prior summary: preserve key facts, decisions, file paths, \
and any open user requests; drop greetings, chit-chat, and redundant tool output. Do not \
lose information that was in the prior summary unless it is now obsolete. Output only the \
updated summary; no preamble.";
pub(crate) trait CompactionModel {
type Message: Clone;
fn is_user(m: &Self::Message) -> bool;
fn sole_text(m: &Self::Message) -> Option<&str>;
fn is_tool_result_turn(m: &Self::Message) -> bool;
fn user_text(text: String) -> Self::Message;
fn render_message(m: &Self::Message, out: &mut String);
}
pub(crate) struct FoldPlan {
pub(crate) split: usize,
pub(crate) prior_summary: Option<String>,
pub(crate) delta_start: usize,
}
pub(crate) async fn try_compact<A, F, Fut>(
history: &Mutex<Vec<A::Message>>,
summarize: F,
) -> bool
where
A: CompactionModel,
F: FnOnce(String) -> Fut,
Fut: Future<Output = Result<String>>,
{
let snapshot = history.lock().clone();
let total = snapshot.len();
if total < MIN_HISTORY_TO_COMPACT {
debug!(total, "compaction: history too short, skipping");
return false;
}
let plan = match plan_fold::<A>(&snapshot) {
Some(p) => p,
None => {
debug!("compaction: nothing to fold before the keep-window");
return false;
}
};
let delta = &snapshot[plan.delta_start..plan.split];
debug!(
prior_summary = plan.prior_summary.is_some(),
delta = delta.len(),
to_keep = total - plan.split,
"compaction: attempting incremental fold"
);
let prompt = fold_prompt::<A>(plan.prior_summary.as_deref(), delta);
let summary = match summarize(prompt).await {
Ok(s) => s,
Err(e) => {
warn!(error = %e, "compaction: summarization failed; folding to drop-oldest");
return drop_oldest_fallback::<A>(history, plan.split, plan.prior_summary.as_deref());
}
};
if summary.trim().is_empty() {
warn!("compaction: summarization returned empty text; folding to drop-oldest");
return drop_oldest_fallback::<A>(history, plan.split, plan.prior_summary.as_deref());
}
let synthetic = A::user_text(format!("{COMPACTION_TAG}\n{summary}"));
let mut hist = history.lock();
if hist.len() != total {
warn!("compaction: history changed under us; aborting install");
return false;
}
let kept: Vec<A::Message> = hist.split_off(plan.split);
hist.clear();
hist.push(synthetic);
hist.extend(kept);
debug!(new_len = hist.len(), "compaction: installed folded summary");
true
}
pub(crate) fn plan_fold<A: CompactionModel>(history: &[A::Message]) -> Option<FoldPlan> {
let split = pick_split::<A>(history, KEEP_RECENT_TURNS);
if split == 0 {
return None;
}
let prior_summary = extract_prior_summary::<A>(history.first());
let delta_start = if prior_summary.is_some() { 1 } else { 0 };
if delta_start >= split {
return None;
}
Some(FoldPlan {
split,
prior_summary,
delta_start,
})
}
pub(crate) fn extract_prior_summary<A: CompactionModel>(
head: Option<&A::Message>,
) -> Option<String> {
let m = head?;
if !A::is_user(m) {
return None;
}
let text = A::sole_text(m)?;
let rest = text.strip_prefix(COMPACTION_TAG)?;
let body = rest.trim_start_matches('\n').to_string();
if body.trim().is_empty() {
return None;
}
Some(body)
}
pub(crate) fn pick_split<A: CompactionModel>(history: &[A::Message], keep_pairs: usize) -> usize {
let keep_entries = keep_pairs * 2;
if history.len() <= keep_entries {
return 0;
}
let mut split = history.len() - keep_entries;
while split > 0 && is_leading_orphan::<A>(history, split) {
split -= 1;
}
split
}
fn is_leading_orphan<A: CompactionModel>(history: &[A::Message], split: usize) -> bool {
match history.get(split) {
Some(m) => A::is_tool_result_turn(m),
None => false,
}
}
pub(crate) fn fold_prompt<A: CompactionModel>(
prior_summary: Option<&str>,
delta: &[A::Message],
) -> String {
let mut body = String::new();
body.push_str(SUMMARY_PROMPT);
body.push_str("\n\n--- PRIOR SUMMARY ---\n");
match prior_summary {
Some(s) if !s.trim().is_empty() => body.push_str(s),
_ => body.push_str("(none — this is the first compaction)"),
}
body.push_str("\n\n--- NEW TURNS ---\n");
body.push_str(&render_transcript::<A>(delta));
body
}
pub(crate) fn render_transcript<A: CompactionModel>(history: &[A::Message]) -> String {
let mut out = String::with_capacity(history.len() * 64);
for entry in history {
A::render_message(entry, &mut out);
out.push('\n');
}
out
}
pub(crate) fn push_truncated(out: &mut String, body: &str) {
if body.len() > 512 {
let mut end = 512;
while end > 0 && !body.is_char_boundary(end) {
end -= 1;
}
out.push_str(&body[..end]);
out.push_str("…[truncated]");
} else {
out.push_str(body);
}
}
fn drop_oldest_fallback<A: CompactionModel>(
history: &Mutex<Vec<A::Message>>,
split: usize,
prior_summary: Option<&str>,
) -> bool {
let mut hist = history.lock();
if split >= hist.len() {
return false;
}
let kept: Vec<A::Message> = hist.split_off(split);
hist.clear();
let text = match prior_summary {
Some(s) if !s.trim().is_empty() => {
format!("{COMPACTION_TAG}\n{s}\n[some prior turns dropped without summary]")
}
_ => format!("{COMPACTION_TAG}\n[prior turns dropped]"),
};
hist.push(A::user_text(text));
hist.extend(kept);
debug!(new_len = hist.len(), "compaction: drop-oldest fallback applied");
true
}
pub fn should_compact(total_tokens: Option<i32>, threshold: Option<u32>) -> bool {
match (total_tokens, threshold) {
(Some(t), Some(th)) => t >= 0 && (t as u32) > th,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone, PartialEq, Debug)]
enum Role {
User,
Assistant,
Tool,
}
#[derive(Clone)]
struct MockMsg {
role: Role,
text: String,
}
fn m(role: Role) -> MockMsg {
MockMsg { role, text: String::new() }
}
struct MockModel;
impl CompactionModel for MockModel {
type Message = MockMsg;
fn is_user(m: &MockMsg) -> bool {
m.role == Role::User
}
fn sole_text(m: &MockMsg) -> Option<&str> {
(!m.text.is_empty()).then_some(m.text.as_str())
}
fn is_tool_result_turn(m: &MockMsg) -> bool {
m.role == Role::Tool
}
fn user_text(text: String) -> MockMsg {
MockMsg { role: Role::User, text }
}
fn render_message(m: &MockMsg, out: &mut String) {
out.push_str(&m.text);
out.push('\n');
}
}
#[test]
fn pick_split_walks_back_past_a_leading_tool_run_when_roles_are_distinct() {
let h = vec![
m(Role::User),
m(Role::Assistant),
m(Role::Tool), m(Role::Tool), m(Role::Tool), m(Role::Tool), m(Role::Assistant),
m(Role::Tool), m(Role::Tool), m(Role::Tool), m(Role::Tool), m(Role::Assistant),
m(Role::Tool), m(Role::Tool), m(Role::Tool), m(Role::Tool), ]; let split = pick_split::<MockModel>(&h, 6);
assert!(
!MockModel::is_tool_result_turn(&h[split]),
"kept head at split={split} must not be an orphaned tool result"
);
assert_eq!(h[split].role, Role::Assistant, "walks back to the issuing assistant turn");
}
fn tagged(summary: &str) -> MockMsg {
MockModel::user_text(format!("{COMPACTION_TAG}\n{summary}"))
}
fn alt(n: usize) -> Vec<MockMsg> {
(0..n).map(|i| m(if i % 2 == 0 { Role::User } else { Role::Assistant })).collect()
}
#[test]
fn should_compact_trigger_boundary_and_negative_guard() {
assert!(!should_compact(None, Some(100)));
assert!(!should_compact(Some(9999), None));
assert!(!should_compact(None, None));
assert!(!should_compact(Some(100), Some(100)), "exactly at threshold must NOT compact");
assert!(should_compact(Some(101), Some(100)));
assert!(!should_compact(Some(99), Some(100)));
assert!(!should_compact(Some(-1), Some(100)));
assert!(!should_compact(Some(i32::MIN), Some(0)));
}
#[test]
fn extract_prior_summary_recognizes_only_the_tagged_user_head() {
assert_eq!(
extract_prior_summary::<MockModel>(Some(&tagged("rolling summary"))).as_deref(),
Some("rolling summary")
);
assert_eq!(extract_prior_summary::<MockModel>(None), None);
let asst = MockMsg { role: Role::Assistant, text: format!("{COMPACTION_TAG}\nx") };
assert_eq!(extract_prior_summary::<MockModel>(Some(&asst)), None);
let plain = MockModel::user_text("just a normal message".to_string());
assert_eq!(extract_prior_summary::<MockModel>(Some(&plain)), None);
assert_eq!(extract_prior_summary::<MockModel>(Some(&tagged(" \n "))), None);
}
#[test]
fn plan_fold_none_when_nothing_has_aged_out() {
assert!(plan_fold::<MockModel>(&alt(6)).is_none());
assert!(plan_fold::<MockModel>(&alt(12)).is_none());
}
#[test]
fn plan_fold_first_compaction_folds_the_whole_prefix() {
let plan = plan_fold::<MockModel>(&alt(14)).expect("should fold");
assert_eq!(plan.split, 2); assert!(plan.prior_summary.is_none());
assert_eq!(plan.delta_start, 0);
}
#[test]
fn plan_fold_incremental_excludes_the_prior_summary_from_the_delta() {
let mut h = vec![tagged("prior")];
h.extend(alt(13)); let plan = plan_fold::<MockModel>(&h).expect("should fold");
assert_eq!(plan.split, 2);
assert_eq!(plan.prior_summary.as_deref(), Some("prior"));
assert_eq!(plan.delta_start, 1);
}
#[test]
fn plan_fold_skips_a_head_only_delta() {
let mut h = vec![tagged("prior")];
h.extend(alt(12)); assert!(plan_fold::<MockModel>(&h).is_none());
}
#[test]
fn push_truncated_verbatim_then_char_boundary_safe() {
let mut s = String::new();
push_truncated(&mut s, "small");
assert_eq!(s, "small");
let mut s = String::new();
let body = "a".repeat(600);
push_truncated(&mut s, &body);
assert!(s.ends_with("…[truncated]") && s.len() < body.len());
let mut s = String::new();
let body = "a".repeat(510) + &"é".repeat(20); push_truncated(&mut s, &body); assert!(s.ends_with("…[truncated]"));
}
#[test]
fn pick_split_keeps_a_clean_user_boundary_as_is() {
let h = vec![
m(Role::User), m(Role::Assistant),
m(Role::User), m(Role::Assistant),
m(Role::User), m(Role::Assistant),
]; let split = pick_split::<MockModel>(&h, 1); assert_eq!(split, 4);
assert_eq!(h[split].role, Role::User);
}
}