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