use std::sync::Arc;
use std::time::Duration;
use futures_util::StreamExt;
use tokio::sync::mpsc::UnboundedSender;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::app::events::{AppEvent, BackgroundKind};
use crate::entities::chat::Compaction;
use crate::entities::sampling::{ReasoningEffort, SamplingConfig};
use crate::features::compaction::{
build_compaction_digest, plan_cut, roll_user_message, summary_system_message,
};
use crate::shared::api::{ApiMessage, ChatChunk, ChatRequest, EngineBackend};
use crate::shared::config::ServerMode;
use crate::shared::session_budget::SILENT_YIELDS_MAX;
use super::background::BgOutcome;
use super::Orchestrator;
use super::generation::TurnUsage;
use super::title::salvage_title_source;
const COMPACT_MAX_TOKENS: usize = 2048;
const COMPACT_TIMEOUT: Duration = Duration::from_secs(180);
pub(super) struct CompactResult {
pub(super) chat_id: Uuid,
pub(super) boundary_id: Uuid,
pub(super) rolls: u32,
pub(super) origin: CompactOrigin,
pub(super) text: Result<String, CompactEnd>,
pub(super) prefill: Option<crate::shared::api::contract::Prefill>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum CompactEnd {
Failed(String),
Cancelled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum CompactOrigin {
Manual,
Auto,
}
pub(super) struct RollPlan {
cut: usize,
boundary_id: Uuid,
rolls: u32,
request: ChatRequest,
}
#[derive(Default)]
pub(super) struct EngineFacts {
pub(super) budget: Option<u32>,
pub(super) caps: Option<crate::shared::api::contract::ModelCapabilities>,
}
#[derive(Default)]
pub(super) struct ContextDiscovery {
epoch: u64,
pending: bool,
answered: bool,
known: Option<u32>,
caps: Option<crate::shared::api::contract::ModelCapabilities>,
}
impl ContextDiscovery {
pub(super) fn invalidate(&mut self) {
self.epoch += 1;
self.pending = false;
self.answered = false;
self.known = None;
self.caps = None;
}
#[cfg(test)]
pub(super) fn epoch(&self) -> u64 {
self.epoch
}
#[cfg(test)]
pub(super) fn pending(&self) -> bool {
self.pending
}
fn caps_differ(&self, facts: &EngineFacts) -> bool {
let published = |c: Option<&crate::shared::api::contract::ModelCapabilities>| {
c.and_then(|c| c.sampling_fields.clone())
};
published(facts.caps.as_ref()) != published(self.caps.as_ref())
}
fn apply(&mut self, epoch: u64, facts: EngineFacts) {
if epoch != self.epoch {
return;
}
self.pending = false;
self.answered = true;
self.known = facts.budget;
self.caps = facts.caps;
}
}
impl Orchestrator {
pub(super) fn handle_compact(&mut self) {
let ui = self.ui_locale();
if !self.config.compaction.enabled {
let _ = self
.evt_tx
.send(AppEvent::Notice(ui.t("ui.compact.disabled").into()));
return;
}
if self.bg_running(BackgroundKind::Compaction) {
let _ = self
.evt_tx
.send(AppEvent::Notice(ui.t("ui.compact.busy").into()));
return;
}
let Some(chat) = self.chats.iter().find(|c| Some(c.id) == self.active_id) else {
return;
};
let chat_id = chat.id;
let Some(plan) = self.plan_roll(chat) else {
let _ = self
.evt_tx
.send(AppEvent::Notice(ui.t("ui.compact.nothing").into()));
return;
};
if let Err(msg) = self.spawn_roll(chat_id, plan, CompactOrigin::Manual) {
let _ = self.evt_tx.send(AppEvent::Error(msg));
}
}
pub(super) fn maybe_auto_compact(&mut self, chat_id: Uuid, usage: Option<TurnUsage>) {
let (enabled, threshold_pct) = {
let cfg = &self.config.compaction;
(cfg.enabled, cfg.threshold_pct)
};
if !enabled || threshold_pct == 0 {
return;
}
let Some(usage) = usage else { return };
let Some(budget) = self.context_budget() else {
return;
};
let threshold = budget.saturating_mul(threshold_pct as u64) / 100;
if usage.next_prompt_estimate() < threshold {
return;
}
if self.bg_running(BackgroundKind::Compaction) {
return;
}
let Some(chat) = self.chats.iter().find(|c| c.id == chat_id) else {
return;
};
let Some(plan) = self.plan_roll(chat) else {
tracing::debug!(chat = %chat_id, "over the compaction threshold with nothing left to fold");
return;
};
let folded = plan.cut;
if let Err(reason) = self.spawn_roll(chat_id, plan, CompactOrigin::Auto) {
tracing::debug!(chat = %chat_id, %reason, "auto-compaction deferred");
return;
}
tracing::info!(
chat = %chat_id,
prompt = usage.prompt_tokens,
budget,
folded,
"auto-compaction started"
);
}
pub(super) fn context_budget(&mut self) -> Option<u64> {
if let Some(explicit) = self.config.compaction.context_tokens.filter(|&n| n > 0) {
return Some(explicit as u64);
}
if self.config.engine.mode == ServerMode::Managed {
let ctx = self.config.engine.managed.context_size;
return (ctx > 0).then_some(ctx as u64);
}
if let Some(known) = self.context.known {
return Some(known as u64);
}
if let Some(published) = self
.context
.caps
.as_ref()
.and_then(|c| c.context_length)
.filter(|&n| n > 0)
{
return Some(published as u64);
}
if !self.context.answered && !self.context.pending {
self.ask_engine_for_budget();
}
None
}
pub(super) fn endpoint_sampling_fields(&self) -> Option<std::sync::Arc<[String]>> {
self.context
.caps
.as_ref()
.and_then(|c| c.sampling_fields.clone())
}
pub(super) fn endpoint_catalogued(&self) -> bool {
self.context.caps.is_some()
}
pub(super) fn refresh_engine_facts(&mut self) {
self.context.invalidate();
self.images_withheld_noted.clear();
self.ask_engine_for_budget();
}
fn ask_engine_for_budget(&mut self) {
let Some(backend) = self.engines.backend.clone() else {
return;
};
self.context.pending = true;
let epoch = self.context.epoch;
let tx = self.budget_tx.clone();
tokio::spawn(async move {
let facts = EngineFacts {
budget: backend.context_budget().await,
caps: backend.model_capabilities().await,
};
let _ = tx.send((epoch, facts));
});
}
pub(super) fn handle_budget_result(&mut self, epoch: u64, facts: EngineFacts) {
if let Some(n) = facts.budget {
tracing::info!(context_budget = n, "engine reported its context window");
}
if let Some(caps) = &facts.caps {
tracing::info!(
context_length = ?caps.context_length,
sampling_fields = caps.sampling_fields.as_ref().map_or(0, |f| f.len()),
"the endpoint's catalogue answered for the configured model"
);
}
let _ = self
.evt_tx
.send(crate::app::events::AppEvent::EngineSamplingFields(
facts.caps.as_ref().and_then(|c| c.sampling_fields.clone()),
));
let narrowed = self.context.caps_differ(&facts);
self.context.apply(epoch, facts);
if narrowed {
self.rebuild_registry();
}
}
fn plan_roll(&self, chat: &crate::entities::chat::Chat) -> Option<RollPlan> {
let loc = self.profile_locale(chat.profile_id);
let cfg = &self.config.compaction;
let previous = chat.compaction_view(true);
let prev_upto = previous.map_or(0, |(_, i)| i);
let cut = plan_cut(&chat.messages, cfg.tail_tokens).filter(|&cut| cut > prev_upto)?;
let digest = build_compaction_digest(&chat.messages[prev_upto..cut], loc)?;
let sampling = SamplingConfig {
max_tokens: Some(COMPACT_MAX_TOKENS),
temperature: Some(0.3),
thinking: Some(false),
reasoning_effort: Some(ReasoningEffort::None),
reasoning_budget: Some(0),
..Default::default()
};
let user = match previous {
Some((summary, _)) => roll_user_message(summary, &digest, loc, cfg.summary_words),
None => digest,
};
Some(RollPlan {
cut,
boundary_id: chat.messages[cut].id,
rolls: chat.compaction.as_ref().map_or(0, |c| c.rolls) + 1,
request: ChatRequest {
continue_final: false,
system: Some(summary_system_message(loc, cfg.summary_words)),
messages: vec![ApiMessage::user(user)],
sampling,
tools: Vec::new(),
},
})
}
fn spawn_roll(
&mut self,
chat_id: Uuid,
plan: RollPlan,
origin: CompactOrigin,
) -> Result<(), String> {
let backend = self.engines.backend_if_ready(self.ui_locale())?;
let cancel = CancellationToken::new();
let sessions = self.session_budget();
spawn_compact(
backend,
plan,
chat_id,
origin,
cancel.clone(),
self.ui_locale(),
self.compact_tx.clone(),
sessions,
);
self.begin_bg(BackgroundKind::Compaction, cancel, None);
Ok(())
}
pub(super) fn handle_compact_result(&mut self, res: CompactResult) {
let CompactResult {
chat_id,
boundary_id,
rolls,
origin,
text,
prefill,
} = res;
let outcome = match (text, origin) {
(Ok(summary), _) => {
match self.apply_compaction(chat_id, boundary_id, rolls, summary, origin) {
Ok(()) => BgOutcome::Done,
Err(msg) => BgOutcome::Failed(msg),
}
}
(Err(CompactEnd::Cancelled), CompactOrigin::Manual) => {
let _ = self.evt_tx.send(AppEvent::Notice(
self.ui_locale().t("ui.compact.cancelled").into(),
));
BgOutcome::Cancelled { consumed: false }
}
(Err(CompactEnd::Cancelled), CompactOrigin::Auto) => {
BgOutcome::Cancelled { consumed: false }
}
(Err(CompactEnd::Failed(msg)), CompactOrigin::Manual) => {
let _ = self.evt_tx.send(AppEvent::Error(msg));
BgOutcome::Done
}
(Err(CompactEnd::Failed(msg)), CompactOrigin::Auto) => BgOutcome::Failed(msg),
};
self.handle_bg_done(BackgroundKind::Compaction, outcome, prefill);
}
fn apply_compaction(
&mut self,
chat_id: Uuid,
boundary_id: Uuid,
rolls: u32,
summary: String,
origin: CompactOrigin,
) -> Result<(), String> {
let summary = summary.trim().to_string();
if summary.is_empty() {
let msg = self.ui_locale().t("ui.err.compact_timeout").to_string();
if origin == CompactOrigin::Manual {
let _ = self.evt_tx.send(AppEvent::Error(msg.clone()));
}
return Err(msg);
}
let Some(chat) = self.chat_mut(chat_id) else {
return Ok(());
};
let Some(upto) = chat.messages.iter().position(|m| m.id == boundary_id) else {
tracing::warn!(chat = %chat_id, "compaction boundary vanished mid-roll, discarding");
return Ok(());
};
chat.compaction = Some(Compaction {
summary: summary.clone(),
upto,
boundary_id,
compacted_at: chrono::Utc::now(),
rolls,
});
self.mark_dirty(chat_id);
let _ = self.evt_tx.send(AppEvent::Compacted {
chat_id,
boundary: boundary_id,
summary,
folded: upto,
});
Ok(())
}
}
#[allow(clippy::too_many_arguments)]
fn spawn_compact(
backend: Arc<dyn EngineBackend>,
plan: RollPlan,
chat_id: Uuid,
origin: CompactOrigin,
cancel: CancellationToken,
loc: &'static crate::shared::i18n::Locale,
compact_tx: UnboundedSender<CompactResult>,
sessions: Arc<crate::shared::session_budget::SessionBudget>,
) {
let RollPlan {
boundary_id,
rolls,
request,
cut: _,
} = plan;
tokio::spawn(async move {
let estimate = super::generation::estimate_prompt_tokens(&request);
let need = sessions.price(
crate::shared::session_budget::Shape::Roll,
estimate,
0,
request.sampling.max_tokens.map(|m| m as u64),
);
let mut yields: u32 = 0;
let (text, prefill) = loop {
let Some(lane) = sessions
.acquire_silent(need, &cancel, "compaction", yields < SILENT_YIELDS_MAX)
.await
else {
let _ = compact_tx.send(CompactResult {
chat_id,
boundary_id,
rolls,
origin,
text: Err(CompactEnd::Cancelled),
prefill: None,
});
return;
};
let collect = collect_roll(&backend, request.clone(), lane.stream_token());
let attempt = tokio::time::timeout(COMPACT_TIMEOUT, collect).await;
if let Ok(Ok(c)) = &attempt
&& let Some(u) = &c.usage
{
sessions.record_usage(
crate::shared::session_budget::Shape::Roll,
estimate,
u.prompt_tokens as u64,
);
}
match attempt {
Ok(Ok(c)) if c.cancelled && lane.displaced() => {
yields += 1;
tracing::info!(
chat = %chat_id,
yields,
"the roll's stream was displaced by an interactive one; made again"
);
}
Ok(Ok(c)) if c.cancelled => break (Err(CompactEnd::Cancelled), None),
Ok(Ok(c)) => {
if c.truncated {
tracing::warn!(
chat = %chat_id,
"the summary hit the token ceiling and was cut; raise the ceiling or lower the word limit"
);
}
if c.filtered {
tracing::warn!(
chat = %chat_id,
"the provider's content filter stopped the summary; what arrived is used"
);
}
break (
Ok(salvage_title_source(c.text, c.thoughts)),
c.usage.and_then(|u| u.prefill),
);
}
Ok(Err(err)) => {
break (
Err(CompactEnd::Failed(
loc.tf("ui.err.compact_failed", &[("err", &err.to_string())]),
)),
None,
);
}
Err(_) => {
cancel.cancel();
break (
Err(CompactEnd::Failed(
loc.t("ui.err.compact_timeout").to_string(),
)),
None,
);
}
}
};
let _ = compact_tx.send(CompactResult {
chat_id,
boundary_id,
rolls,
origin,
text,
prefill,
});
});
}
#[derive(Default)]
pub(super) struct Collected {
pub(super) text: String,
pub(super) thoughts: String,
pub(super) truncated: bool,
pub(super) filtered: bool,
pub(super) cancelled: bool,
pub(super) usage: Option<crate::shared::api::contract::TokenUsage>,
}
pub(super) async fn collect_roll(
backend: &Arc<dyn EngineBackend>,
request: ChatRequest,
token: CancellationToken,
) -> anyhow::Result<Collected> {
let mut stream = backend.chat_stream(request, token).await?;
let mut c = Collected::default();
let mut failure: Option<String> = None;
while let Some(chunk) = stream.next().await {
match chunk {
ChatChunk::Text(t) => c.text.push_str(&t),
ChatChunk::Thoughts(t) => c.thoughts.push_str(&t),
ChatChunk::Finished(reason) => {
c.truncated = matches!(reason, crate::shared::api::FinishReason::Length);
c.cancelled = matches!(reason, crate::shared::api::FinishReason::Cancelled);
c.filtered = matches!(reason, crate::shared::api::FinishReason::Filtered);
break;
}
ChatChunk::Retry {
attempt,
max,
delay,
} => {
tracing::info!(attempt, max, ?delay, "retrying a compaction turn");
}
ChatChunk::Error { message, .. } => failure = Some(message),
ChatChunk::Usage(u) => c.usage = Some(u),
ChatChunk::ThoughtsSignature(_) | ChatChunk::ToolCall(_) => {}
}
}
if let Some(err) = failure {
anyhow::bail!("{err}");
}
Ok(c)
}