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