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 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");
}
#[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);
}
}