use super::builder::AgentService;
use super::compaction_notice::CompactionNotifier;
use super::types::{ProgressCallback, ProgressEvent};
use crate::brain::agent::context::{AgentContext, CompactionScope};
use uuid::Uuid;
pub(crate) enum CompactionOutcome {
Summarised(String),
Truncated,
}
impl CompactionOutcome {
pub(crate) fn marker(&self, trigger: &str) -> String {
match self {
Self::Summarised(summary) => format!(
"[CONTEXT COMPACTION — The conversation was automatically compacted{trigger}. \
Below is a structured summary of everything before this point.]\n\n{summary}"
),
Self::Truncated => format!(
"[CONTEXT COMPACTION — The conversation was automatically compacted{trigger}. \
No summary is available: every summariser attempt failed, so the oldest \
messages were dropped to fit the window. Nothing before this point \
survives. Ask the user to restate anything you need rather than guessing \
at what was lost.]"
),
}
}
}
pub(crate) struct PendingCompaction {
handle: tokio::task::JoinHandle<crate::brain::agent::error::Result<String>>,
snapshot_len: usize,
scope: CompactionScope,
snapshot_usage_pct: f64,
started: std::time::Instant,
}
impl PendingCompaction {
pub(super) fn abort(self) {
self.handle.abort();
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum BudgetPhase {
TurnStart,
MidLoop,
}
pub(crate) enum PendingState {
Empty,
StillRunning,
Applied(String),
Failed,
}
pub(crate) fn gate_announces_compaction(state: &PendingState) -> bool {
matches!(state, PendingState::Empty | PendingState::Failed)
}
pub(crate) fn gate_warn_message(usage_pct: f64) -> String {
format!(
"Context at {:.1}% (threshold 65%) — triggering LLM compaction",
usage_pct
)
}
const BACKPRESSURE_CEILING_PCT: f64 = 80.0;
pub(crate) fn must_wait_for_compaction(phase: BudgetPhase, usage_pct: f64) -> bool {
phase == BudgetPhase::TurnStart || usage_pct >= BACKPRESSURE_CEILING_PCT
}
impl AgentService {
pub(super) async fn enforce_context_budget(
&self,
session_id: Uuid,
context: &mut AgentContext,
model_name: &str,
cancel_token: Option<&tokio_util::sync::CancellationToken>,
progress_callback: &Option<ProgressCallback>,
phase: BudgetPhase,
) -> Option<CompactionOutcome> {
let mut truncated = false;
let effective_max = context.max_tokens;
let effective_tokens = context.effective_token_count();
let usage_pct = if effective_max > 0 {
(effective_tokens as f64 / effective_max as f64) * 100.0
} else {
100.0
};
tracing::trace!(
"Context budget: {} tokens / {} max = {:.1}%",
context.token_count,
effective_max,
usage_pct,
);
if usage_pct >= 90.0 {
tracing::warn!(
"Context at {:.0}% ({} tokens) — hard truncating to 80%",
usage_pct,
context.token_count,
);
let target = (effective_max as f64 * 0.80) as usize;
let before_len = context.messages.len();
context.hard_truncate_to(target);
context.trim_to_fit(0);
truncated |= context.messages.len() < before_len;
if let Some(cb) = progress_callback {
cb(session_id, ProgressEvent::TokenCount(context.token_count));
}
tracing::info!(
"Hard truncation complete: {} messages, {} tokens ({:.0}%)",
context.messages.len(),
context.token_count,
effective_tokens as f64 / effective_max as f64 * 100.0,
);
let usage_pct_now = if effective_max > 0 {
(context.effective_token_count() as f64 / effective_max as f64) * 100.0
} else {
100.0
};
tracing::debug!(
"Post-truncation: {:.0}% — falling through to auto-compaction",
usage_pct_now,
);
}
let usage_pct = if effective_max > 0 {
(context.effective_token_count() as f64 / effective_max as f64) * 100.0
} else {
100.0
};
let pending_state = self
.resolve_pending_compaction(session_id, context, phase, usage_pct, progress_callback)
.await;
if let PendingState::Applied(summary) = pending_state {
return Some(CompactionOutcome::Summarised(summary));
}
let usage_pct = if effective_max > 0 {
(context.effective_token_count() as f64 / effective_max as f64) * 100.0
} else {
100.0
};
if usage_pct <= 65.0 {
self.maybe_emit_pressure_warning(session_id, context, usage_pct);
return truncated.then_some(CompactionOutcome::Truncated);
}
if !gate_announces_compaction(&pending_state) {
return truncated.then_some(CompactionOutcome::Truncated);
}
tracing::warn!("{}", gate_warn_message(usage_pct));
self.record_provider_feedback(
session_id,
"context_compaction",
model_name,
Some(&format!("proactive_65pct tokens={}", context.token_count)),
);
match pending_state {
PendingState::Empty if self.background_compaction => {
self.spawn_background_compaction(session_id, context, model_name, usage_pct);
return truncated.then_some(CompactionOutcome::Truncated);
}
PendingState::Empty | PendingState::Failed => {}
PendingState::StillRunning | PendingState::Applied(_) => {
unreachable!("gate_announces_compaction returns false for both")
}
}
let compact_started = std::time::Instant::now();
let before_pct = usage_pct;
let predicted = self
.last_compaction_elapsed
.read()
.ok()
.and_then(|map| map.get(&session_id).copied());
if let Some(cb) = progress_callback {
cb(
session_id,
ProgressEvent::Compacting {
usage_pct,
predicted,
},
);
}
let mut summary_result = None;
const MAX_ATTEMPTS: u32 = 3;
for attempt in 1..=MAX_ATTEMPTS {
match self
.compact_context(
session_id,
context,
model_name,
cancel_token,
CompactionNotifier::auto_from(session_id, progress_callback.as_ref()),
)
.await
{
Ok(summary) => {
summary_result = Some(summary);
break;
}
Err(e) => {
tracing::error!(
"LLM compaction failed (attempt {}/{}): {}",
attempt,
MAX_ATTEMPTS,
e
);
}
}
}
let target_tokens = (effective_max as f64 * 0.65) as usize;
if context.token_count > target_tokens && summary_result.is_some() {
tracing::warn!(
"Still at {} tokens after compaction (target {}), re-compacting",
context.token_count,
target_tokens,
);
if let Ok(summary) = self
.compact_context(
session_id,
context,
model_name,
cancel_token,
CompactionNotifier::auto_from(session_id, progress_callback.as_ref()),
)
.await
{
summary_result = Some(summary);
}
}
if summary_result.is_none() {
let safety_target = (effective_max as f64 * 0.80) as usize;
if context.token_count > safety_target {
tracing::warn!(
"Compaction exhausted, context at {} tokens (>{:.0}%) — safety truncation to 80%",
context.token_count,
usage_pct,
);
let before_len = context.messages.len();
context.hard_truncate_to(safety_target);
context.trim_to_fit(0);
truncated |= context.messages.len() < before_len;
}
}
if let Some(ref summary) = summary_result {
self.note_compaction_success(
session_id,
context,
summary,
before_pct,
compact_started.elapsed(),
progress_callback,
);
}
if let Some(cb) = progress_callback {
if let Some(ref summary) = summary_result {
let marker_tokens = AgentContext::estimate_tokens(summary) + 100;
let brain_tokens = self
.default_system_brain
.as_deref()
.map(AgentContext::estimate_tokens)
.unwrap_or(0);
cb(
session_id,
ProgressEvent::TokenCount(marker_tokens + brain_tokens),
);
} else {
cb(session_id, ProgressEvent::TokenCount(context.token_count));
}
}
match summary_result {
Some(summary) => Some(CompactionOutcome::Summarised(summary)),
None if truncated => Some(CompactionOutcome::Truncated),
None => None,
}
}
pub(crate) fn waiting_report_levels(requested: f64, live: f64) -> (f64, f64) {
(requested, live)
}
fn note_compaction_success(
&self,
session_id: Uuid,
context: &AgentContext,
summary: &str,
before_pct: f64,
elapsed: std::time::Duration,
progress_callback: &Option<ProgressCallback>,
) {
if let Ok(mut map) = self.session_pressure_warned.write() {
map.insert(session_id, false);
}
if let Ok(mut map) = self.last_compaction_elapsed.write() {
map.insert(session_id, elapsed);
}
if let Some(cb) = progress_callback {
let after_pct = context.usage_percentage();
cb(
session_id,
ProgressEvent::CompactionSummary {
summary: summary.to_string(),
before_pct,
after_pct,
elapsed,
},
);
}
}
fn predicted_compaction_elapsed(&self, session_id: Uuid) -> Option<std::time::Duration> {
self.last_compaction_elapsed
.read()
.ok()
.and_then(|map| map.get(&session_id).copied())
}
pub(super) fn compaction_attempt_deadline(&self, session_id: Uuid) -> std::time::Duration {
self.predicted_compaction_elapsed(session_id)
.map(|observed| observed * 3)
.unwrap_or(AgentService::COMPACTION_ATTEMPT_FLOOR)
.max(AgentService::COMPACTION_ATTEMPT_FLOOR)
}
pub(super) fn take_pending_compaction(&self, session_id: Uuid) -> Option<PendingCompaction> {
self.pending_compactions
.lock()
.ok()
.and_then(|mut map| map.remove(&session_id))
}
async fn resolve_pending_compaction(
&self,
session_id: Uuid,
context: &mut AgentContext,
phase: BudgetPhase,
usage_pct: f64,
progress_callback: &Option<ProgressCallback>,
) -> PendingState {
let Some(pending) = self.take_pending_compaction(session_id) else {
return PendingState::Empty;
};
if !pending.handle.is_finished() && !must_wait_for_compaction(phase, usage_pct) {
if let Ok(mut map) = self.pending_compactions.lock() {
map.insert(session_id, pending);
}
return PendingState::StillRunning;
}
if !pending.handle.is_finished() {
let (notice_pct, log_pct) =
Self::waiting_report_levels(pending.snapshot_usage_pct, usage_pct);
tracing::warn!(
"Waiting on background compaction at {:.0}% ({:?} elapsed) — {}",
log_pct,
pending.started.elapsed(),
if phase == BudgetPhase::TurnStart {
"a reply must not be composed against a context queued for replacement"
} else {
"context reached the back-pressure ceiling"
},
);
if let Some(cb) = progress_callback {
cb(
session_id,
ProgressEvent::Compacting {
usage_pct: notice_pct,
predicted: self.predicted_compaction_elapsed(session_id),
},
);
}
}
let PendingCompaction {
handle,
snapshot_len,
scope,
snapshot_usage_pct,
started,
} = pending;
match handle.await {
Ok(Ok(summary)) => {
Self::apply_compaction_summary_after(context, scope, &summary, snapshot_len);
self.note_compaction_success(
session_id,
context,
&summary,
snapshot_usage_pct,
started.elapsed(),
progress_callback,
);
if let Some(cb) = progress_callback {
cb(session_id, ProgressEvent::TokenCount(context.token_count));
}
PendingState::Applied(summary)
}
Ok(Err(e)) => {
tracing::error!(
"Background compaction failed after {:?}: {e}",
started.elapsed()
);
if let Some(cb) = progress_callback {
cb(
session_id,
ProgressEvent::SelfHealingAlert {
message: format!("Background compaction failed: {e}"),
},
);
}
PendingState::Failed
}
Err(e) => {
tracing::error!("Background compaction task did not finish: {e}");
PendingState::Failed
}
}
}
fn spawn_background_compaction(
&self,
session_id: Uuid,
context: &AgentContext,
model_name: &str,
usage_pct: f64,
) {
let snapshot_len = context.messages.len();
let (scope, messages, token_count) = context.compaction_input();
tracing::info!(
"Spawning background compaction at {:.0}% ({snapshot_len} messages, {scope:?} scope)",
usage_pct,
);
let provider = self.provider_for_session(session_id);
let fallbacks = self.fallback_chain_snapshot();
let max_tokens = context.max_tokens;
let model = model_name.to_string();
let max_output = self.request_max_tokens_for_session(session_id);
let working_dir = self.get_working_directory_for_session(session_id);
let auto_approve = self.auto_approve_tools;
let subagents = self.subagent_manager.clone();
let attempt_deadline = self.compaction_attempt_deadline(session_id);
let cancel = tokio_util::sync::CancellationToken::new();
let handle = tokio::spawn(async move {
let summary = Self::compute_compaction_summary(
provider,
fallbacks,
session_id,
scope,
messages,
token_count,
max_tokens,
usage_pct,
model,
max_output,
working_dir,
auto_approve,
cancel,
attempt_deadline,
None,
)
.await?;
Ok(Self::decorate_compaction_summary(summary, session_id, subagents).await)
});
if let Ok(mut map) = self.pending_compactions.lock() {
map.insert(
session_id,
PendingCompaction {
handle,
snapshot_len,
scope,
snapshot_usage_pct: usage_pct,
started: std::time::Instant::now(),
},
);
}
}
pub(super) fn maybe_emit_pressure_warning(
&self,
session_id: Uuid,
context: &mut AgentContext,
usage_pct: f64,
) {
use super::nudge::should_emit_pressure_warning;
if usage_pct < super::nudge::PRESSURE_WARN_FLOOR {
if let Ok(mut map) = self.session_pressure_warned.write() {
map.insert(session_id, false);
}
return;
}
let already_emitted = self
.session_pressure_warned
.read()
.ok()
.and_then(|map| map.get(&session_id).copied())
.unwrap_or(false);
if let Some(warning) = should_emit_pressure_warning(usage_pct, already_emitted) {
if let Some(ref mut brain) = context.system_brain {
tracing::info!(
"Context at {:.0}% - emitting pre-compaction pressure warning",
usage_pct
);
brain.push_str(warning);
context.token_count += AgentContext::estimate_tokens(warning);
}
if let Ok(mut map) = self.session_pressure_warned.write() {
map.insert(session_id, true);
}
}
}
pub fn pressure_warning_active(&self, session_id: Uuid) -> bool {
self.session_pressure_warned
.read()
.ok()
.and_then(|map| map.get(&session_id).copied())
.unwrap_or(false)
}
}