use futures::StreamExt;
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use theway_llm_provider::{
AssistantMessage, AssistantMessageEvent, Context as PiContext, Message as PiMessage, Model,
SimpleStreamOptions, Usage,
};
use tokio_util::sync::CancellationToken;
use super::super::session::session::SessionTreeEntry;
use super::algorithm::{CompactAlgorithm, SummarizeRequest, SummaryOutcome};
pub use super::estimate::{
ContextUsageEstimate, calculate_context_tokens, estimate_context_tokens, estimate_text_tokens,
estimate_tokens, get_last_assistant_usage, should_compact,
};
use crate::types::default_stream_fn;
use crate::types::*;
pub fn default_compaction_algorithm() -> String {
"builtin".to_string()
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CompactionSettings {
pub enabled: bool,
pub reserve_tokens: u32,
pub keep_recent_tokens: u32,
#[serde(default = "default_compaction_algorithm")]
pub algorithm: String,
}
impl Default for CompactionSettings {
fn default() -> Self {
DEFAULT_COMPACTION_SETTINGS.clone()
}
}
pub static DEFAULT_COMPACTION_SETTINGS: Lazy<CompactionSettings> =
Lazy::new(|| CompactionSettings {
enabled: true,
reserve_tokens: 16_384,
keep_recent_tokens: 20_000,
algorithm: default_compaction_algorithm(),
});
pub fn find_turn_start_index(
entries: &[SessionTreeEntry],
entry_index: usize,
start_index: usize,
) -> usize {
let upper = entry_index.min(entries.len().saturating_sub(1));
let mut i = upper as isize;
while i >= start_index as isize {
let idx = i as usize;
if let SessionTreeEntry::Message { message, .. } = &entries[idx] {
if matches!(message, AgentMessage::Llm(PiMessage::User(_))) {
return idx;
}
}
i -= 1;
}
start_index
}
#[derive(Clone, Debug)]
pub struct CutPointResult {
pub cut_index: usize,
pub first_kept_entry_id: Option<String>,
}
pub fn find_cut_point(
entries: &[SessionTreeEntry],
settings: &CompactionSettings,
) -> CutPointResult {
if entries.is_empty() {
return CutPointResult {
cut_index: 0,
first_kept_entry_id: None,
};
}
let mut acc: u64 = 0;
let mut target = entries.len();
for (i, entry) in entries.iter().enumerate().rev() {
if let SessionTreeEntry::Message { message, .. } = entry {
acc += estimate_tokens(message);
}
if acc >= settings.keep_recent_tokens as u64 {
target = i;
break;
}
}
let cut = find_turn_start_index(entries, target, 0);
let first_kept_entry_id = entries.get(cut).map(|e| e.id().to_string());
CutPointResult {
cut_index: cut,
first_kept_entry_id,
}
}
pub const SUMMARIZATION_SYSTEM_PROMPT: &str = "You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary preserving the user's intent, the files and topics discussed, decisions made, and any work still in progress. Be concise but thorough; the assistant will rely on your summary instead of replaying the dropped messages.";
const DEFAULT_SUMMARY_PROMPT_TOKEN_BUDGET: u64 = 64_000;
pub fn serialize_conversation(messages: &[AgentMessage]) -> String {
let mut out = String::new();
for m in messages {
match m {
AgentMessage::Llm(PiMessage::User(u)) => {
out.push_str("USER:\n");
match &u.content {
theway_llm_provider::UserContent::Text(s) => out.push_str(s),
theway_llm_provider::UserContent::Blocks(blocks) => {
for b in blocks {
match b {
theway_llm_provider::UserContentBlock::Text(t) => {
out.push_str(&t.text)
}
theway_llm_provider::UserContentBlock::Image(_) => {
out.push_str("<image>")
}
}
}
}
}
out.push_str("\n\n");
}
AgentMessage::Llm(PiMessage::Assistant(a)) => {
out.push_str("ASSISTANT:\n");
for b in &a.content {
match b {
theway_llm_provider::ContentBlock::Text(t) => out.push_str(&t.text),
theway_llm_provider::ContentBlock::Thinking(t) => {
out.push_str("<thinking>");
out.push_str(&t.thinking);
out.push_str("</thinking>");
}
theway_llm_provider::ContentBlock::Image(_) => out.push_str("<image>"),
theway_llm_provider::ContentBlock::ToolCall(tc) => {
out.push_str(&format!(
"<tool_call name=\"{}\">{}</tool_call>",
tc.name,
serde_json::Value::Object(tc.arguments.clone())
));
}
}
}
out.push_str("\n\n");
}
AgentMessage::Llm(PiMessage::ToolResult(tr)) => {
out.push_str(&format!("TOOL_RESULT[{}]:\n", tr.tool_name));
for b in &tr.content {
if let theway_llm_provider::UserContentBlock::Text(t) = b {
out.push_str(&t.text);
}
}
out.push_str("\n\n");
}
AgentMessage::Custom(c) => {
out.push_str(&format!("{}:\n{}\n\n", c.role.to_uppercase(), c.payload));
}
}
}
out
}
const SUMMARY_PROMPT_FRAMING_TOKENS: u64 = 512;
const MIN_SUMMARY_PROMPT_BUDGET_TOKENS: u64 = 1_024;
const MAX_SUMMARY_OVERFLOW_RETRIES: u32 = 3;
fn summary_output_tokens(model: &Model, settings: &CompactionSettings) -> u32 {
let reserve = if settings.reserve_tokens > 0 {
settings.reserve_tokens
} else {
DEFAULT_COMPACTION_SETTINGS.reserve_tokens
};
let mut output = if model.max_tokens > 0 {
model.max_tokens.min(reserve)
} else {
reserve
};
if model.context_window > 0 {
output = output.min(model.context_window / 4).max(1);
}
output
}
fn summarization_prompt_budget(model: &Model, settings: &CompactionSettings) -> u64 {
if model.context_window == 0 {
return DEFAULT_SUMMARY_PROMPT_TOKEN_BUDGET;
}
let window = model.context_window as u64;
let output = summary_output_tokens(model, settings) as u64;
window.saturating_sub(output).saturating_mul(4) / 5
}
fn summary_prompt_overhead_tokens(custom_instructions: Option<&str>) -> u64 {
SUMMARY_PROMPT_FRAMING_TOKENS
+ estimate_text_tokens(SUMMARIZATION_SYSTEM_PROMPT)
+ custom_instructions
.map(estimate_text_tokens)
.unwrap_or_default()
}
fn summarize_prompt_estimate_tokens(
messages: &[AgentMessage],
custom_instructions: Option<&str>,
) -> u64 {
let conversation: u64 = messages.iter().map(estimate_tokens).sum();
summary_prompt_overhead_tokens(custom_instructions) + conversation
}
fn trim_messages_for_summary_budget(
messages: &[AgentMessage],
budget_tokens: u64,
custom_instructions: Option<&str>,
) -> Vec<AgentMessage> {
if summarize_prompt_estimate_tokens(messages, custom_instructions) <= budget_tokens {
return messages.to_vec();
}
let mut kept = Vec::new();
let mut total = summary_prompt_overhead_tokens(custom_instructions);
for message in messages.iter().rev() {
let message_tokens = estimate_tokens(message);
if !kept.is_empty() && total + message_tokens > budget_tokens {
break;
}
kept.push(message.clone());
total = total.saturating_add(message_tokens);
if total >= budget_tokens {
break;
}
}
kept.reverse();
let omitted = messages.len().saturating_sub(kept.len());
if omitted > 0 {
kept.insert(
0,
AgentMessage::Llm(PiMessage::User(theway_llm_provider::UserMessage {
role: theway_llm_provider::UserRole::User,
content: theway_llm_provider::UserContent::Text(format!(
"[compaction note: omitted {omitted} older message(s) before summarization because the session exceeded the summarizer prompt budget]"
)),
timestamp: chrono::Utc::now().timestamp_millis(),
})),
);
}
kept
}
pub const SUMMARY_TEXT_CAP: usize = 8_000;
pub const SUMMARY_TOOL_CAP: usize = 2_000;
pub const SUMMARY_THINKING_CAP: usize = 2_000;
pub const SUMMARY_PROJECTED_BUDGET_TOKENS: u64 = 96_000;
fn truncate_chars_for_summary(text: &str, cap: usize) -> String {
if text.chars().count() <= cap {
return text.to_string();
}
let mut out: String = text.chars().take(cap).collect();
out.push_str("\n…[truncated for summarization]");
out
}
fn truncate_tool_arguments(
args: serde_json::Map<String, serde_json::Value>,
) -> serde_json::Map<String, serde_json::Value> {
let serialized = serde_json::to_string(&args).unwrap_or_default();
if serialized.chars().count() <= SUMMARY_TOOL_CAP {
return args;
}
let truncated = truncate_chars_for_summary(&serialized, SUMMARY_TOOL_CAP);
match serde_json::from_str(&truncated) {
Ok(parsed) => parsed,
Err(_) => {
let mut out = serde_json::Map::new();
out.insert(
"arguments_truncated".to_string(),
serde_json::Value::String(truncated),
);
out
}
}
}
fn project_summary_message(message: &AgentMessage) -> Option<AgentMessage> {
match message {
AgentMessage::Llm(PiMessage::User(u)) => {
let mut u = u.clone();
match &mut u.content {
theway_llm_provider::UserContent::Text(s) => {
*s = truncate_chars_for_summary(s, SUMMARY_TEXT_CAP);
}
theway_llm_provider::UserContent::Blocks(blocks) => {
for block in blocks {
if let theway_llm_provider::UserContentBlock::Text(t) = block {
t.text = truncate_chars_for_summary(&t.text, SUMMARY_TEXT_CAP);
}
}
}
}
Some(AgentMessage::Llm(PiMessage::User(u)))
}
AgentMessage::Llm(PiMessage::Assistant(a)) => {
let mut a = a.clone();
for block in &mut a.content {
match block {
theway_llm_provider::ContentBlock::Text(t) => {
t.text = truncate_chars_for_summary(&t.text, SUMMARY_TEXT_CAP);
}
theway_llm_provider::ContentBlock::Thinking(t) => {
t.thinking = truncate_chars_for_summary(&t.thinking, SUMMARY_THINKING_CAP);
}
theway_llm_provider::ContentBlock::ToolCall(c) => {
c.arguments = truncate_tool_arguments(std::mem::take(&mut c.arguments));
}
theway_llm_provider::ContentBlock::Image(_) => {}
}
}
Some(AgentMessage::Llm(PiMessage::Assistant(a)))
}
AgentMessage::Llm(PiMessage::ToolResult(tr)) => {
let mut tr = tr.clone();
for block in &mut tr.content {
if let theway_llm_provider::UserContentBlock::Text(t) = block {
t.text = truncate_chars_for_summary(&t.text, SUMMARY_TOOL_CAP);
}
}
tr.details = None;
Some(AgentMessage::Llm(PiMessage::ToolResult(tr)))
}
AgentMessage::Custom(_) => None,
}
}
pub fn project_summary_messages(entries: &[SessionTreeEntry]) -> Vec<AgentMessage> {
let projected: Vec<AgentMessage> = entries
.iter()
.filter_map(|entry| match entry {
SessionTreeEntry::Message { message, .. } => project_summary_message(message),
_ => None,
})
.collect();
trim_projected_budget(projected)
}
fn trim_projected_budget(messages: Vec<AgentMessage>) -> Vec<AgentMessage> {
let total: u64 = messages.iter().map(estimate_tokens).sum();
if total <= SUMMARY_PROJECTED_BUDGET_TOKENS || messages.len() <= 1 {
return messages;
}
let mut kept: Vec<AgentMessage> = Vec::with_capacity(messages.len());
let mut running = 0u64;
for message in messages.iter().rev() {
let tokens = estimate_tokens(message);
if !kept.is_empty() && running + tokens > SUMMARY_PROJECTED_BUDGET_TOKENS {
break;
}
running = running.saturating_add(tokens);
kept.push(message.clone());
}
kept.reverse();
kept
}
fn suffix_start_for_token_budget(s: &str, budget_tokens: u64) -> usize {
let mut ascii = 0u64;
let mut non_ascii = 0u64;
let mut start = s.len();
for (idx, c) in s.char_indices().rev() {
let (next_ascii, next_non_ascii) = if c.is_ascii() {
(ascii + 1, non_ascii)
} else {
(ascii, non_ascii + 1)
};
if next_ascii.div_ceil(4) + next_non_ascii > budget_tokens {
break;
}
ascii = next_ascii;
non_ascii = next_non_ascii;
start = idx;
}
start
}
fn serialize_conversation_for_summary_budget(
messages: &[AgentMessage],
budget_tokens: u64,
custom_instructions: Option<&str>,
) -> String {
let messages = trim_messages_for_summary_budget(messages, budget_tokens, custom_instructions);
let conversation = serialize_conversation(&messages);
let available_tokens =
budget_tokens.saturating_sub(summary_prompt_overhead_tokens(custom_instructions));
if estimate_text_tokens(&conversation) <= available_tokens {
return conversation;
}
let note = "[compaction note: omitted older serialized content before summarization because the session exceeded the summarizer prompt budget]\n\n";
let note_tokens = estimate_text_tokens(note);
if available_tokens <= note_tokens {
return note
.chars()
.take(available_tokens.saturating_mul(4) as usize)
.collect();
}
let start = suffix_start_for_token_budget(&conversation, available_tokens - note_tokens);
format!("{note}{}", &conversation[start..])
}
#[derive(Clone)]
pub struct GenerateSummaryRequest {
pub model: Model,
pub messages: Vec<AgentMessage>,
pub custom_instructions: Option<String>,
pub prompt_budget_tokens: Option<u64>,
pub max_output_tokens: Option<u32>,
pub stream_fn: Option<StreamFn>,
}
#[derive(Clone, Debug)]
pub struct GenerateSummaryOutput {
pub summary: String,
pub usage: Usage,
}
pub async fn generate_summary(
request: GenerateSummaryRequest,
cancel: CancellationToken,
) -> Result<GenerateSummaryOutput, SummarizeError> {
let mut prompt = SUMMARIZATION_SYSTEM_PROMPT.to_string();
if let Some(extra) = request.custom_instructions.as_deref() {
prompt.push_str("\n\n");
prompt.push_str(extra);
}
let convo = if let Some(budget) = request.prompt_budget_tokens {
serialize_conversation_for_summary_budget(
&request.messages,
budget,
request.custom_instructions.as_deref(),
)
} else {
serialize_conversation(&request.messages)
};
let user = theway_llm_provider::UserMessage {
role: theway_llm_provider::UserRole::User,
content: theway_llm_provider::UserContent::Text(convo),
timestamp: chrono::Utc::now().timestamp_millis(),
};
let context = PiContext {
system_prompt: Some(prompt),
messages: vec![theway_llm_provider::Message::User(user)],
tools: None,
};
let stream_fn = request.stream_fn.unwrap_or_else(default_stream_fn);
let mut options = SimpleStreamOptions::default();
options.base.abort = Some(cancel.clone());
options.base.max_tokens = request.max_output_tokens;
let mut stream = stream_fn(&request.model, &context, Some(&options));
let mut last: Option<AssistantMessage> = None;
while let Some(ev) = stream.next().await {
if cancel.is_cancelled() {
return Err(SummarizeError::Aborted);
}
match ev {
AssistantMessageEvent::Done { message, .. } => last = Some(message),
AssistantMessageEvent::Error { error, .. } => {
let window = (request.model.context_window > 0)
.then_some(request.model.context_window as u64);
let overflowed = theway_llm_provider::is_context_overflow(&error, window);
let message = error
.error_message
.unwrap_or_else(|| "summarization failed".into());
return Err(if overflowed {
SummarizeError::ContextOverflow(message)
} else {
SummarizeError::Provider(message)
});
}
_ => {}
}
}
let msg = last.ok_or(SummarizeError::Empty)?;
let summary = msg
.content
.iter()
.filter_map(|b| match b {
theway_llm_provider::ContentBlock::Text(t) => Some(t.text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("");
Ok(GenerateSummaryOutput {
summary,
usage: msg.usage,
})
}
#[derive(Debug, thiserror::Error)]
pub enum SummarizeError {
#[error("aborted")]
Aborted,
#[error("provider error: {0}")]
Provider(String),
#[error("summarizer prompt overflowed the model context window: {0}")]
ContextOverflow(String),
#[error("summarizer produced no message")]
Empty,
}
#[derive(Clone, Debug)]
pub struct CompactionPreparation {
pub cut: CutPointResult,
pub entries_to_summarize: Vec<SessionTreeEntry>,
pub tokens_before: u64,
}
pub fn prepare_compaction(
entries: &[SessionTreeEntry],
settings: &CompactionSettings,
) -> CompactionPreparation {
let cut = find_cut_point(entries, settings);
let entries_to_summarize = entries[..cut.cut_index].to_vec();
let tokens_before = entries_to_summarize
.iter()
.filter_map(|e| match e {
SessionTreeEntry::Message { message, .. } => Some(estimate_tokens(message)),
_ => None,
})
.sum();
CompactionPreparation {
cut,
entries_to_summarize,
tokens_before,
}
}
#[derive(Clone, Debug)]
pub struct CompactionResult {
pub summary: String,
pub first_kept_entry_id: Option<String>,
pub tokens_before: u64,
pub usage: Usage,
}
pub async fn compact(
algorithm: &dyn CompactAlgorithm,
model: Model,
entries: &[SessionTreeEntry],
settings: &CompactionSettings,
custom_instructions: Option<String>,
stream_fn: Option<StreamFn>,
cancel: CancellationToken,
) -> Result<CompactionResult, SummarizeError> {
compact_with_model_context(
algorithm,
model,
entries,
&[],
settings,
custom_instructions,
stream_fn,
cancel,
)
.await
}
pub async fn compact_with_model_context(
algorithm: &dyn CompactAlgorithm,
model: Model,
entries: &[SessionTreeEntry],
persistent_model_context: &[AgentMessage],
settings: &CompactionSettings,
custom_instructions: Option<String>,
stream_fn: Option<StreamFn>,
cancel: CancellationToken,
) -> Result<CompactionResult, SummarizeError> {
let cut = algorithm.select_cut_point(entries, settings).await;
let entries_to_summarize = &entries[..cut.cut_index];
let tokens_before = entries_to_summarize
.iter()
.filter_map(|e| match e {
SessionTreeEntry::Message { message, .. } => Some(estimate_tokens(message)),
_ => None,
})
.sum();
if entries_to_summarize.is_empty() {
return Ok(CompactionResult {
summary: String::new(),
first_kept_entry_id: cut.first_kept_entry_id,
tokens_before,
usage: Usage::default(),
});
}
let mut messages = Vec::with_capacity(
persistent_model_context
.len()
.saturating_add(entries_to_summarize.len()),
);
messages.extend_from_slice(persistent_model_context);
messages.extend(project_summary_messages(entries_to_summarize));
let request = SummarizeRequest {
model: &model,
messages: &messages,
custom_instructions: custom_instructions.as_deref(),
settings,
stream_fn: stream_fn.as_ref(),
cancel: &cancel,
};
let out = algorithm.summarize_prefix(&request).await?;
Ok(CompactionResult {
summary: out.summary,
first_kept_entry_id: cut.first_kept_entry_id,
tokens_before,
usage: out.usage,
})
}
pub async fn summarize_with_llm(
request: &SummarizeRequest<'_>,
) -> Result<SummaryOutcome, SummarizeError> {
let max_output_tokens = summary_output_tokens(request.model, request.settings);
let mut budget = summarization_prompt_budget(request.model, request.settings);
let mut attempts = 0u32;
let out = loop {
let result = generate_summary(
GenerateSummaryRequest {
model: request.model.clone(),
messages: request.messages.to_vec(),
custom_instructions: request.custom_instructions.map(str::to_string),
prompt_budget_tokens: Some(budget),
max_output_tokens: Some(max_output_tokens),
stream_fn: request.stream_fn.cloned(),
},
request.cancel.clone(),
)
.await;
match result {
Ok(out) => break out,
Err(SummarizeError::ContextOverflow(message)) => {
attempts += 1;
if attempts > MAX_SUMMARY_OVERFLOW_RETRIES
|| budget <= MIN_SUMMARY_PROMPT_BUDGET_TOKENS
{
return Err(SummarizeError::ContextOverflow(message));
}
budget = (budget / 2).max(MIN_SUMMARY_PROMPT_BUDGET_TOKENS);
}
Err(e) => return Err(e),
}
};
Ok(SummaryOutcome {
summary: out.summary,
usage: out.usage,
})
}
#[cfg(test)]
tests_bridge_macro::tests_bridge!("agent/compaction/compaction");
#[cfg(test)]
mod compaction_extra_tests {
tests_bridge_macro::tests_bridge!("agent/compaction/compaction/extra");
}
#[cfg(test)]
mod compaction_more_tests {
tests_bridge_macro::tests_bridge!("agent/compaction/compaction/more");
}
#[cfg(test)]
mod compaction_linecov_tests {
tests_bridge_macro::tests_bridge!("agent/compaction/compaction/linecov");
}