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
27#[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; }
52 if !window.needs_eviction(EVICTION_THRESHOLD) {
53 continue; }
55 let target_free = window.max_tokens / 10;
56 let Ok(eviction) = window.try_evict(target_free) else {
57 continue; };
59
60 let config = &settings.0;
63 let mut requests = Vec::new();
64 for region_name in &eviction.needs_compaction {
65 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; }
80 requests.push((
81 region_name.clone(),
82 compaction_request(config, &content, region_name),
83 ));
84 }
85 if requests.is_empty() {
86 continue; }
88
89 let Some(provider) = providers.0.get(&config.provider) else {
90 continue; };
92 let Some(permit) = stage.pools.try_acquire(&config.model) else {
93 continue; };
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
114pub 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; };
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 == ®ion_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(®ion_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
170pub(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#[derive(Component, Debug, Clone)]
204pub struct PendingEdgeCompact(pub Vec<String>);
205
206pub(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
222pub(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#[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; }
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
334pub(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}