Files
1jehuang--jcode/crates/jcode-base/src/compaction.rs
T
wehub-resource-sync a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

1791 lines
72 KiB
Rust

//! Background compaction for conversation context management
//!
//! When context reaches 80% of the limit, kicks off background summarization.
//! User continues chatting while summary is generated. When ready, seamlessly
//! swaps in the compacted context.
//!
//! The CompactionManager does NOT store its own copy of messages. Instead,
//! callers pass `&[Message]` references when needed. The manager tracks how
//! many messages from the front have been compacted via `compacted_count`.
//!
//! ## Compaction Modes
//!
//! - **Reactive** (default): compact when context hits a fixed threshold (80%).
//! - **Proactive**: compact early based on predicted EWMA token growth rate.
//! - **Semantic**: compact based on embedding-detected topic shifts and
//! relevance scoring. Falls back to proactive if embeddings are unavailable.
use crate::message::{ContentBlock, Message, Role};
use crate::provider::Provider;
use crate::provider::openai_request::{
openai_encrypted_content_fallback_summary, openai_encrypted_content_is_sendable,
};
use anyhow::Result;
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use std::time::Instant;
use tokio::task::JoinHandle;
pub use jcode_compaction_core::{
CHARS_PER_TOKEN, COMPACTION_THRESHOLD, CRITICAL_THRESHOLD, CompactionAction, CompactionEvent,
CompactionStats, DEFAULT_TOKEN_BUDGET, EMBED_MAX_CHARS_PER_MSG, EMBEDDING_HISTORY_WINDOW,
EMERGENCY_IMAGE_MAX_CHARS, EMERGENCY_TOOL_RESULT_MAX_CHARS, MANUAL_COMPACT_MIN_THRESHOLD,
MIN_TURNS_TO_KEEP, PAYLOAD_IMAGE_CHAR_BUDGET, RECENT_TURNS_TO_KEEP,
SEMANTIC_EMBED_CACHE_CAPACITY, SUMMARY_PROMPT, SYSTEM_OVERHEAD_TOKENS, Summary,
TOKEN_HISTORY_WINDOW, build_compaction_prompt, build_emergency_summary_text,
compacted_summary_text_block, content_char_count, effective_context_tokens_from_usage,
emergency_strip_large_images, emergency_truncate_large_payloads, estimate_compaction_tokens,
is_request_payload_too_large_error, mean_embedding, message_char_count, safe_compaction_cutoff,
semantic_cache_key, semantic_goal_text, semantic_message_text, strip_large_images_in_contents,
summary_payload_char_count,
};
const HARD_THRESHOLD_PENDING_WAIT_MS: u64 = 15_000;
const HARD_THRESHOLD_PENDING_POLL_MS: u64 = 50;
/// Result from background compaction task
struct CompactionResult {
summary_text: String,
openai_encrypted_content: Option<String>,
covers_up_to_turn: usize,
duration_ms: u64,
summarized_messages: usize,
}
struct CompactionOutcomeLog<'a> {
trigger: &'a str,
pre_tokens: u64,
post_tokens: u64,
messages_compacted: usize,
messages_dropped: Option<usize>,
duration_ms: u64,
all_messages: &'a [Message],
}
struct HardThresholdWait {
waited_ms: u64,
applied: bool,
timed_out: bool,
}
/// Rolling character-count estimate for the active (non-compacted) message
/// suffix.
///
/// Token estimation needs the size of the live message tail without rescanning
/// the entire history on every call, so this caches that sum next to a dirty
/// flag. The value and the flag must always move together: previously they were
/// two independent `CompactionManager` fields, and a code path that updated one
/// without the other silently corrupted token accounting. Keeping the raw
/// fields private and forcing every mutation through these named operations
/// makes that class of bug unrepresentable.
#[derive(Debug, Clone, Default)]
struct ActiveCharEstimate {
chars: usize,
dirty: bool,
}
impl ActiveCharEstimate {
/// The currently cached character count. Only trustworthy when not dirty;
/// readers must consult [`Self::is_dirty`] (and any external invariants)
/// before relying on it.
fn value(&self) -> usize {
self.chars
}
/// Whether the cached value is stale and must be recomputed from history.
fn is_dirty(&self) -> bool {
self.dirty
}
/// Mark the cached value stale so the next read recomputes from history.
fn invalidate(&mut self) {
self.dirty = true;
}
/// Record an exact, freshly computed count as the trusted value.
fn set_exact(&mut self, chars: usize) {
self.chars = chars;
self.dirty = false;
}
/// Extend a trusted count by a newly appended message's characters.
///
/// Mirrors the append-only fast path: the prior value is assumed accurate,
/// so the running sum stays trusted (dirty cleared).
fn append_exact(&mut self, chars: usize) {
self.chars = self.chars.saturating_add(chars);
self.dirty = false;
}
/// Reset to zero after a restore/clamp. Stays dirty when there may be active
/// messages whose characters have not been measured yet.
fn reset_pending(&mut self, maybe_has_active: bool) {
self.chars = 0;
self.dirty = maybe_has_active;
}
}
/// Manages background compaction of conversation context.
///
/// Does NOT own message data. The caller owns the messages and passes
/// references into methods that need them. After compaction, the manager
/// records `compacted_count` — the number of leading messages that have
/// been summarized and should be skipped when building API payloads.
pub struct CompactionManager {
/// Number of leading messages that have been compacted into the summary.
/// When building API messages, skip the first `compacted_count` messages.
compacted_count: usize,
/// Active summary (if we've compacted before)
active_summary: Option<Summary>,
/// Rolling char estimate for the active (non-compacted) message suffix.
///
/// In the common append-only case this is maintained incrementally, so token
/// estimation does not need to rescan the entire active history every time.
/// Bundled with its own dirty flag so the value and staleness can never
/// drift apart (see [`ActiveCharEstimate`]).
active_chars: ActiveCharEstimate,
/// Background compaction task handle
pending_task: Option<JoinHandle<Result<CompactionResult>>>,
/// User-facing trigger label for the currently running background compaction.
pending_trigger: Option<String>,
/// Turn index (relative to uncompacted messages) where pending compaction will cut off
pending_cutoff: usize,
/// Total turns seen (for tracking)
total_turns: usize,
/// When true, session restore/reseed has just loaded old history and
/// compaction must stay disabled until a genuinely new message is added.
suppress_compaction_until_new_message: bool,
/// Token budget
token_budget: usize,
/// Provider-reported input token usage from the latest request.
/// Used to trigger compaction with real token counts instead of only heuristics.
observed_input_tokens: Option<u64>,
/// Last compaction event (if any)
last_compaction: Option<CompactionEvent>,
// ── Mode & strategy ────────────────────────────────────────────────────
/// Active compaction mode (set from config at construction)
mode: crate::config::CompactionMode,
/// Config snapshot for mode-specific parameters
compaction_config: crate::config::CompactionConfig,
// ── Proactive mode state ───────────────────────────────────────────────
/// Rolling window of observed token counts, one entry per turn snapshot.
/// Used to compute EWMA growth rate for proactive compaction.
token_history: VecDeque<u64>,
/// Total turns elapsed since the last successful compaction.
/// Used as a cooldown anti-signal.
turns_since_last_compact: usize,
// ── Semantic mode state ────────────────────────────────────────────────
/// Per-turn embedding snapshots for topic-shift detection.
/// Each entry is the L2-normalized embedding of the last assistant message
/// of that turn (truncated to EMBED_MAX_CHARS_PER_MSG for speed).
embedding_history: VecDeque<Vec<f32>>,
/// Local cache for semantic compaction embeddings keyed by truncated-text hash.
/// Stores both successful embeddings and failed lookups (`None`) so repeated
/// semantic scans do not redo the same work.
semantic_embed_cache: HashMap<u64, (Option<Vec<f32>>, u64)>,
/// Monotonic recency counter for the semantic embedding cache LRU.
semantic_embed_cache_counter: u64,
}
impl CompactionManager {
pub fn new() -> Self {
let cfg = crate::config::config().compaction.clone();
let mode = cfg.mode.clone();
Self {
compacted_count: 0,
active_summary: None,
active_chars: ActiveCharEstimate::default(),
pending_task: None,
pending_trigger: None,
pending_cutoff: 0,
total_turns: 0,
suppress_compaction_until_new_message: false,
token_budget: DEFAULT_TOKEN_BUDGET,
observed_input_tokens: None,
last_compaction: None,
mode,
compaction_config: cfg,
token_history: VecDeque::with_capacity(TOKEN_HISTORY_WINDOW + 1),
turns_since_last_compact: 0,
embedding_history: VecDeque::with_capacity(EMBEDDING_HISTORY_WINDOW + 1),
semantic_embed_cache: HashMap::with_capacity(SEMANTIC_EMBED_CACHE_CAPACITY),
semantic_embed_cache_counter: 0,
}
}
/// Reset all compaction state
pub fn reset(&mut self) {
*self = Self::new();
}
pub fn with_budget(mut self, budget: usize) -> Self {
self.token_budget = budget;
self
}
/// Update the token budget (e.g., when model changes)
pub fn set_budget(&mut self, budget: usize) {
self.token_budget = budget;
}
/// Get current token budget
pub fn token_budget(&self) -> usize {
self.token_budget
}
/// Notify the manager that a message was added.
///
/// Legacy callers that do not provide the message content keep turn counts
/// correct, but mark the rolling char estimate dirty so the next token
/// estimate will resync from the provided history slice.
pub fn notify_message_added(&mut self) {
self.total_turns += 1;
self.suppress_compaction_until_new_message = false;
self.active_chars.invalidate();
}
/// Notify the manager that a message was added and update the rolling char
/// estimate incrementally.
pub fn notify_message_added_with(&mut self, message: &Message) {
self.notify_message_added_blocks(&message.content);
}
pub fn notify_message_added_blocks(&mut self, content: &[ContentBlock]) {
self.total_turns += 1;
self.suppress_compaction_until_new_message = false;
self.active_chars.append_exact(content_char_count(content));
}
/// Backward-compatible alias for `notify_message_added`.
/// Accepts (and ignores) the message — callers that haven't been
/// updated yet can still call `add_message(msg)`.
pub fn add_message(&mut self, message: Message) {
self.notify_message_added_with(&message);
}
/// Seed the manager from already-existing history that was restored from
/// disk or otherwise replayed into memory.
///
/// This updates turn counts but deliberately suppresses compaction until a
/// genuinely new message is added after the restore. Restoring history must
/// not itself trigger compaction.
pub fn seed_restored_messages(&mut self, count: usize) {
self.total_turns = count;
self.suppress_compaction_until_new_message = count > 0;
self.active_chars.reset_pending(count > 0);
}
/// Seed the manager from already-existing history with an exact rolling char
/// estimate for the active suffix.
pub fn seed_restored_messages_with(&mut self, all_messages: &[Message]) {
self.total_turns = all_messages.len();
self.suppress_compaction_until_new_message = !all_messages.is_empty();
self.active_chars
.set_exact(all_messages.iter().map(message_char_count).sum());
}
pub fn seed_restored_stored_messages_with(
&mut self,
all_messages: &[crate::session::StoredMessage],
) {
self.total_turns = all_messages.len();
self.suppress_compaction_until_new_message = !all_messages.is_empty();
self.active_chars.set_exact(
all_messages
.iter()
.map(|message| content_char_count(&message.content))
.sum(),
);
}
/// Restore a previously persisted compacted view.
pub fn restore_persisted_state(
&mut self,
state: &crate::session::StoredCompactionState,
total_messages: usize,
) {
self.pending_task = None;
self.pending_trigger = None;
self.pending_cutoff = 0;
self.observed_input_tokens = None;
self.last_compaction = None;
self.token_history.clear();
self.turns_since_last_compact = 0;
self.embedding_history.clear();
self.semantic_embed_cache.clear();
self.semantic_embed_cache_counter = 0;
self.total_turns = total_messages;
self.compacted_count = state.compacted_count.min(total_messages);
self.active_chars
.reset_pending(total_messages > self.compacted_count);
self.active_summary = Some(Summary {
text: state.summary_text.clone(),
openai_encrypted_content: state.openai_encrypted_content.clone(),
covers_up_to_turn: state.covers_up_to_turn,
original_turn_count: state.original_turn_count,
});
self.suppress_compaction_until_new_message = total_messages > 0;
}
/// Restore persisted compaction state and compute the active-suffix char
/// estimate from the provided full message list.
pub fn restore_persisted_state_with(
&mut self,
state: &crate::session::StoredCompactionState,
all_messages: &[Message],
) {
self.restore_persisted_state(state, all_messages.len());
self.active_chars.set_exact(
self.active_messages(all_messages)
.iter()
.map(message_char_count)
.sum(),
);
}
pub fn restore_persisted_stored_state_with(
&mut self,
state: &crate::session::StoredCompactionState,
all_messages: &[crate::session::StoredMessage],
) {
self.restore_persisted_state(state, all_messages.len());
let start = self.compacted_count.min(all_messages.len());
self.active_chars.set_exact(
all_messages[start..]
.iter()
.map(|message| content_char_count(&message.content))
.sum(),
);
}
/// Export the currently active compacted view for persistence.
pub fn persisted_state(&self) -> Option<crate::session::StoredCompactionState> {
self.active_summary
.as_ref()
.map(|summary| crate::session::StoredCompactionState {
summary_text: summary.text.clone(),
openai_encrypted_content: summary.openai_encrypted_content.clone(),
covers_up_to_turn: summary.covers_up_to_turn,
original_turn_count: summary.original_turn_count,
compacted_count: self.compacted_count,
})
}
/// Drop provider-native OpenAI compaction state when it can no longer be
/// replayed within OpenAI's per-string request limit. The compacted prefix
/// remains compacted, but future requests use a small text fallback instead
/// of bricking the session with an oversized `encrypted_content` field.
pub fn discard_oversized_openai_native_compaction(&mut self) -> bool {
let Some(summary) = self.active_summary.as_mut() else {
return false;
};
let Some(encrypted_content) = summary.openai_encrypted_content.as_ref() else {
return false;
};
if openai_encrypted_content_is_sendable(encrypted_content) {
return false;
}
let encrypted_content_len = encrypted_content.len();
crate::logging::warn(&format!(
"[compaction] Discarding oversized OpenAI native compaction payload ({} chars)",
encrypted_content_len,
));
summary.openai_encrypted_content = None;
let fallback = openai_encrypted_content_fallback_summary(encrypted_content_len);
if summary.text.trim().is_empty() {
summary.text = fallback;
} else if !summary
.text
.contains("OpenAI native compaction state was discarded")
{
summary.text.push_str("\n\n");
summary.text.push_str(&fallback);
}
self.observed_input_tokens = None;
true
}
// ── Token snapshot (proactive mode) ────────────────────────────────────
/// Record the observed token count after a completed turn.
///
/// Called by the agent after `update_compaction_usage_from_stream`.
/// Pushes the value into the rolling history window used by the proactive
/// and semantic modes. Also increments the cooldown counter.
pub fn push_token_snapshot(&mut self, tokens: u64) {
self.token_history.push_back(tokens);
if self.token_history.len() > TOKEN_HISTORY_WINDOW {
self.token_history.pop_front();
}
self.turns_since_last_compact += 1;
}
/// Record an embedding snapshot for the current turn (semantic mode).
///
/// `text` should be a short representation of the turn's assistant output
/// (first EMBED_MAX_CHARS_PER_MSG chars). Silently skipped if the
/// embedding model is unavailable.
pub fn push_embedding_snapshot(&mut self, text: &str) {
let snippet: String = text.chars().take(EMBED_MAX_CHARS_PER_MSG).collect();
if let Some(emb) = self.cached_semantic_embedding(&snippet) {
self.embedding_history.push_back(emb);
if self.embedding_history.len() > EMBEDDING_HISTORY_WINDOW {
self.embedding_history.pop_front();
}
}
}
// ── Anti-signal guard (shared by proactive + semantic) ──────────────────
/// Returns `true` when any anti-signal fires and we should NOT compact
/// proactively right now.
///
/// Anti-signals are universal guards applied before the mode-specific
/// trigger logic. They prevent wasted work and respect user intent.
fn anti_signals_block(&self, all_messages: &[Message]) -> bool {
let cfg = &self.compaction_config;
// 1. Already compacting — never double-trigger.
if self.pending_task.is_some() {
return true;
}
// 2. Context below the proactive floor — too early regardless of trend.
let usage = self.context_usage_with(all_messages);
if usage < cfg.proactive_floor {
return true;
}
// 3. Not enough token history to project from.
if self.token_history.len() < cfg.min_samples {
return true;
}
// 4. Growth has stalled: last stall_window snapshots show no increase.
// If tokens haven't grown, there's no urgency.
if self.token_history.len() >= cfg.stall_window {
let recent: Vec<u64> = self
.token_history
.iter()
.rev()
.take(cfg.stall_window)
.cloned()
.collect();
let oldest = recent[recent.len() - 1];
let newest = recent[0];
if newest <= oldest {
return true;
}
}
// 5. Cooldown: too soon after the last compaction.
if self.turns_since_last_compact < cfg.min_turns_between_compactions {
return true;
}
false
}
// ── Proactive mode trigger ──────────────────────────────────────────────
/// Returns `true` if the proactive strategy thinks we should compact now.
///
/// Uses an EWMA over the token history to project forward `lookahead_turns`
/// turns. If the projected token count would exceed the 80% threshold,
/// it's time to compact before we get there.
fn should_compact_proactively(&self, all_messages: &[Message]) -> bool {
if self.anti_signals_block(all_messages) {
return false;
}
let cfg = &self.compaction_config;
let budget = self.token_budget as f64;
let threshold = COMPACTION_THRESHOLD as f64 * budget;
// Compute EWMA of per-turn token deltas.
// We need at least 2 snapshots to get a delta.
let snapshots: Vec<u64> = self.token_history.iter().cloned().collect();
if snapshots.len() < 2 {
return false;
}
let alpha = cfg.ewma_alpha as f64;
let mut ewma_delta: f64 = (snapshots[1] as f64) - (snapshots[0] as f64);
ewma_delta = ewma_delta.max(0.0);
for i in 2..snapshots.len() {
let delta = ((snapshots[i] as f64) - (snapshots[i - 1] as f64)).max(0.0);
ewma_delta = alpha * delta + (1.0 - alpha) * ewma_delta;
}
let Some(current) = snapshots.last().copied().map(|value| value as f64) else {
return false;
};
let projected = current + ewma_delta * cfg.lookahead_turns as f64;
crate::logging::info(&format!(
"[compaction/proactive] current={:.0} ewma_delta={:.1}/turn projected@{}turns={:.0} threshold={:.0}",
current, ewma_delta, cfg.lookahead_turns, projected, threshold
));
projected >= threshold
}
// ── Semantic mode trigger ───────────────────────────────────────────────
/// Returns `true` if the semantic strategy detects a topic shift or
/// predicts we should compact now.
///
/// Topic-shift detection: compares the mean embedding of the oldest half
/// of the history window against the newest half. A low cosine similarity
/// between the two clusters indicates a topic boundary was crossed —
/// the previous topic is complete and safe to summarize.
///
/// Falls back to proactive logic if embeddings are unavailable.
fn should_compact_semantic(&self, all_messages: &[Message]) -> bool {
if self.anti_signals_block(all_messages) {
return false;
}
// Need enough embedding history to split into two halves.
let history_len = self.embedding_history.len();
if history_len < 4 {
// Fall back to proactive trigger.
return self.should_compact_proactively(all_messages);
}
let cfg = &self.compaction_config;
let half = history_len / 2;
let old_embeddings: Vec<&Vec<f32>> = self.embedding_history.iter().take(half).collect();
let new_embeddings: Vec<&Vec<f32>> = self.embedding_history.iter().skip(half).collect();
let dim = old_embeddings[0].len();
// Compute mean embedding for each half.
let mean_old = mean_embedding(&old_embeddings, dim);
let mean_new = mean_embedding(&new_embeddings, dim);
let similarity = crate::embedding::cosine_similarity(&mean_old, &mean_new);
crate::logging::info(&format!(
"[compaction/semantic] topic similarity (old vs new half) = {:.3} (threshold={:.2})",
similarity, cfg.topic_shift_threshold
));
if similarity < cfg.topic_shift_threshold {
crate::logging::info(
"[compaction/semantic] Topic shift detected — triggering proactive compaction",
);
return true;
}
// No topic shift — still fall back to proactive growth check.
self.should_compact_proactively(all_messages)
}
/// Build a relevance-scored keep set for semantic compaction.
///
/// Embeds the last `goal_window_turns` messages to represent the current
/// goal, then scores all active messages by cosine similarity. Returns the
/// cutoff index: messages before the cutoff will be summarized, messages at
/// or after are kept verbatim.
///
/// Messages above `relevance_keep_threshold` anywhere in the history are
/// pulled out of the summarize set. Falls back to the standard recency
/// cutoff if embeddings fail.
fn semantic_cutoff(&mut self, active: &[Message]) -> usize {
let goal_window_turns = self.compaction_config.goal_window_turns;
let relevance_keep_threshold = self.compaction_config.relevance_keep_threshold;
let standard_cutoff = active.len().saturating_sub(RECENT_TURNS_TO_KEEP);
if standard_cutoff == 0 {
return 0;
}
// Build goal text from recent turns.
let goal_turns = goal_window_turns.min(active.len());
let goal_text = semantic_goal_text(&active[active.len() - goal_turns..]);
if goal_text.is_empty() {
return standard_cutoff;
}
let goal_emb = match self.cached_semantic_embedding(&goal_text) {
Some(embedding) => embedding,
None => return standard_cutoff,
};
// Score each candidate message (those before standard_cutoff).
let mut high_relevance_count = 0usize;
let mut earliest_high_relevance = standard_cutoff;
for (idx, msg) in active[..standard_cutoff].iter().enumerate() {
let text = semantic_message_text(msg);
if text.is_empty() {
continue;
}
if let Some(embedding) = self.cached_semantic_embedding(&text) {
let sim = crate::embedding::cosine_similarity(&goal_emb, &embedding);
if sim >= relevance_keep_threshold {
high_relevance_count += 1;
earliest_high_relevance = earliest_high_relevance.min(idx);
}
}
}
if high_relevance_count == 0 {
return standard_cutoff;
}
// Find the latest high-relevance message before standard_cutoff.
// We can't have gaps in the summarized range (tool call integrity),
// so we move the cutoff up to just before the earliest high-relevance
// message in the tail of the compaction range.
let adjusted_cutoff = earliest_high_relevance;
// Ensure we actually compact something meaningful.
if adjusted_cutoff < 2 {
return standard_cutoff;
}
crate::logging::info(&format!(
"[compaction/semantic] relevance scoring: {} high-relevance msgs kept, cutoff {} -> {}",
high_relevance_count, standard_cutoff, adjusted_cutoff
));
adjusted_cutoff
}
/// Get the active (uncompacted) messages from a full message list.
/// Skips the first `compacted_count` messages.
fn active_messages<'a>(&self, all_messages: &'a [Message]) -> &'a [Message] {
// If session restore/replay leaves the manager with bookkeeping from a
// longer message vector, never fall back to the full transcript. That
// makes already-compacted messages active again and can drive repeated
// emergency compaction loops. Clamp to the end instead: all available
// messages are covered by the summary until new turns arrive.
let start = self.compacted_count.min(all_messages.len());
&all_messages[start..]
}
fn clamp_compacted_count_to_messages(
&mut self,
all_messages: &[Message],
reason: &str,
) -> bool {
// Some backward-compatible call paths intentionally poll/apply without
// caller-owned message history. An empty slice there means "unknown",
// not necessarily an empty transcript, so do not treat it as an
// authoritative upper bound.
if all_messages.is_empty() {
return false;
}
if self.compacted_count <= all_messages.len() {
return false;
}
crate::logging::warn(&format!(
"[compaction/invariant] compacted_count_exceeded_messages reason={} compacted_count={} messages_len={} total_turns={} has_summary={} summary_chars={} observed_input_tokens={:?}",
reason,
self.compacted_count,
all_messages.len(),
self.total_turns,
self.active_summary.is_some(),
self.summary_chars(),
self.observed_input_tokens,
));
self.compacted_count = all_messages.len();
self.active_chars.set_exact(0);
true
}
fn log_compaction_state(&self, phase: &str, trigger: &str, all_messages: &[Message]) {
let active_len = self.active_messages(all_messages).len();
crate::logging::info(&format!(
"[compaction/state] phase={} trigger={} messages_len={} active_messages={} compacted_count={} total_turns={} token_budget={} token_estimate={} effective_tokens={} observed_input_tokens={:?} has_summary={} summary_chars={} pending_cutoff={} is_compacting={}",
phase,
trigger,
all_messages.len(),
active_len,
self.compacted_count,
self.total_turns,
self.token_budget,
self.token_estimate_with(all_messages),
self.effective_token_count_with(all_messages),
self.observed_input_tokens,
self.active_summary.is_some(),
self.summary_chars(),
self.pending_cutoff,
self.pending_task.is_some(),
));
}
fn log_compaction_outcome(&self, outcome: CompactionOutcomeLog<'_>) {
let tokens_saved = outcome.pre_tokens.saturating_sub(outcome.post_tokens);
let grew = outcome.post_tokens > outcome.pre_tokens;
let level = if grew { "warn" } else { "info" };
let line = format!(
"[compaction/outcome] level={} trigger={} duration_ms={} pre_tokens={} post_tokens={} tokens_saved={} grew={} messages_len={} active_messages={} compacted_count={} total_turns={} messages_compacted={} messages_dropped={} summary_chars={} observed_input_tokens={:?}",
level,
outcome.trigger,
outcome.duration_ms,
outcome.pre_tokens,
outcome.post_tokens,
tokens_saved,
grew,
outcome.all_messages.len(),
self.active_messages(outcome.all_messages).len(),
self.compacted_count,
self.total_turns,
outcome.messages_compacted,
outcome.messages_dropped.unwrap_or(0),
self.summary_chars(),
self.observed_input_tokens,
);
if grew {
crate::logging::warn(&line);
} else {
crate::logging::info(&line);
}
}
fn active_message_chars_with(&self, all_messages: &[Message]) -> usize {
// Recompute from history when the cache is stale, or when the
// display-side turn estimate disagrees with the real active slice
// length (the two can diverge across restore/clamp/compaction paths,
// and trusting a mismatched cache is exactly what corrupts token
// accounting).
if self.active_chars.is_dirty()
|| self.active_messages_count() != self.active_messages(all_messages).len()
{
self.active_messages(all_messages)
.iter()
.map(message_char_count)
.sum()
} else {
self.active_chars.value()
}
}
/// Get current token estimate using the caller's message list
pub fn token_estimate_with(&self, all_messages: &[Message]) -> usize {
estimate_compaction_tokens(
self.active_summary.as_ref(),
self.active_message_chars_with(all_messages),
self.token_budget,
)
}
/// Get current token estimate (backward compat — uses 0 messages, only summary + observed)
pub fn token_estimate(&self) -> usize {
estimate_compaction_tokens(self.active_summary.as_ref(), 0, self.token_budget)
}
/// Store provider-reported input token usage for compaction decisions.
pub fn update_observed_input_tokens(&mut self, tokens: u64) {
self.observed_input_tokens = Some(tokens);
}
/// Best-effort current token count using the caller's messages.
pub fn effective_token_count_with(&self, all_messages: &[Message]) -> usize {
let estimate = self.token_estimate_with(all_messages);
let observed = self
.observed_input_tokens
.and_then(|tokens| usize::try_from(tokens).ok())
.unwrap_or(0);
estimate.max(observed)
}
/// Best-effort token count without message data (uses only observed tokens)
pub fn effective_token_count(&self) -> usize {
let estimate = self.token_estimate();
let observed = self
.observed_input_tokens
.and_then(|tokens| usize::try_from(tokens).ok())
.unwrap_or(0);
estimate.max(observed)
}
/// Get current context usage as percentage (using caller's messages)
pub fn context_usage_with(&self, all_messages: &[Message]) -> f32 {
self.effective_token_count_with(all_messages) as f32 / self.token_budget as f32
}
/// Get current context usage (without messages, uses observed tokens only)
pub fn context_usage(&self) -> f32 {
self.effective_token_count() as f32 / self.token_budget as f32
}
/// Check if we should start compaction
pub fn should_compact_with(&self, all_messages: &[Message]) -> bool {
use crate::config::CompactionMode;
if self.suppress_compaction_until_new_message {
return false;
}
let active = self.active_messages(all_messages);
match self.mode {
CompactionMode::Reactive => {
self.pending_task.is_none()
&& self.context_usage_with(all_messages) >= COMPACTION_THRESHOLD
&& active.len() > RECENT_TURNS_TO_KEEP
}
CompactionMode::Proactive => {
active.len() > RECENT_TURNS_TO_KEEP && self.should_compact_proactively(all_messages)
}
CompactionMode::Semantic => {
active.len() > RECENT_TURNS_TO_KEEP && self.should_compact_semantic(all_messages)
}
}
}
/// Start background compaction if needed
pub fn maybe_start_compaction_with(
&mut self,
all_messages: &[Message],
provider: Arc<dyn Provider>,
) {
if !self.should_compact_with(all_messages) {
return;
}
let active = self.active_messages(all_messages);
// Calculate cutoff within active messages.
// Semantic mode uses relevance scoring; other modes use recency.
let mut cutoff = match self.mode {
crate::config::CompactionMode::Semantic => self.semantic_cutoff(active),
_ => active.len().saturating_sub(RECENT_TURNS_TO_KEEP),
};
if cutoff == 0 {
return;
}
// Adjust cutoff to not split tool call/result pairs
cutoff = safe_compaction_cutoff(active, cutoff);
if cutoff == 0 {
return;
}
// Snapshot messages to summarize (must clone for the async task)
let messages_to_summarize: Vec<Message> = active[..cutoff].to_vec();
let msg_count = messages_to_summarize.len();
let existing_summary = self.active_summary.clone();
let mode_label = self.mode_trigger_label().to_string();
let estimated_tokens = self.effective_token_count_with(all_messages);
crate::logging::info(&format!(
"[TIMING] compaction_start: trigger={}, active_messages={}, cutoff={}, estimated_tokens={}, has_existing_summary={}",
mode_label,
active.len(),
cutoff,
estimated_tokens,
existing_summary.is_some(),
));
self.pending_cutoff = cutoff;
self.pending_trigger = Some(mode_label.clone());
// Spawn background task that notifies via Bus when done
self.pending_task = Some(tokio::spawn(async move {
let start = std::time::Instant::now();
let result =
generate_compaction_artifact(provider, messages_to_summarize, existing_summary)
.await;
let duration_ms = start.elapsed().as_millis() as u64;
crate::logging::info(&format!(
"Compaction ({}) finished in {:.2}s ({} messages summarized)",
mode_label,
duration_ms as f64 / 1000.0,
msg_count,
));
crate::bus::Bus::global().publish(crate::bus::BusEvent::CompactionFinished);
result.map(|mut result| {
result.duration_ms = duration_ms;
result.summarized_messages = msg_count;
result
})
}));
}
/// Ensure context fits before an API call.
///
/// Starts background compaction if above 80%. If context is critically full
/// (>=95%), also performs an immediate hard-compact (drops old messages) so
/// the next API call doesn't fail with "prompt too long".
pub fn ensure_context_fits(
&mut self,
all_messages: &[Message],
provider: Arc<dyn Provider>,
) -> CompactionAction {
// If we're already critically full, hard-compact synchronously *before*
// kicking off any background compaction. Starting a background task here
// would only get aborted by the hard compact (its summary is computed
// against the pre-hard-compact offsets), so skip the wasted work and the
// risk of a stale `pending_cutoff` being applied later.
let usage = self.context_usage_with(all_messages);
if usage >= CRITICAL_THRESHOLD {
if self.pending_task.is_some() {
crate::logging::warn(&format!(
"[compaction] Context at {:.1}% with background compaction in flight — waiting up to {}ms before hard compact",
usage * 100.0,
HARD_THRESHOLD_PENDING_WAIT_MS,
));
let waited = self.wait_for_pending_compaction_at_hard_threshold(all_messages);
let post_wait_usage = self.context_usage_with(all_messages);
crate::logging::info(&format!(
"[compaction] Hard-threshold wait complete: waited_ms={}, applied={}, timed_out={}, usage_now={:.1}%",
waited.waited_ms,
waited.applied,
waited.timed_out,
post_wait_usage * 100.0,
));
if post_wait_usage < CRITICAL_THRESHOLD {
// We may still be above the soft threshold. Let the normal
// path below decide whether another async compaction should
// start, but avoid dropping context now that the hard
// threshold has been cleared.
} else {
crate::logging::warn(&format!(
"[compaction] Context still at {:.1}% after waiting for in-flight compaction; escalating to hard compact",
post_wait_usage * 100.0,
));
match self.hard_compact_with(all_messages) {
Ok(dropped) => {
let post_usage = self.context_usage_with(all_messages);
crate::logging::info(&format!(
"[compaction] Hard compact dropped {} messages, context now at {:.1}%",
dropped,
post_usage * 100.0,
));
return CompactionAction::HardCompacted(dropped);
}
Err(reason) => {
crate::logging::error(&format!(
"[compaction] Hard compact failed at critical threshold: {}",
reason
));
}
}
}
} else {
crate::logging::warn(&format!(
"[compaction] Context at {:.1}% (critical threshold {:.0}%) — performing synchronous hard compact",
usage * 100.0,
CRITICAL_THRESHOLD * 100.0,
));
match self.hard_compact_with(all_messages) {
Ok(dropped) => {
let post_usage = self.context_usage_with(all_messages);
crate::logging::info(&format!(
"[compaction] Hard compact dropped {} messages, context now at {:.1}%",
dropped,
post_usage * 100.0,
));
return CompactionAction::HardCompacted(dropped);
}
Err(reason) => {
crate::logging::error(&format!(
"[compaction] Hard compact failed at critical threshold: {}",
reason
));
}
}
}
}
let was_compacting = self.is_compacting();
self.maybe_start_compaction_with(all_messages, provider);
let bg_started = !was_compacting && self.is_compacting();
if bg_started {
CompactionAction::BackgroundStarted {
trigger: self
.pending_trigger
.clone()
.unwrap_or_else(|| self.mode_trigger_label().to_string()),
}
} else {
CompactionAction::None
}
}
fn wait_for_pending_compaction_at_hard_threshold(
&mut self,
all_messages: &[Message],
) -> HardThresholdWait {
let start = Instant::now();
let timeout = std::time::Duration::from_millis(HARD_THRESHOLD_PENDING_WAIT_MS);
let poll = std::time::Duration::from_millis(HARD_THRESHOLD_PENDING_POLL_MS);
while start.elapsed() < timeout {
if self
.pending_task
.as_ref()
.map(|task| task.is_finished())
.unwrap_or(false)
{
self.check_and_apply_compaction_with(all_messages);
return HardThresholdWait {
waited_ms: start.elapsed().as_millis() as u64,
applied: self.last_compaction.is_some(),
timed_out: false,
};
}
std::thread::sleep(poll);
}
if self
.pending_task
.as_ref()
.map(|task| task.is_finished())
.unwrap_or(false)
{
self.check_and_apply_compaction_with(all_messages);
return HardThresholdWait {
waited_ms: start.elapsed().as_millis() as u64,
applied: self.last_compaction.is_some(),
timed_out: false,
};
}
HardThresholdWait {
waited_ms: start.elapsed().as_millis() as u64,
applied: false,
timed_out: true,
}
}
/// Force immediate compaction (for manual /compact command).
pub fn force_compact_with(
&mut self,
all_messages: &[Message],
provider: Arc<dyn Provider>,
) -> Result<(), String> {
if self.pending_task.is_some() {
return Err("Compaction already in progress".to_string());
}
let active = self.active_messages(all_messages);
if active.len() <= RECENT_TURNS_TO_KEEP {
return Err(format!(
"Not enough messages to compact (need more than {}, have {})",
RECENT_TURNS_TO_KEEP,
active.len()
));
}
if self.context_usage_with(all_messages) < MANUAL_COMPACT_MIN_THRESHOLD {
return Err(format!(
"Context usage too low ({:.1}%) - nothing to compact",
self.context_usage_with(all_messages) * 100.0
));
}
let mut cutoff = active.len().saturating_sub(RECENT_TURNS_TO_KEEP);
if cutoff == 0 {
return Err("No messages available to compact after keeping recent turns".to_string());
}
cutoff = safe_compaction_cutoff(active, cutoff);
if cutoff == 0 {
return Err("Cannot compact - would split tool call/result pairs".to_string());
}
let messages_to_summarize: Vec<Message> = active[..cutoff].to_vec();
let msg_count = messages_to_summarize.len();
let existing_summary = self.active_summary.clone();
self.pending_cutoff = cutoff;
self.pending_trigger = Some("manual".to_string());
self.pending_task = Some(tokio::spawn(async move {
let start = std::time::Instant::now();
let result =
generate_compaction_artifact(provider, messages_to_summarize, existing_summary)
.await;
let duration_ms = start.elapsed().as_millis() as u64;
crate::logging::info(&format!(
"Compaction finished in {:.2}s ({} messages summarized)",
duration_ms as f64 / 1000.0,
msg_count,
));
crate::bus::Bus::global().publish(crate::bus::BusEvent::CompactionFinished);
result.map(|mut result| {
result.duration_ms = duration_ms;
result.summarized_messages = msg_count;
result
})
}));
Ok(())
}
/// Check if background compaction is done and apply it, updating rolling
/// token-estimate state from the provided full message list.
pub fn check_and_apply_compaction_with(&mut self, all_messages: &[Message]) {
self.clamp_compacted_count_to_messages(all_messages, "check_and_apply_start");
let task = match self.pending_task.take() {
Some(task) => task,
None => return,
};
// Check if done without blocking
if !task.is_finished() {
// Not done yet, put it back
self.pending_task = Some(task);
return;
}
// Get result
match futures::executor::block_on(task) {
Ok(Ok(result)) => {
let trigger = self
.pending_trigger
.clone()
.unwrap_or_else(|| self.mode_trigger_label().to_string());
self.log_compaction_state("apply_start", &trigger, all_messages);
// Defense-in-depth: `pending_cutoff` was computed against the
// active slice as it existed when the background task started. If
// the active slice has since shrunk (e.g. an interleaving hard
// compaction advanced `compacted_count`), the produced summary no
// longer aligns with the current offsets, and applying the stale
// cutoff would over-advance `compacted_count` and wipe out live
// messages (observed as "kept 0 recent messages"). A soft
// compaction must always leave a healthy active tail, so detect
// the mismatch and discard the stale result instead of applying
// it. Hard compacts already abort the pending task, so this is a
// belt-and-suspenders guard.
let active_len = self.active_messages(all_messages).len();
let leaves_no_healthy_tail =
self.pending_cutoff > active_len.saturating_sub(MIN_TURNS_TO_KEEP);
if !all_messages.is_empty() && leaves_no_healthy_tail {
crate::logging::warn(&format!(
"[compaction] Discarding stale background compaction result (pending_cutoff={}, active_len={}, trigger={}) — context changed since it started",
self.pending_cutoff, active_len, trigger,
));
self.pending_cutoff = 0;
self.pending_trigger = None;
return;
}
let pre_tokens = self.effective_token_count_with(all_messages) as u64;
let compacted_chars: usize = self
.active_messages(all_messages)
.iter()
.take(self.pending_cutoff)
.map(message_char_count)
.sum();
let summary = Summary {
text: result.summary_text,
openai_encrypted_content: result.openai_encrypted_content,
covers_up_to_turn: result.covers_up_to_turn,
original_turn_count: self.pending_cutoff,
};
// Advance the compacted count — these messages are now summarized
self.compacted_count = self.compacted_count.saturating_add(self.pending_cutoff);
if !all_messages.is_empty() {
self.compacted_count = self.compacted_count.min(all_messages.len());
}
self.active_chars.set_exact(
self.active_message_chars_with(all_messages)
.saturating_sub(compacted_chars),
);
// Store summary
self.active_summary = Some(summary);
self.discard_oversized_openai_native_compaction();
self.observed_input_tokens = None;
let post_tokens = self.effective_token_count_with(all_messages) as u64;
self.last_compaction = Some(CompactionEvent {
trigger: trigger.clone(),
pre_tokens: Some(pre_tokens),
post_tokens: Some(post_tokens),
tokens_saved: Some(pre_tokens.saturating_sub(post_tokens)),
duration_ms: Some(result.duration_ms),
messages_dropped: None,
messages_compacted: Some(result.summarized_messages),
summary_chars: self
.active_summary
.as_ref()
.map(|summary| summary.text.len()),
active_messages: Some(self.active_messages_count()),
});
crate::logging::info(&format!(
"[TIMING] compaction_complete: trigger={}, duration={}ms, pre_tokens={}, post_tokens={}, tokens_saved={}, messages_compacted={}, summary_chars={}, active_messages={}",
self.last_compaction
.as_ref()
.map(|event| event.trigger.as_str())
.unwrap_or("unknown"),
result.duration_ms,
pre_tokens,
post_tokens,
pre_tokens.saturating_sub(post_tokens),
result.summarized_messages,
self.active_summary
.as_ref()
.map(|summary| summary.text.len())
.unwrap_or(0),
self.active_messages_count(),
));
self.log_compaction_outcome(CompactionOutcomeLog {
trigger: &trigger,
pre_tokens,
post_tokens,
messages_compacted: result.summarized_messages,
messages_dropped: None,
duration_ms: result.duration_ms,
all_messages,
});
// Reset cooldown counter so proactive/semantic modes don't
// fire again immediately after a successful compaction.
self.turns_since_last_compact = 0;
self.pending_cutoff = 0;
self.pending_trigger = None;
}
Ok(Err(e)) => {
crate::logging::error(&format!("[compaction] Failed to generate summary: {}", e));
self.pending_trigger = None;
self.pending_cutoff = 0;
}
Err(e) => {
crate::logging::error(&format!("[compaction] Task panicked: {}", e));
self.pending_trigger = None;
self.pending_cutoff = 0;
}
}
}
/// Backward-compatible completion check without caller history.
pub fn check_and_apply_compaction(&mut self) {
self.check_and_apply_compaction_with(&[]);
self.active_chars.invalidate();
}
/// Take the last compaction event (if any)
pub fn take_compaction_event(&mut self) -> Option<CompactionEvent> {
self.last_compaction.take()
}
/// Get messages for API call (with summary if compacted).
/// Takes the full message list from the caller.
pub fn messages_for_api_with(&mut self, all_messages: &[Message]) -> Vec<Message> {
self.check_and_apply_compaction_with(all_messages);
self.discard_oversized_openai_native_compaction();
let active = self.active_messages(all_messages);
match &self.active_summary {
Some(summary) => {
let summary_block = summary
.openai_encrypted_content
.as_ref()
.map(|encrypted_content| ContentBlock::OpenAICompaction {
encrypted_content: encrypted_content.clone(),
})
.unwrap_or_else(|| ContentBlock::Text {
text: compacted_summary_text_block(&summary.text),
cache_control: None,
});
let mut result = Vec::with_capacity(active.len() + 1);
result.push(Message {
role: Role::User,
content: vec![summary_block],
timestamp: None,
tool_duration_ms: None,
});
// Clone only the active (non-compacted) messages
result.extend(active.iter().cloned());
result
}
None => active.to_vec(),
}
}
/// Check if compaction is in progress
pub fn is_compacting(&self) -> bool {
self.pending_task.is_some()
}
/// Get the active compaction mode
pub fn mode(&self) -> crate::config::CompactionMode {
self.mode.clone()
}
/// Change the active compaction mode for this session at runtime.
pub fn set_mode(&mut self, mode: crate::config::CompactionMode) {
self.mode = mode.clone();
self.compaction_config.mode = mode;
}
fn mode_trigger_label(&self) -> &'static str {
self.mode.as_str()
}
/// Get the number of compacted (summarized) messages
pub fn compacted_count(&self) -> usize {
self.compacted_count
}
/// Get the character count of the active summary (0 if none)
pub fn summary_chars(&self) -> usize {
self.active_summary
.as_ref()
.map(summary_payload_char_count)
.unwrap_or(0)
}
/// Get the current number of active, un-compacted messages.
pub fn active_messages_count(&self) -> usize {
self.total_turns.saturating_sub(self.compacted_count)
}
/// Get stats about current state (without message data)
pub fn stats(&self) -> CompactionStats {
CompactionStats {
total_turns: self.total_turns,
active_messages: 0, // unknown without messages
has_summary: self.active_summary.is_some(),
is_compacting: self.is_compacting(),
token_estimate: self.token_estimate(),
effective_tokens: self.effective_token_count(),
observed_input_tokens: self.observed_input_tokens,
context_usage: self.context_usage(),
}
}
/// Get stats with full message data
pub fn stats_with(&self, all_messages: &[Message]) -> CompactionStats {
let active = self.active_messages(all_messages);
CompactionStats {
total_turns: self.total_turns,
active_messages: active.len(),
has_summary: self.active_summary.is_some(),
is_compacting: self.is_compacting(),
token_estimate: self.token_estimate_with(all_messages),
effective_tokens: self.effective_token_count_with(all_messages),
observed_input_tokens: self.observed_input_tokens,
context_usage: self.context_usage_with(all_messages),
}
}
fn cached_semantic_embedding(&mut self, text: &str) -> Option<Vec<f32>> {
let key = semantic_cache_key(text);
if let Some((cached, recency)) = self.semantic_embed_cache.get_mut(&key) {
let counter = self.semantic_embed_cache_counter;
self.semantic_embed_cache_counter = counter.wrapping_add(1);
*recency = counter;
return cached.clone();
}
let embedding = crate::embedding::embed(text).ok();
self.insert_semantic_embedding_cache(key, embedding.clone());
embedding
}
fn insert_semantic_embedding_cache(&mut self, key: u64, embedding: Option<Vec<f32>>) {
if self.semantic_embed_cache.len() >= SEMANTIC_EMBED_CACHE_CAPACITY {
let oldest_key = self
.semantic_embed_cache
.iter()
.min_by_key(|(_, (_, recency))| *recency)
.map(|(&key, _)| key);
if let Some(oldest_key) = oldest_key {
self.semantic_embed_cache.remove(&oldest_key);
}
}
let counter = self.semantic_embed_cache_counter;
self.semantic_embed_cache_counter = counter.wrapping_add(1);
self.semantic_embed_cache.insert(key, (embedding, counter));
}
/// Poll for compaction completion and return an event if one was applied.
pub fn poll_compaction_event_with(
&mut self,
all_messages: &[Message],
) -> Option<CompactionEvent> {
self.check_and_apply_compaction_with(all_messages);
self.take_compaction_event()
}
/// Emergency hard compaction: drop old messages without summarizing.
/// Takes the caller's full message list to inspect content.
///
/// When the remaining turns (after keeping `RECENT_TURNS_TO_KEEP`) still
/// exceed the token budget, progressively keeps fewer turns down to
/// `MIN_TURNS_TO_KEEP`.
pub fn hard_compact_with(&mut self, all_messages: &[Message]) -> Result<usize, String> {
if self.clamp_compacted_count_to_messages(all_messages, "hard_compact_start") {
self.log_compaction_state("hard_compact_clamped", "hard_compact", all_messages);
}
let active = self.active_messages(all_messages);
if active.len() <= MIN_TURNS_TO_KEEP {
return Err(format!(
"Not enough messages to compact (have {}, need more than {})",
active.len(),
MIN_TURNS_TO_KEEP
));
}
let pre_tokens = self.effective_token_count_with(all_messages) as u64;
self.log_compaction_state("hard_compact_start", "hard_compact", all_messages);
let active_char_counts: Vec<usize> = active.iter().map(message_char_count).collect();
let mut remaining_suffix_chars = vec![0usize; active_char_counts.len() + 1];
for idx in (0..active_char_counts.len()).rev() {
remaining_suffix_chars[idx] =
remaining_suffix_chars[idx + 1].saturating_add(active_char_counts[idx]);
}
let mut turns_to_keep = RECENT_TURNS_TO_KEEP.min(active.len().saturating_sub(1));
let mut cutoff;
loop {
cutoff = active.len().saturating_sub(turns_to_keep);
cutoff = safe_compaction_cutoff(active, cutoff);
if cutoff > 0 {
let remaining_tokens = remaining_suffix_chars[cutoff] / CHARS_PER_TOKEN;
if remaining_tokens <= self.token_budget {
break;
}
}
if turns_to_keep <= MIN_TURNS_TO_KEEP {
cutoff = active.len().saturating_sub(MIN_TURNS_TO_KEEP);
cutoff = safe_compaction_cutoff(active, cutoff);
break;
}
turns_to_keep = (turns_to_keep / 2).max(MIN_TURNS_TO_KEEP);
}
if cutoff == 0 {
return Err("Cannot compact — would split tool call/result pairs".to_string());
}
// This hard compact will advance `compacted_count` and supersede any
// in-flight background (reactive/proactive/semantic) compaction. That
// background task summarized messages relative to the *old*
// `compacted_count`; if it completed afterwards, `check_and_apply_*`
// would add its stale `pending_cutoff` on top of the already-advanced
// `compacted_count`, double-compacting and wiping out all live messages
// (observed as "kept 0 recent messages"). Abort and discard it now that
// we're committed to the hard compact.
if let Some(task) = self.pending_task.take() {
task.abort();
crate::logging::warn(&format!(
"[compaction] Aborting in-flight background compaction (pending_cutoff={}, trigger={:?}) — superseded by hard compact",
self.pending_cutoff, self.pending_trigger,
));
self.pending_cutoff = 0;
self.pending_trigger = None;
}
let dropped_count = cutoff;
let summary_text = build_emergency_summary_text(
self.active_summary
.as_ref()
.map(|summary| summary.text.as_str()),
dropped_count,
pre_tokens,
self.token_budget,
&active[..cutoff],
);
let summary = Summary {
text: summary_text,
openai_encrypted_content: None,
covers_up_to_turn: cutoff,
original_turn_count: cutoff,
};
self.compacted_count = self
.compacted_count
.saturating_add(cutoff)
.min(all_messages.len());
self.active_chars.set_exact(remaining_suffix_chars[cutoff]);
self.active_summary = Some(summary);
self.observed_input_tokens = None;
let post_tokens = self.effective_token_count_with(all_messages) as u64;
self.last_compaction = Some(CompactionEvent {
trigger: "hard_compact".to_string(),
pre_tokens: Some(pre_tokens),
post_tokens: Some(post_tokens),
tokens_saved: Some(pre_tokens.saturating_sub(post_tokens)),
duration_ms: Some(0),
messages_dropped: Some(dropped_count),
messages_compacted: Some(dropped_count),
summary_chars: self
.active_summary
.as_ref()
.map(|summary| summary.text.len()),
active_messages: Some(self.active_messages_count()),
});
self.log_compaction_outcome(CompactionOutcomeLog {
trigger: "hard_compact",
pre_tokens,
post_tokens,
messages_compacted: dropped_count,
messages_dropped: Some(dropped_count),
duration_ms: 0,
all_messages,
});
Ok(dropped_count)
}
/// Emergency truncation: shorten large tool results in active messages.
///
/// When hard compaction isn't sufficient (the remaining few turns are
/// individually too large), this truncates tool result content so the
/// conversation can fit within the token budget.
///
/// Returns the number of tool results that were truncated.
pub fn emergency_truncate_with(&mut self, all_messages: &mut [Message]) -> usize {
let start = self.compacted_count.min(all_messages.len());
let active = &mut all_messages[start..];
let truncated = emergency_truncate_large_payloads(
active,
EMERGENCY_TOOL_RESULT_MAX_CHARS,
EMERGENCY_IMAGE_MAX_CHARS,
);
if truncated > 0 {
self.observed_input_tokens = None;
self.active_chars.invalidate();
}
truncated
}
/// Synchronously force the context back under budget without waiting for a
/// background summary.
///
/// This is the shared escalation policy used by every emergency-recovery
/// caller: drop old turns via [`hard_compact_with`], then — only if the
/// context is *still* over budget — shorten oversized tool results via
/// [`emergency_truncate_with`]. Previously each caller open-coded this
/// sequence with subtly different escalation (one retried after a hard
/// compact without re-checking the budget), so centralizing it both removes
/// the duplication and guarantees consistent behavior.
///
/// Returns a structured outcome so callers can render their own
/// user-facing message. `pre_usage` is the context usage fraction observed
/// before recovery (captured here so the report matches what triggered it).
pub fn recover_within_budget(&mut self, all_messages: &mut [Message]) -> EmergencyRecovery {
let pre_usage = self.context_usage_with(all_messages);
let dropped = match self.hard_compact_with(all_messages) {
Ok(dropped) => Some(dropped),
Err(reason) => {
crate::logging::warn(&format!(
"[compaction] recover_within_budget: hard compact failed ({reason})"
));
None
}
};
// Only escalate to truncation when dropping turns did not get us under
// budget (or could not run at all).
let still_over_budget = self.context_usage_with(all_messages) > 1.0 || dropped.is_none();
let truncated = if still_over_budget {
self.emergency_truncate_with(all_messages)
} else {
0
};
EmergencyRecovery {
pre_usage,
dropped,
truncated,
}
}
}
/// Outcome of [`CompactionManager::recover_within_budget`].
#[derive(Debug, Clone, Copy)]
pub struct EmergencyRecovery {
/// Context usage fraction (1.0 == full budget) observed before recovery.
pub pre_usage: f32,
/// Messages dropped by the hard compact, or `None` if it could not run.
pub dropped: Option<usize>,
/// Number of oversized tool results that were truncated as a fallback.
pub truncated: usize,
}
impl EmergencyRecovery {
/// Whether any space-reclaiming action actually happened.
pub fn did_anything(&self) -> bool {
self.dropped.unwrap_or(0) > 0 || self.truncated > 0
}
/// A user-facing description of what recovery did, without a trailing
/// call to action (callers append their own, e.g. "Retrying..." or
/// "You can continue."). `trigger_usage` is the usage fraction that
/// triggered recovery (rendered as a percentage).
pub fn summary_line(&self, trigger_usage: f32) -> String {
let pct = trigger_usage * 100.0;
match (self.dropped, self.truncated) {
(Some(dropped), 0) => format!(
"⚡ Emergency compaction: dropped {dropped} old messages (context was at {pct:.0}%).",
),
(Some(dropped), truncated) => format!(
"⚡ Emergency compaction: dropped {dropped} old messages and truncated {truncated} tool result(s) (context was at {pct:.0}%).",
),
(None, truncated) => format!(
"⚡ Emergency truncation: shortened {truncated} large tool result(s) to fit context.",
),
}
}
}
impl Default for CompactionManager {
fn default() -> Self {
Self::new()
}
}
/// Generate summary using the provider
async fn generate_compaction_artifact(
provider: Arc<dyn Provider>,
messages: Vec<Message>,
mut existing_summary: Option<Summary>,
) -> Result<CompactionResult> {
let start = Instant::now();
if let Some(summary) = existing_summary.as_mut()
&& let Some(encrypted_content) = summary.openai_encrypted_content.as_ref()
&& !openai_encrypted_content_is_sendable(encrypted_content)
{
let encrypted_content_len = encrypted_content.len();
crate::logging::warn(&format!(
"[compaction] Existing OpenAI native compaction payload is oversized ({} chars); falling back to text summary",
encrypted_content_len,
));
summary.openai_encrypted_content = None;
let fallback = openai_encrypted_content_fallback_summary(encrypted_content_len);
if summary.text.trim().is_empty() {
summary.text = fallback;
} else if !summary
.text
.contains("OpenAI native compaction state was discarded")
{
summary.text.push_str("\n\n");
summary.text.push_str(&fallback);
}
}
if let Ok(native) = provider
.native_compact(
&messages,
existing_summary
.as_ref()
.map(|summary| summary.text.as_str()),
existing_summary
.as_ref()
.and_then(|summary| summary.openai_encrypted_content.as_deref()),
)
.await
{
if let Some(encrypted_content) = native.openai_encrypted_content.as_ref()
&& !openai_encrypted_content_is_sendable(encrypted_content)
{
crate::logging::warn(&format!(
"[compaction] OpenAI native compaction returned oversized encrypted_content ({} chars); falling back to text summary",
encrypted_content.len(),
));
} else {
return Ok(CompactionResult {
summary_text: native.summary_text.unwrap_or_default(),
openai_encrypted_content: native.openai_encrypted_content,
covers_up_to_turn: messages.len(),
duration_ms: start.elapsed().as_millis() as u64,
summarized_messages: messages.len(),
});
}
}
let max_prompt_chars = provider.context_window().saturating_sub(4000) * CHARS_PER_TOKEN;
let prompt = build_compaction_prompt(&messages, existing_summary.as_ref(), max_prompt_chars);
// Generate summary using simple completion
let summary = provider
.complete_simple(
&prompt,
"You are a helpful assistant that summarizes conversations.",
)
.await?;
Ok(CompactionResult {
summary_text: summary,
openai_encrypted_content: None,
covers_up_to_turn: messages.len(),
duration_ms: start.elapsed().as_millis() as u64,
summarized_messages: messages.len(),
})
}
pub async fn build_transfer_compaction_state(
provider: Arc<dyn Provider>,
messages: Vec<Message>,
existing_state: Option<crate::session::StoredCompactionState>,
) -> Result<Option<crate::session::StoredCompactionState>> {
let existing_summary = existing_state.as_ref().map(|state| Summary {
text: state.summary_text.clone(),
openai_encrypted_content: state.openai_encrypted_content.clone(),
covers_up_to_turn: state.covers_up_to_turn,
original_turn_count: state.original_turn_count,
});
if messages.is_empty() {
return Ok(existing_state.map(|mut state| {
state.compacted_count = 0;
state
}));
}
let prior_turns = existing_state
.as_ref()
.map(|state| state.original_turn_count.max(state.covers_up_to_turn))
.unwrap_or(0);
let result = generate_compaction_artifact(provider, messages.clone(), existing_summary).await?;
let total_turns = prior_turns + messages.len();
Ok(Some(crate::session::StoredCompactionState {
summary_text: result.summary_text,
openai_encrypted_content: result.openai_encrypted_content,
covers_up_to_turn: total_turns,
original_turn_count: total_turns,
compacted_count: 0,
}))
}
#[cfg(test)]
#[path = "compaction_tests.rs"]
mod tests;