leviath_runtime/pipeline/compaction.rs
1//! Threshold compaction and edge-transform compaction, over the compaction lane.
2
3use super::*;
4
5// ─── Compaction (LLM context summarization) ──────────────────────────────────
6
7/// Per-agent compaction configuration; its presence opts the agent into
8/// automatic eviction + LLM compaction before each inference (mirrors the
9/// imperative loop's `Option<&CompactionConfig>`).
10#[derive(Component, Clone)]
11pub struct CompactionSettings(pub leviath_core::CompactionConfig);
12
13/// A compaction job (LLM summarization) is in flight; the agent is held out of
14/// inference until its summaries land.
15#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
16pub struct AwaitingCompaction;
17
18/// The receiving end of the compaction-outcomes channel, as a world resource.
19/// (The sending end lives in [`InferenceStage::compaction_outcomes`].)
20#[derive(Resource)]
21pub struct CompactionResults(pub UnboundedReceiver<CompactionOutcome>);
22
23/// The eviction threshold (fraction of budget) at which compaction kicks in -
24/// the same 0.9 the imperative `evict_and_compact` uses.
25pub(crate) const EVICTION_THRESHOLD: f32 = 0.9;
26
27/// Spawn a compaction job under the lane supervisor, so a job that dies without
28/// reporting still produces an outcome.
29///
30/// Compaction is best-effort, but *waiting* for it is not: the agent is held
31/// `AwaitingCompaction` until an outcome lands. A lost job would park it there
32/// for good. The synthesized error takes the collect system's failure path,
33/// which returns the agent to `ReadyToInfer` with its context untouched - the
34/// same place a genuine summarization failure leaves it.
35fn spawn_supervised_compaction(stage: &InferenceStage, entity: Entity, job: CompactionJob) {
36 let lost_outcomes = stage.compaction_outcomes.clone();
37 let lost_wake = stage.wake.clone();
38 crate::lane_supervisor::spawn_supervised(
39 &stage.runtime,
40 "compaction",
41 run_compaction_job(
42 job,
43 std::time::Duration::from_secs(leviath_providers::DEFAULT_INFERENCE_TIMEOUT_SECS),
44 stage.compaction_outcomes.clone(),
45 stage.wake.clone(),
46 ),
47 move |message| {
48 let _ = lost_outcomes.send(CompactionOutcome {
49 entity,
50 result: Err(leviath_providers::ProviderError::Other(message)),
51 // A job that never ran billed nothing. Empty rather than
52 // absent: there is no call to attribute, not an unknown cost.
53 usage: Vec::new(),
54 provider_name: String::new(),
55 model: String::new(),
56 });
57 lost_wake.notify_one();
58 },
59 );
60}
61
62/// What `dispatch_compaction` selects.
63///
64/// `&'static` is bevy's `WorldQuery` convention, not a claim about
65/// lifetimes: the borrow is bound when the query is fetched.
66type CompactionQuery = (
67 Entity,
68 &'static AgentState,
69 &'static mut ContextWindow,
70 &'static CompactionSettings,
71);
72
73/// Compaction-dispatch system: for each `ReadyToInfer` agent with
74/// [`CompactionSettings`] whose window is over the eviction threshold, do the
75/// synchronous eviction inline; if that surfaces regions needing LLM
76/// summarization (and content to summarize), build one request per region,
77/// acquire a permit for the compaction model, spawn the job, and hold the agent
78/// as `AwaitingCompaction`. Anything that can't proceed (under threshold, nothing
79/// to summarize, provider missing, pool full) simply leaves the agent
80/// `ReadyToInfer` so inference proceeds - compaction is best-effort. (Ported from
81/// `AgentEngine::evict_and_compact`.)
82pub fn dispatch_compaction(
83 mut agents: Query<CompactionQuery, (With<ReadyToInfer>, Without<AwaitingCompaction>)>,
84 stage: Res<InferenceStage>,
85 providers: Res<Providers>,
86 mut commands: Commands,
87) {
88 crate::tick_scope::clear();
89 for (entity, state, mut window, settings) in agents.iter_mut() {
90 crate::tick_scope::enter(entity);
91 if state.status != AgentStatus::Active {
92 continue; // paused / waiting / cancelled - don't start new work
93 }
94 if !window.needs_eviction(EVICTION_THRESHOLD) {
95 continue; // under threshold - nothing to do
96 }
97 let target_free = window.max_tokens / 10;
98 let Ok(eviction) = window.try_evict(target_free) else {
99 continue; // couldn't evict - proceed to inference as-is
100 };
101
102 // Build a summarize request per region that both needs compaction and
103 // has content to summarize.
104 let config = &settings.0;
105 let mut requests = Vec::new();
106 for region_name in &eviction.needs_compaction {
107 // The names come from `try_evict`'s own scan of `window.regions`, and
108 // nothing between there and here mutates the region set, so the region
109 // is guaranteed present.
110 let region = window
111 .get_region(region_name)
112 .expect("needs_compaction region present: named by try_evict's own scan");
113 let content: String = region
114 .content
115 .iter()
116 .map(|e| e.content.as_str())
117 .collect::<Vec<_>>()
118 .join("\n\n");
119 if content.is_empty() {
120 continue; // nothing to summarize (e.g. token-only placeholder)
121 }
122 requests.push((
123 region_name.clone(),
124 compaction_request(config, &content, region_name),
125 ));
126 }
127 if requests.is_empty() {
128 continue; // sync eviction was enough (or nothing summarizable)
129 }
130
131 let Some(provider) = providers.0.get(&config.provider) else {
132 continue; // compaction provider not registered - skip, non-fatal
133 };
134 let Some(permit) = stage.pools.try_acquire(&config.model) else {
135 continue; // pool full - skip compaction this round
136 };
137
138 spawn_supervised_compaction(
139 &stage,
140 entity,
141 CompactionJob {
142 entity,
143 provider,
144 provider_name: config.provider.clone(),
145 model: config.model.clone(),
146 requests,
147 permit,
148 },
149 );
150 commands
151 .entity(entity)
152 .remove::<ReadyToInfer>()
153 .insert(AwaitingCompaction);
154 }
155}
156
157/// What `collect_compaction` selects.
158///
159/// `&'static` is bevy's `WorldQuery` convention, not a claim about
160/// lifetimes: the borrow is bound when the query is fetched.
161type CollectCompactionQuery = (
162 &'static mut ContextWindow,
163 Option<&'static mut crate::telemetry::StageActivity>,
164 Option<&'static mut crate::persistence::TokenTotals>,
165 Option<&'static crate::persistence::RunMetadata>,
166 Option<&'static AgentState>,
167);
168
169/// Compaction-collect system: drain finished compaction jobs and apply each
170/// summary into its paired `CompactHistory` region, clearing the summarized
171/// source region. A provider error leaves the context untouched (best-effort).
172/// Either way the agent returns to `ReadyToInfer`. (Ported from the storage tail
173/// of `AgentEngine::compact_region`.)
174pub fn collect_compaction(
175 mut results: ResMut<CompactionResults>,
176 mut agents: Query<CollectCompactionQuery, With<AwaitingCompaction>>,
177 persist: Option<Res<crate::pipeline::PersistenceStage>>,
178 mut commands: Commands,
179) {
180 crate::tick_scope::clear();
181 while let Ok(outcome) = results.0.try_recv() {
182 let Ok((mut window, activity, mut totals, md, state)) = agents.get_mut(outcome.entity)
183 else {
184 continue; // stale: agent cancelled/despawned since dispatch
185 };
186 crate::tick_scope::enter(outcome.entity);
187 if let Some(mut activity) = activity {
188 activity
189 .0
190 .push(crate::telemetry::ActivityRecord::Compaction {
191 success: outcome.result.is_ok(),
192 });
193 }
194 // One record per call, not one per batch. A batch summarizes a region
195 // each, so folding them into a single number would recreate downstream
196 // exactly the ambiguity this record exists to remove.
197 //
198 // Counted even when the batch failed partway: the calls that already
199 // ran were billed, and the summaries being discarded is a decision
200 // about the window, not about the invoice.
201 for usage in &outcome.usage {
202 crate::inference_usage::record_call(
203 totals.as_deref_mut(),
204 persist.as_deref(),
205 md,
206 &crate::inference_usage::CallUsage {
207 kind: leviath_core::run_archive::InferenceKind::Compaction,
208 stage: state.map_or("", |s| s.current_stage.as_str()),
209 iteration: state.map_or(0, |s| s.iteration),
210 provider: &outcome.provider_name,
211 model: &outcome.model,
212 usage,
213 },
214 );
215 }
216 if let Ok(summaries) = outcome.result {
217 for (region_name, summary) in summaries {
218 let summary_tokens = leviath_core::estimate_tokens(&summary);
219 let history = window
220 .regions
221 .iter()
222 .find(|r| {
223 matches!(&r.kind, leviath_core::RegionKind::CompactHistory { source_region }
224 if source_region == ®ion_name)
225 })
226 .map(|r| r.name.clone());
227 if let Some(history_name) = history {
228 let _ = window.add_to_region(&history_name, summary, summary_tokens);
229 }
230 if let Some(region) = window.get_region_mut(®ion_name) {
231 region.clear();
232 }
233 }
234 window.current_tokens = window.calculate_tokens();
235 }
236 commands
237 .entity(outcome.entity)
238 .remove::<AwaitingCompaction>()
239 .insert(ReadyToInfer);
240 }
241}
242
243/// Build the summarize [`InferenceRequest`] for one region's content.
244pub(crate) fn compaction_request(
245 config: &leviath_core::CompactionConfig,
246 content: &str,
247 region_name: &str,
248) -> InferenceRequest {
249 InferenceRequest {
250 system: vec![],
251 messages: vec![
252 leviath_providers::Message {
253 role: "system".to_string(),
254 content: config.system_prompt().to_string().into(),
255 cache_breakpoint: false,
256 },
257 leviath_providers::Message {
258 role: "user".to_string(),
259 content: config.user_prompt(content, region_name).into(),
260 cache_breakpoint: false,
261 },
262 ],
263 model: config.model.clone(),
264 max_tokens: config.max_summary_tokens,
265 temperature: config.temperature,
266 tools: Vec::new(),
267 extra: serde_json::Value::Null,
268 request_timeout_secs: None,
269 }
270}
271
272// ─── Edge transforms (context reshaping on stage transitions) ────────────────
273
274/// Regions an edge transform asked to LLM-compact after a transition, awaiting
275/// the compaction lane (drained by [`dispatch_edge_compact`]).
276#[derive(Component, Debug, Clone)]
277pub struct PendingEdgeCompact(pub Vec<String>);
278
279/// Whether a region kind is "stage-specific" - eligible for an edge transform to
280/// clear or compact. The always-preserved kinds (pinned identity, compaction
281/// history, hashmap stores, persistent custom regions) are never touched.
282pub fn is_stage_specific(kind: &leviath_core::RegionKind) -> bool {
283 !matches!(
284 kind,
285 leviath_core::RegionKind::Pinned
286 | leviath_core::RegionKind::CompactHistory { .. }
287 | leviath_core::RegionKind::HashMap { .. }
288 | leviath_core::RegionKind::Custom {
289 persistent: true,
290 ..
291 }
292 )
293}
294
295/// Apply an edge transform's **synchronous** effects to the outgoing window
296/// (clearing stage-specific / named regions) and return the names of regions the
297/// caller should hand to the LLM compaction lane. (Ported from the deleted
298/// `graph::apply_edge_transform`; `Direct` on a linear/chosen edge carries context
299/// as-is.)
300pub(crate) fn apply_edge_transform(
301 window: &mut ContextWindow,
302 transform: &leviath_core::blueprint::EdgeTransform,
303) -> Vec<String> {
304 use leviath_core::blueprint::EdgeTransform;
305 match transform {
306 EdgeTransform::Direct => Vec::new(),
307 EdgeTransform::Clear => {
308 window
309 .regions
310 .iter_mut()
311 .filter(|r| is_stage_specific(&r.kind))
312 .for_each(|r| r.clear());
313 window.current_tokens = window.calculate_tokens();
314 Vec::new()
315 }
316 // Kind cannot tell a transcript from a table of results, so a region
317 // whose author said its content does not survive a paraphrase is left
318 // alone however the edge is spelled (#369).
319 EdgeTransform::Compact { .. } => window
320 .regions
321 .iter()
322 .filter(|r| is_stage_specific(&r.kind) && r.summarizable && !r.content.is_empty())
323 .map(|r| r.name.clone())
324 .collect(),
325 EdgeTransform::Custom {
326 carry,
327 compact,
328 clear,
329 ..
330 } => {
331 clear
332 .iter()
333 .filter(|n| !carry.contains(n))
334 .for_each(|name| {
335 window
336 .get_region_mut(name)
337 .into_iter()
338 .for_each(|r| r.clear());
339 });
340 window.current_tokens = window.calculate_tokens();
341 compact
342 .iter()
343 .filter(|n| !carry.contains(n))
344 .filter(|n| {
345 // The region-level flag wins over an explicit list: it is
346 // there so a deliverable is protected wherever it is used,
347 // rather than at each of the N edges that might touch it.
348 // Said out loud, because refusing an explicit instruction
349 // silently is the thing this issue is about.
350 match window.get_region(n) {
351 Some(r) if !r.summarizable => {
352 tracing::warn!(
353 region = %n,
354 "edge asks to compact a region declared \
355 summarizable = false; leaving it as written"
356 );
357 false
358 }
359 Some(r) => !r.content.is_empty(),
360 None => false,
361 }
362 })
363 .cloned()
364 .collect()
365 }
366 }
367}
368
369/// What `dispatch_edge_compact` selects.
370///
371/// `&'static` is bevy's `WorldQuery` convention, not a claim about
372/// lifetimes: the borrow is bound when the query is fetched.
373type EdgeCompactQuery = (
374 Entity,
375 &'static AgentState,
376 &'static ContextWindow,
377 &'static PendingEdgeCompact,
378 Option<&'static CompactionSettings>,
379);
380
381/// Edge-compaction dispatch: for each `ReadyToInfer` agent with a
382/// [`PendingEdgeCompact`] (an edge transform requested LLM summarization), spawn a
383/// compaction job for the named regions (reusing the compaction lane) and hold the
384/// agent `AwaitingCompaction`. If the agent has no compaction config, nothing to
385/// summarize, or no provider/permit, the request is dropped and the agent proceeds
386/// to inference un-compacted (memory-pressure compaction still applies later).
387pub fn dispatch_edge_compact(
388 mut agents: Query<EdgeCompactQuery, (With<ReadyToInfer>, Without<AwaitingCompaction>)>,
389 stage: Res<InferenceStage>,
390 providers: Res<Providers>,
391 mut commands: Commands,
392) {
393 crate::tick_scope::clear();
394 for (entity, state, window, pending, settings) in agents.iter_mut() {
395 crate::tick_scope::enter(entity);
396 if state.status != AgentStatus::Active {
397 continue; // paused / waiting / cancelled - don't start new work
398 }
399 // Each way this can decline says which one it was. A declared
400 // transform that quietly does nothing is a transform that behaves
401 // differently on different runs of the same blueprint, with no signal
402 // either way - and the un-compacted run looks identical to a compacted
403 // one from outside (#369).
404 let started = settings
405 .and_then(|s| {
406 let config = &s.0;
407 let requests = build_edge_compact_requests(window, &pending.0, config)?;
408 let Some(provider) = providers.0.get(&config.provider) else {
409 tracing::warn!(
410 provider = %config.provider,
411 regions = ?pending.0,
412 "edge transform asked to compact, but its compaction provider is \
413 not registered; carrying the regions as written"
414 );
415 return None;
416 };
417 let Some(permit) = stage.pools.try_acquire(&config.model) else {
418 tracing::warn!(
419 model = %config.model,
420 regions = ?pending.0,
421 "edge transform asked to compact, but the compaction pool is full; \
422 carrying the regions as written"
423 );
424 return None;
425 };
426 spawn_supervised_compaction(
427 &stage,
428 entity,
429 CompactionJob {
430 entity,
431 provider,
432 provider_name: config.provider.clone(),
433 model: config.model.clone(),
434 requests,
435 permit,
436 },
437 );
438 Some(())
439 })
440 .is_some();
441
442 let mut ec = commands.entity(entity);
443 ec.remove::<PendingEdgeCompact>();
444 if started {
445 ec.remove::<ReadyToInfer>().insert(AwaitingCompaction);
446 }
447 }
448}
449
450/// Build the per-region summarize requests for an edge compaction, or `None` when
451/// none of the named regions have content to summarize.
452pub(crate) fn build_edge_compact_requests(
453 window: &ContextWindow,
454 regions: &[String],
455 config: &leviath_core::CompactionConfig,
456) -> Option<Vec<(String, InferenceRequest)>> {
457 let requests: Vec<(String, InferenceRequest)> = regions
458 .iter()
459 .filter_map(|name| {
460 let region = window.get_region(name)?;
461 let content = region
462 .content
463 .iter()
464 .map(|e| e.content.as_str())
465 .collect::<Vec<_>>()
466 .join("\n\n");
467 (!content.is_empty())
468 .then(|| (name.clone(), compaction_request(config, &content, name)))
469 })
470 .collect();
471 (!requests.is_empty()).then_some(requests)
472}