Skip to main content

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 == &region_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(&region_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}