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