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 });
52 lost_wake.notify_one();
53 },
54 );
55}
56
57/// What `dispatch_compaction` selects.
58///
59/// `&'static` is bevy's `WorldQuery` convention, not a claim about
60/// lifetimes: the borrow is bound when the query is fetched.
61type CompactionQuery = (
62 Entity,
63 &'static AgentState,
64 &'static mut ContextWindow,
65 &'static CompactionSettings,
66);
67
68/// Compaction-dispatch system: for each `ReadyToInfer` agent with
69/// [`CompactionSettings`] whose window is over the eviction threshold, do the
70/// synchronous eviction inline; if that surfaces regions needing LLM
71/// summarization (and content to summarize), build one request per region,
72/// acquire a permit for the compaction model, spawn the job, and hold the agent
73/// as `AwaitingCompaction`. Anything that can't proceed (under threshold, nothing
74/// to summarize, provider missing, pool full) simply leaves the agent
75/// `ReadyToInfer` so inference proceeds - compaction is best-effort. (Ported from
76/// `AgentEngine::evict_and_compact`.)
77pub fn dispatch_compaction(
78 mut agents: Query<CompactionQuery, (With<ReadyToInfer>, Without<AwaitingCompaction>)>,
79 stage: Res<InferenceStage>,
80 providers: Res<Providers>,
81 mut commands: Commands,
82) {
83 crate::tick_scope::clear();
84 for (entity, state, mut window, settings) in agents.iter_mut() {
85 crate::tick_scope::enter(entity);
86 if state.status != AgentStatus::Active {
87 continue; // paused / waiting / cancelled - don't start new work
88 }
89 if !window.needs_eviction(EVICTION_THRESHOLD) {
90 continue; // under threshold - nothing to do
91 }
92 let target_free = window.max_tokens / 10;
93 let Ok(eviction) = window.try_evict(target_free) else {
94 continue; // couldn't evict - proceed to inference as-is
95 };
96
97 // Build a summarize request per region that both needs compaction and
98 // has content to summarize.
99 let config = &settings.0;
100 let mut requests = Vec::new();
101 for region_name in &eviction.needs_compaction {
102 // The names come from `try_evict`'s own scan of `window.regions`, and
103 // nothing between there and here mutates the region set, so the region
104 // is guaranteed present.
105 let region = window
106 .get_region(region_name)
107 .expect("needs_compaction region present: named by try_evict's own scan");
108 let content: String = region
109 .content
110 .iter()
111 .map(|e| e.content.as_str())
112 .collect::<Vec<_>>()
113 .join("\n\n");
114 if content.is_empty() {
115 continue; // nothing to summarize (e.g. token-only placeholder)
116 }
117 requests.push((
118 region_name.clone(),
119 compaction_request(config, &content, region_name),
120 ));
121 }
122 if requests.is_empty() {
123 continue; // sync eviction was enough (or nothing summarizable)
124 }
125
126 let Some(provider) = providers.0.get(&config.provider) else {
127 continue; // compaction provider not registered - skip, non-fatal
128 };
129 let Some(permit) = stage.pools.try_acquire(&config.model) else {
130 continue; // pool full - skip compaction this round
131 };
132
133 spawn_supervised_compaction(
134 &stage,
135 entity,
136 CompactionJob {
137 entity,
138 provider,
139 requests,
140 permit,
141 },
142 );
143 commands
144 .entity(entity)
145 .remove::<ReadyToInfer>()
146 .insert(AwaitingCompaction);
147 }
148}
149
150/// Compaction-collect system: drain finished compaction jobs and apply each
151/// summary into its paired `CompactHistory` region, clearing the summarized
152/// source region. A provider error leaves the context untouched (best-effort).
153/// Either way the agent returns to `ReadyToInfer`. (Ported from the storage tail
154/// of `AgentEngine::compact_region`.)
155pub fn collect_compaction(
156 mut results: ResMut<CompactionResults>,
157 mut agents: Query<
158 (
159 &mut ContextWindow,
160 Option<&mut crate::telemetry::StageActivity>,
161 ),
162 With<AwaitingCompaction>,
163 >,
164 mut commands: Commands,
165) {
166 crate::tick_scope::clear();
167 while let Ok(outcome) = results.0.try_recv() {
168 let Ok((mut window, activity)) = agents.get_mut(outcome.entity) else {
169 continue; // stale: agent cancelled/despawned since dispatch
170 };
171 crate::tick_scope::enter(outcome.entity);
172 if let Some(mut activity) = activity {
173 activity
174 .0
175 .push(crate::telemetry::ActivityRecord::Compaction {
176 success: outcome.result.is_ok(),
177 });
178 }
179 if let Ok(summaries) = outcome.result {
180 for (region_name, summary) in summaries {
181 let summary_tokens = leviath_core::estimate_tokens(&summary);
182 let history = window
183 .regions
184 .iter()
185 .find(|r| {
186 matches!(&r.kind, leviath_core::RegionKind::CompactHistory { source_region }
187 if source_region == ®ion_name)
188 })
189 .map(|r| r.name.clone());
190 if let Some(history_name) = history {
191 let _ = window.add_to_region(&history_name, summary, summary_tokens);
192 }
193 if let Some(region) = window.get_region_mut(®ion_name) {
194 region.clear();
195 }
196 }
197 window.current_tokens = window.calculate_tokens();
198 }
199 commands
200 .entity(outcome.entity)
201 .remove::<AwaitingCompaction>()
202 .insert(ReadyToInfer);
203 }
204}
205
206/// Build the summarize [`InferenceRequest`] for one region's content.
207pub(crate) fn compaction_request(
208 config: &leviath_core::CompactionConfig,
209 content: &str,
210 region_name: &str,
211) -> InferenceRequest {
212 InferenceRequest {
213 system: vec![],
214 messages: vec![
215 leviath_providers::Message {
216 role: "system".to_string(),
217 content: config.system_prompt().to_string().into(),
218 cache_breakpoint: false,
219 },
220 leviath_providers::Message {
221 role: "user".to_string(),
222 content: config.user_prompt(content, region_name).into(),
223 cache_breakpoint: false,
224 },
225 ],
226 model: config.model.clone(),
227 max_tokens: config.max_summary_tokens,
228 temperature: config.temperature,
229 tools: Vec::new(),
230 extra: serde_json::Value::Null,
231 request_timeout_secs: None,
232 }
233}
234
235// ─── Edge transforms (context reshaping on stage transitions) ────────────────
236
237/// Regions an edge transform asked to LLM-compact after a transition, awaiting
238/// the compaction lane (drained by [`dispatch_edge_compact`]).
239#[derive(Component, Debug, Clone)]
240pub struct PendingEdgeCompact(pub Vec<String>);
241
242/// Whether a region kind is "stage-specific" - eligible for an edge transform to
243/// clear or compact. The always-preserved kinds (pinned identity, compaction
244/// history, hashmap stores, persistent custom regions) are never touched.
245pub fn is_stage_specific(kind: &leviath_core::RegionKind) -> bool {
246 !matches!(
247 kind,
248 leviath_core::RegionKind::Pinned
249 | leviath_core::RegionKind::CompactHistory { .. }
250 | leviath_core::RegionKind::HashMap { .. }
251 | leviath_core::RegionKind::Custom {
252 persistent: true,
253 ..
254 }
255 )
256}
257
258/// Apply an edge transform's **synchronous** effects to the outgoing window
259/// (clearing stage-specific / named regions) and return the names of regions the
260/// caller should hand to the LLM compaction lane. (Ported from the deleted
261/// `graph::apply_edge_transform`; `Direct` on a linear/chosen edge carries context
262/// as-is.)
263pub(crate) fn apply_edge_transform(
264 window: &mut ContextWindow,
265 transform: &leviath_core::blueprint::EdgeTransform,
266) -> Vec<String> {
267 use leviath_core::blueprint::EdgeTransform;
268 match transform {
269 EdgeTransform::Direct => Vec::new(),
270 EdgeTransform::Clear => {
271 window
272 .regions
273 .iter_mut()
274 .filter(|r| is_stage_specific(&r.kind))
275 .for_each(|r| r.clear());
276 window.current_tokens = window.calculate_tokens();
277 Vec::new()
278 }
279 // Kind cannot tell a transcript from a table of results, so a region
280 // whose author said its content does not survive a paraphrase is left
281 // alone however the edge is spelled (#369).
282 EdgeTransform::Compact { .. } => window
283 .regions
284 .iter()
285 .filter(|r| is_stage_specific(&r.kind) && r.summarizable && !r.content.is_empty())
286 .map(|r| r.name.clone())
287 .collect(),
288 EdgeTransform::Custom {
289 carry,
290 compact,
291 clear,
292 ..
293 } => {
294 clear
295 .iter()
296 .filter(|n| !carry.contains(n))
297 .for_each(|name| {
298 window
299 .get_region_mut(name)
300 .into_iter()
301 .for_each(|r| r.clear());
302 });
303 window.current_tokens = window.calculate_tokens();
304 compact
305 .iter()
306 .filter(|n| !carry.contains(n))
307 .filter(|n| {
308 // The region-level flag wins over an explicit list: it is
309 // there so a deliverable is protected wherever it is used,
310 // rather than at each of the N edges that might touch it.
311 // Said out loud, because refusing an explicit instruction
312 // silently is the thing this issue is about.
313 match window.get_region(n) {
314 Some(r) if !r.summarizable => {
315 tracing::warn!(
316 region = %n,
317 "edge asks to compact a region declared \
318 summarizable = false; leaving it as written"
319 );
320 false
321 }
322 Some(r) => !r.content.is_empty(),
323 None => false,
324 }
325 })
326 .cloned()
327 .collect()
328 }
329 }
330}
331
332/// What `dispatch_edge_compact` selects.
333///
334/// `&'static` is bevy's `WorldQuery` convention, not a claim about
335/// lifetimes: the borrow is bound when the query is fetched.
336type EdgeCompactQuery = (
337 Entity,
338 &'static AgentState,
339 &'static ContextWindow,
340 &'static PendingEdgeCompact,
341 Option<&'static CompactionSettings>,
342);
343
344/// Edge-compaction dispatch: for each `ReadyToInfer` agent with a
345/// [`PendingEdgeCompact`] (an edge transform requested LLM summarization), spawn a
346/// compaction job for the named regions (reusing the compaction lane) and hold the
347/// agent `AwaitingCompaction`. If the agent has no compaction config, nothing to
348/// summarize, or no provider/permit, the request is dropped and the agent proceeds
349/// to inference un-compacted (memory-pressure compaction still applies later).
350pub fn dispatch_edge_compact(
351 mut agents: Query<EdgeCompactQuery, (With<ReadyToInfer>, Without<AwaitingCompaction>)>,
352 stage: Res<InferenceStage>,
353 providers: Res<Providers>,
354 mut commands: Commands,
355) {
356 crate::tick_scope::clear();
357 for (entity, state, window, pending, settings) in agents.iter_mut() {
358 crate::tick_scope::enter(entity);
359 if state.status != AgentStatus::Active {
360 continue; // paused / waiting / cancelled - don't start new work
361 }
362 // Each way this can decline says which one it was. A declared
363 // transform that quietly does nothing is a transform that behaves
364 // differently on different runs of the same blueprint, with no signal
365 // either way - and the un-compacted run looks identical to a compacted
366 // one from outside (#369).
367 let started = settings
368 .and_then(|s| {
369 let config = &s.0;
370 let requests = build_edge_compact_requests(window, &pending.0, config)?;
371 let Some(provider) = providers.0.get(&config.provider) else {
372 tracing::warn!(
373 provider = %config.provider,
374 regions = ?pending.0,
375 "edge transform asked to compact, but its compaction provider is \
376 not registered; carrying the regions as written"
377 );
378 return None;
379 };
380 let Some(permit) = stage.pools.try_acquire(&config.model) else {
381 tracing::warn!(
382 model = %config.model,
383 regions = ?pending.0,
384 "edge transform asked to compact, but the compaction pool is full; \
385 carrying the regions as written"
386 );
387 return None;
388 };
389 spawn_supervised_compaction(
390 &stage,
391 entity,
392 CompactionJob {
393 entity,
394 provider,
395 requests,
396 permit,
397 },
398 );
399 Some(())
400 })
401 .is_some();
402
403 let mut ec = commands.entity(entity);
404 ec.remove::<PendingEdgeCompact>();
405 if started {
406 ec.remove::<ReadyToInfer>().insert(AwaitingCompaction);
407 }
408 }
409}
410
411/// Build the per-region summarize requests for an edge compaction, or `None` when
412/// none of the named regions have content to summarize.
413pub(crate) fn build_edge_compact_requests(
414 window: &ContextWindow,
415 regions: &[String],
416 config: &leviath_core::CompactionConfig,
417) -> Option<Vec<(String, InferenceRequest)>> {
418 let requests: Vec<(String, InferenceRequest)> = regions
419 .iter()
420 .filter_map(|name| {
421 let region = window.get_region(name)?;
422 let content = region
423 .content
424 .iter()
425 .map(|e| e.content.as_str())
426 .collect::<Vec<_>>()
427 .join("\n\n");
428 (!content.is_empty())
429 .then(|| (name.clone(), compaction_request(config, &content, name)))
430 })
431 .collect();
432 (!requests.is_empty()).then_some(requests)
433}