mod config;
mod episodic;
mod plain;
mod reasoning;
mod truncate;
pub use config::{
CharHeuristic, CompactionConfig, TokenEstimator, DEFAULT_AUTO_COMPACT_TOKEN_LIMIT,
DEFAULT_COMPACTION_PROMPT, DEFAULT_COMPACT_REQUEST_TOKEN_LIMIT,
DEFAULT_PRIVATE_REASONING_SUMMARY_GUIDANCE, DEFAULT_RECENT_USER_TOKEN_BUDGET,
DEFAULT_SUMMARY_PREFIX, with_private_reasoning_summary_guidance,
};
pub use episodic::{
EpisodicCompactionConfig, EpisodicCompactionPressure, EpisodicCompactionTrigger,
episodic_compaction_trigger,
};
pub use plain::{PlainMessage, PlainToolCall};
pub use reasoning::{
append_private_reasoning_for_compaction, DEFAULT_PRIVATE_REASONING_EXCERPT_CHARS,
};
pub use truncate::{truncate_to_token_budget, truncate_to_token_budget_with_estimator};
pub trait TranscriptMessage {
fn render_for_compaction(&self, out: &mut String);
fn user_text_for_compaction(&self, out: &mut String) -> bool;
fn is_compaction_summary(&self, summary_prefix: &str) -> bool {
let mut text = String::new();
self.user_text_for_compaction(&mut text) && text.starts_with(summary_prefix)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PreparedCompaction {
pub request: CompactionRequest,
pub plan: CompactionPlan,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CompactionRequest {
pub prompt: String,
pub omitted_messages: usize,
pub estimated_transcript_tokens: usize,
pub estimated_request_tokens: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CompactionPlan {
pub recent_user_messages: Vec<String>,
pub summary_prefix: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CompactedTranscript {
pub summary_message: String,
pub recent_user_messages: Vec<String>,
}
pub fn estimate_transcript_tokens<M, E>(messages: &[M], estimator: &E) -> usize
where
M: TranscriptMessage,
E: TokenEstimator + ?Sized,
{
let mut rendered = String::new();
messages
.iter()
.map(|message| {
rendered.clear();
message.render_for_compaction(&mut rendered);
estimator.estimate(&rendered)
})
.sum()
}
pub fn should_compact<M, E>(messages: &[M], config: &CompactionConfig, estimator: &E) -> bool
where
M: TranscriptMessage,
E: TokenEstimator + ?Sized,
{
config.enabled()
&& estimate_transcript_tokens(messages, estimator) >= config.auto_compact_token_limit
}
pub fn prepare_compaction<M, E>(
messages: &[M],
config: &CompactionConfig,
estimator: &E,
) -> Option<PreparedCompaction>
where
M: TranscriptMessage,
E: TokenEstimator + ?Sized,
{
let estimated_transcript_tokens = estimate_transcript_tokens(messages, estimator);
if !config.enabled() || estimated_transcript_tokens < config.auto_compact_token_limit {
return None;
}
let mut rendered_messages = Vec::new();
let mut token_total = estimator.estimate(&config.compaction_prompt);
let mut scratch = String::new();
let mut omitted_messages = 0usize;
for idx in (0..messages.len()).rev() {
scratch.clear();
messages[idx].render_for_compaction(&mut scratch);
let tokens = estimator.estimate(&scratch);
if token_total.saturating_add(tokens) > config.compact_request_token_limit
&& !rendered_messages.is_empty()
{
omitted_messages = idx + 1;
break;
}
token_total = token_total.saturating_add(tokens);
rendered_messages.push(scratch.clone());
}
rendered_messages.reverse();
if rendered_messages.is_empty() {
return None;
}
let mut prompt = String::new();
prompt.push_str(&config.compaction_prompt);
prompt.push_str("\n\nConversation transcript:\n\n");
prompt.push_str(&rendered_messages.join("\n\n"));
if omitted_messages > 0 {
prompt.push_str("\n\nNote: ");
prompt.push_str(&omitted_messages.to_string());
prompt.push_str(
" oldest message(s) were omitted because the compaction request was too large.",
);
}
let plan = CompactionPlan {
recent_user_messages: recent_user_messages(messages, config, estimator),
summary_prefix: config.summary_prefix.clone(),
};
let estimated_request_tokens = estimator.estimate(&prompt);
Some(PreparedCompaction {
request: CompactionRequest {
prompt,
omitted_messages,
estimated_transcript_tokens,
estimated_request_tokens,
},
plan,
})
}
pub fn finalize_compaction(plan: &CompactionPlan, summary: &str) -> CompactedTranscript {
let summary = summary.trim();
let summary = if summary.is_empty() {
"(no summary was produced)"
} else {
summary
};
let summary_message = if plan.summary_prefix.trim().is_empty() {
summary.to_string()
} else {
format!("{}\n{}", plan.summary_prefix.trim_end(), summary)
};
CompactedTranscript {
summary_message,
recent_user_messages: plan.recent_user_messages.clone(),
}
}
fn recent_user_messages<M, E>(
messages: &[M],
config: &CompactionConfig,
estimator: &E,
) -> Vec<String>
where
M: TranscriptMessage,
E: TokenEstimator + ?Sized,
{
if config.recent_user_token_budget == 0 {
return Vec::new();
}
let mut selected = Vec::new();
let mut remaining = config.recent_user_token_budget;
let mut scratch = String::new();
for message in messages.iter().rev() {
scratch.clear();
if !message.user_text_for_compaction(&mut scratch) || scratch.is_empty() {
continue;
}
if message.is_compaction_summary(&config.summary_prefix) {
continue;
}
let tokens = estimator.estimate(&scratch);
if tokens <= remaining {
selected.push(scratch.clone());
remaining = remaining.saturating_sub(tokens);
continue;
}
if remaining > 0 {
selected.push(truncate_to_token_budget_with_estimator(
&scratch, remaining, estimator,
));
}
break;
}
selected.reverse();
selected
}