use std::sync::Arc;
use serde_json::{Value, json};
use crate::client::{BeforeLlmEvent, BeforeLlmHook, BoxFuture, LlmOverride};
pub fn estimate_tokens(messages: &[Value]) -> usize {
messages
.iter()
.map(|m| {
serde_json::to_string(m)
.map(|s| s.len())
.unwrap_or(0)
.div_ceil(4)
})
.sum()
}
pub type SummarizeFn = Arc<dyn Fn(Vec<Value>) -> BoxFuture<Result<String, String>> + Send + Sync>;
pub type CountTokensFn = Arc<dyn Fn(&[Value]) -> usize + Send + Sync>;
pub type OnCompactFn = Arc<dyn Fn(&CompactInfo) + Send + Sync>;
#[derive(Clone, Default)]
pub struct CompactorOptions {
pub max_tokens: usize,
pub keep_tail: usize,
pub summarize: Option<SummarizeFn>,
pub count_tokens: Option<CountTokensFn>,
pub flush_to_memory: bool,
pub on_compact: Option<OnCompactFn>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct CompactInfo {
pub tokens_before: usize,
pub tokens_after: usize,
pub messages_before: usize,
pub messages_after: usize,
pub summarized: usize,
pub summary: String,
pub folded: Vec<Value>,
}
fn role_of(m: &Value) -> &str {
m.get("role").and_then(Value::as_str).unwrap_or("")
}
fn carries_tool_result(m: &Value) -> bool {
m.get("content").and_then(Value::as_array).is_some_and(|b| {
b.iter()
.any(|x| x.get("type").and_then(Value::as_str) == Some("tool_result"))
})
}
fn is_boundary(m: &Value) -> bool {
role_of(m) == "user" && !carries_tool_result(m)
}
pub fn compactor(opts: CompactorOptions) -> BeforeLlmHook {
let count: CountTokensFn = opts
.count_tokens
.clone()
.unwrap_or_else(|| Arc::new(|m: &[Value]| estimate_tokens(m)));
let keep_tail = if opts.keep_tail == 0 {
opts.max_tokens / 2
} else {
opts.keep_tail
};
let opts = Arc::new(opts);
Arc::new(move |ev: BeforeLlmEvent| {
let (count, opts) = (count.clone(), opts.clone());
Box::pin(async move { compact(&ev.messages, &opts, &count, keep_tail).await })
})
}
async fn compact(
msgs: &[Value],
opts: &CompactorOptions,
count: &CountTokensFn,
keep_tail: usize,
) -> Result<Option<LlmOverride>, String> {
let before = count(msgs);
if before <= opts.max_tokens {
return Ok(None);
}
let head0 = usize::from(msgs.first().is_some_and(|m| role_of(m) == "system"));
let system = &msgs[..head0];
let n = msgs.len();
let mut split = n;
let mut i = n;
while i > head0 + 1 {
i -= 1;
let tail = count(&msgs[i..]);
if is_boundary(&msgs[i]) && tail <= keep_tail {
split = i;
}
if tail > keep_tail {
break;
}
}
if split == n {
for i in (head0 + 1..n).rev() {
if is_boundary(&msgs[i]) {
split = i;
break;
}
}
}
if split <= head0 {
return Ok(None);
}
let mut older = &msgs[head0..split];
let tail = &msgs[split..];
let mut pinned: &[Value] = &[];
if split == n && head0 < n && is_boundary(&msgs[head0]) {
pinned = &msgs[head0..head0 + 1];
older = &msgs[head0 + 1..];
}
if older.is_empty() {
return Ok(None);
}
let Some(summarize) = &opts.summarize else {
return Err("toolnexus: CompactorOptions.summarize is required".into());
};
let summary = summarize(older.to_vec()).await?;
let mut out: Vec<Value> = Vec::with_capacity(system.len() + 3 + tail.len());
out.extend_from_slice(system);
out.extend_from_slice(pinned);
out.push(json!({"role": "system", "content": format!("[Summary of earlier conversation]\n{summary}")}));
if opts.flush_to_memory {
out.push(json!({"role": "system", "content": "Before continuing: if anything from earlier is worth keeping, save it with the memory tool now — the earlier transcript is about to be summarized."}));
}
out.extend_from_slice(tail);
let after = count(&out);
if after >= before {
return Ok(None);
}
if let Some(f) = &opts.on_compact {
f(&CompactInfo {
tokens_before: before,
tokens_after: after,
messages_before: n,
messages_after: out.len(),
summarized: older.len(),
summary,
folded: older.to_vec(),
});
}
Ok(Some(LlmOverride {
messages: Some(out),
..Default::default()
}))
}