Skip to main content

oxios_kernel/
compression.rs

1//! Context compression — LLM-generated session summaries (LobeHub port).
2//!
3//! When a session exceeds a message threshold, older exchanges are summarized
4//! by an LLM and stored in `session.metadata["compression"]`. The summary
5//! streams via `KernelEvent::CompressionDelta` for real-time UI rendering.
6
7use std::collections::HashSet;
8use std::sync::Arc;
9
10use anyhow::{Context, Result};
11use futures::StreamExt;
12use parking_lot::Mutex;
13use serde_json::json;
14
15use crate::config::OxiosConfig;
16use crate::engine::EngineHandle;
17use crate::event_bus::{EventBus, KernelEvent};
18use crate::state_store::{Session, StateStore};
19
20/// Messages above this count trigger compression.
21const COLLAPSE_THRESHOLD: usize = 40;
22/// Recent messages never compressed (kept as raw context).
23const VISIBLE_TAIL: usize = 20;
24
25/// System prompt for the compression LLM call (ported from LobeHub).
26const COMPRESSION_SYSTEM_PROMPT: &str = r#"You are a conversation context compressor. Your task is to create a structured summary that preserves essential information while significantly reducing token count.
27
28## Output Format
29
30Structure your summary using these sections (omit empty sections):
31
32### Context
33Brief background and conversation setup (1-2 sentences max)
34
35### Key Information
36- Critical facts, data, specifications mentioned
37- Technical details, configurations, parameters
38- Names, identifiers, file paths, URLs
39
40### Decisions & Conclusions
41- Decisions made during the conversation
42- Agreed-upon solutions or approaches
43- Final conclusions reached
44
45### Action Items
46- Tasks assigned or planned
47- Next steps discussed
48- Pending items requiring follow-up
49
50### Code & Technical
51```
52Preserve essential code snippets, commands, or technical syntax
53```
54
55## Rules
56
57### MUST
58- Output in the SAME LANGUAGE as the conversation
59- Preserve ALL technical terms, code identifiers, file paths, and proper nouns exactly
60- Maintain factual accuracy - never invent or assume information
61- Keep code snippets that are essential for context
62
63### SHOULD
64- Achieve 60-80% compression ratio (summary should be 20-40% of original length)
65- Use bullet points for clarity and scannability
66- Preserve chronological order for sequential events
67- Consolidate repeated information into single entries
68
69### MAY
70- Omit greetings, pleasantries, and filler content
71- Combine related points into concise statements
72- Abbreviate obvious context when meaning is preserved
73
74## Important Notes
75
76- The summary will be injected into a new conversation as context
77- Recipient should be able to continue the conversation seamlessly
78- Prioritize information that affects future responses"#;
79
80/// LLM-generated session summary service.
81pub struct CompressionService {
82    state_store: Arc<StateStore>,
83    engine_handle: Arc<EngineHandle>,
84    config: OxiosConfig,
85    event_bus: EventBus,
86    /// Sessions currently being compressed (prevents concurrent runs).
87    active: Mutex<HashSet<String>>,
88}
89
90impl CompressionService {
91    /// Create a new CompressionService.
92    pub fn new(
93        state_store: Arc<StateStore>,
94        engine_handle: Arc<EngineHandle>,
95        config: OxiosConfig,
96        event_bus: EventBus,
97    ) -> Self {
98        Self {
99            state_store,
100            engine_handle,
101            config,
102            event_bus,
103            active: Mutex::new(HashSet::new()),
104        }
105    }
106
107    /// Whether a session should be compressed (delegates to free function).
108    pub fn should_compress(&self, session: &Session) -> bool {
109        session_needs_compression(session)
110    }
111
112    /// Spawn compression in the background. No-op if already running.
113    pub fn spawn_compress(self: &Arc<Self>, session_id: String) {
114        {
115            let mut active = self.active.lock();
116            if !active.insert(session_id.clone()) {
117                return; // already running
118            }
119        }
120        let this = Arc::clone(self);
121        tokio::spawn(async move {
122            let result = this.compress(&session_id).await;
123            this.active.lock().remove(&session_id);
124            if let Err(e) = result {
125                tracing::warn!(session_id = %session_id, error = %e, "Compression failed");
126            }
127        });
128    }
129
130    /// Run compression for a session: build prompt, stream LLM, persist.
131    pub async fn compress(&self, session_id: &str) -> Result<()> {
132        let sid = crate::state_store::SessionId(session_id.to_string());
133        let session = self
134            .state_store
135            .load_session(&sid)
136            .await
137            .context("load session")?
138            .context("session not found")?;
139
140        let count = session.exchange_count();
141        let range_end = count.saturating_sub(VISIBLE_TAIL);
142        if range_end == 0 {
143            return Ok(());
144        }
145
146        // Determine incremental start.
147        let existing_summary = session
148            .metadata
149            .get("compression")
150            .filter(|c| c.get("status").and_then(|s| s.as_str()) == Some("done"))
151            .and_then(|c| c.get("summary").and_then(|s| s.as_str()).map(String::from));
152        let start_index = session
153            .metadata
154            .get("compression")
155            .and_then(|c| c.get("compressed_before_index").and_then(|v| v.as_u64()))
156            .unwrap_or(0) as usize;
157
158        // Set generating status.
159        self.state_store
160            .update_session_with(&sid, |s| {
161                s.metadata.insert(
162                    "compression".to_string(),
163                    json!({
164                        "status": "generating",
165                        "summary": existing_summary.as_deref().unwrap_or(""),
166                        "compressed_before_index": start_index,
167                    }),
168                );
169                Ok(())
170            })
171            .await
172            .context("set generating status")?;
173
174        // Build user prompt.
175        let user_prompt = build_compression_prompt(
176            &session,
177            start_index,
178            range_end,
179            existing_summary.as_deref(),
180        );
181
182        // Resolve model.
183        let resolved = {
184            let model_id = self.config.system_agents.model_for_task("history_compress");
185            match model_id {
186                Some(id) => self.engine_handle.resolve(&id),
187                None => self.engine_handle.resolve_default(),
188            }
189        }
190        .context("resolve compression model")?;
191
192        // Build context and stream.
193        let mut ctx = oxi_sdk::Context::new();
194        ctx.set_system_prompt(COMPRESSION_SYSTEM_PROMPT);
195        ctx.add_message(oxi_sdk::Message::User(oxi_sdk::UserMessage::new(
196            user_prompt,
197        )));
198
199        let stream = resolved
200            .provider
201            .stream(&resolved.model, &ctx, None)
202            .await
203            .context("start compression stream")?;
204
205        let mut summary = String::new();
206        let mut pinned = std::pin::pin!(stream);
207        while let Some(event) = pinned.next().await {
208            match event {
209                oxi_sdk::ProviderEvent::TextDelta { delta, .. } => {
210                    summary.push_str(&delta);
211                    let _ = self.event_bus.publish(KernelEvent::CompressionDelta {
212                        session_id: session_id.to_string(),
213                        delta,
214                    });
215                }
216                oxi_sdk::ProviderEvent::Done { .. } => break,
217                oxi_sdk::ProviderEvent::Error { error, .. } => {
218                    let err_msg = format!("{error:?}");
219                    self.state_store
220                        .update_session_with(&sid, |s| {
221                            s.metadata.insert(
222                                "compression".to_string(),
223                                json!({
224                                    "status": "failed",
225                                    "error": err_msg,
226                                    "compressed_before_index": start_index,
227                                }),
228                            );
229                            Ok(())
230                        })
231                        .await
232                        .ok();
233                    let _ = self.event_bus.publish(KernelEvent::CompressionFailed {
234                        session_id: session_id.to_string(),
235                        error: err_msg,
236                    });
237                    anyhow::bail!("compression stream error");
238                }
239                _ => {}
240            }
241        }
242
243        // Persist final summary.
244        self.state_store
245            .update_session_with(&sid, |s| {
246                s.metadata.insert(
247                    "compression".to_string(),
248                    json!({
249                        "status": "done",
250                        "summary": summary,
251                        "compressed_at": chrono::Utc::now().to_rfc3339(),
252                        "original_count": count,
253                        "compressed_before_index": range_end,
254                        "model": resolved.model_id,
255                    }),
256                );
257                Ok(())
258            })
259            .await
260            .context("persist compression summary")?;
261
262        let _ = self.event_bus.publish(KernelEvent::CompressionDone {
263            session_id: session_id.to_string(),
264        });
265
266        tracing::info!(
267            session_id = %session_id,
268            new_messages = range_end.saturating_sub(start_index),
269            summary_len = summary.len(),
270            "Session compressed"
271        );
272        Ok(())
273    }
274}
275
276/// Pure predicate: does this session need compression? Extracted as a free
277/// function so it can be unit-tested without constructing a full service.
278pub fn session_needs_compression(session: &Session) -> bool {
279    let count = session.exchange_count();
280    if count < COLLAPSE_THRESHOLD {
281        return false;
282    }
283    let range_end = count.saturating_sub(VISIBLE_TAIL);
284    if let Some(comp) = session.metadata.get("compression")
285        && comp.get("status").and_then(|s| s.as_str()) == Some("done")
286    {
287        let covered = comp
288            .get("compressed_before_index")
289            .and_then(|v| v.as_u64())
290            .unwrap_or(0) as usize;
291        if covered >= range_end {
292            return false;
293        }
294    }
295    true
296}
297
298/// Build the user prompt for compression, formatting exchanges as
299/// `[User]: ...` / `[Assistant]: ...` wrapped in `<chat_history>` XML.
300fn build_compression_prompt(
301    session: &Session,
302    start: usize,
303    end: usize,
304    existing_summary: Option<&str>,
305) -> String {
306    let mut prompt = String::new();
307
308    if let Some(prev) = existing_summary {
309        prompt.push_str("<existing_summary>\n");
310        prompt.push_str(prev);
311        prompt.push_str("\n</existing_summary>\n\n");
312    }
313
314    prompt.push_str("<chat_history>\n");
315    for i in start..end {
316        if let Some(um) = session.user_messages.get(i) {
317            prompt.push_str(&format!("[User]: {}\n", um.content));
318        }
319        if let Some(ar) = session.agent_responses.get(i) {
320            prompt.push_str(&format!("[Assistant]: {}\n", ar.content));
321        }
322    }
323    prompt.push_str("</chat_history>\n\n");
324    prompt.push_str(
325        "Please compress the above conversation history.\n\
326         Output ONLY the structured summary following the format specified. \
327         No additional commentary or meta-discussion.",
328    );
329    prompt
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use crate::state_store::{AgentResponse, UserMessage};
336
337    fn make_session(exchanges: usize) -> Session {
338        let mut s = Session::new("test");
339        for i in 0..exchanges {
340            s.user_messages.push(UserMessage {
341                content: format!("question {i}"),
342                timestamp: chrono::Utc::now(),
343            });
344            s.agent_responses.push(AgentResponse {
345                content: format!("answer {i}"),
346                session_id: None,
347                phase_reached: None,
348                evaluation_passed: None,
349                timestamp: chrono::Utc::now(),
350                trajectory_range: None,
351            });
352        }
353        s
354    }
355
356    #[test]
357    fn needs_compression_below_threshold() {
358        let session = make_session(39);
359        assert!(!session_needs_compression(&session));
360    }
361
362    #[test]
363    fn needs_compression_at_threshold() {
364        let session = make_session(40);
365        assert!(session_needs_compression(&session));
366    }
367
368    #[test]
369    fn no_compression_when_already_covered() {
370        let mut session = make_session(45);
371        // range_end = 45 - 20 = 25. Mark as covered.
372        session.metadata.insert(
373            "compression".to_string(),
374            json!({ "status": "done", "compressed_before_index": 25 }),
375        );
376        assert!(!session_needs_compression(&session));
377    }
378
379    #[test]
380    fn recompress_when_new_messages_exist() {
381        let mut session = make_session(50);
382        // Previously compressed only up to 20, but range_end = 50 - 20 = 30.
383        // So there are new messages to compress (20..30).
384        session.metadata.insert(
385            "compression".to_string(),
386            json!({ "status": "done", "compressed_before_index": 20 }),
387        );
388        assert!(session_needs_compression(&session));
389    }
390
391    #[test]
392    fn build_prompt_includes_existing_summary() {
393        let session = make_session(5);
394        let prompt = build_compression_prompt(&session, 0, 3, Some("prev summary"));
395        assert!(prompt.contains("<existing_summary>"));
396        assert!(prompt.contains("prev summary"));
397        assert!(prompt.contains("[User]: question 0"));
398        assert!(prompt.contains("[Assistant]: answer 2"));
399        assert!(!prompt.contains("question 3")); // end is exclusive
400    }
401}