leviath_runtime/pipeline/
compaction.rs1use super::*;
4
5#[derive(Component, Clone)]
11pub struct CompactionSettings(pub leviath_core::CompactionConfig);
12
13#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
16pub struct AwaitingCompaction;
17
18#[derive(Resource)]
21pub struct CompactionResults(pub UnboundedReceiver<CompactionOutcome>);
22
23pub(crate) const EVICTION_THRESHOLD: f32 = 0.9;
26
27fn 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#[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; }
82 if !window.needs_eviction(EVICTION_THRESHOLD) {
83 continue; }
85 let target_free = window.max_tokens / 10;
86 let Ok(eviction) = window.try_evict(target_free) else {
87 continue; };
89
90 let config = &settings.0;
93 let mut requests = Vec::new();
94 for region_name in &eviction.needs_compaction {
95 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; }
110 requests.push((
111 region_name.clone(),
112 compaction_request(config, &content, region_name),
113 ));
114 }
115 if requests.is_empty() {
116 continue; }
118
119 let Some(provider) = providers.0.get(&config.provider) else {
120 continue; };
122 let Some(permit) = stage.pools.try_acquire(&config.model) else {
123 continue; };
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
143pub 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; };
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 == ®ion_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(®ion_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
199pub(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#[derive(Component, Debug, Clone)]
233pub struct PendingEdgeCompact(pub Vec<String>);
234
235pub(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
251pub(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#[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; }
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
360pub(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}