1use 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
20const COLLAPSE_THRESHOLD: usize = 40;
22const VISIBLE_TAIL: usize = 20;
24
25const 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
80pub struct CompressionService {
82 state_store: Arc<StateStore>,
83 engine_handle: Arc<EngineHandle>,
84 config: OxiosConfig,
85 event_bus: EventBus,
86 active: Mutex<HashSet<String>>,
88}
89
90impl CompressionService {
91 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 pub fn should_compress(&self, session: &Session) -> bool {
109 session_needs_compression(session)
110 }
111
112 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; }
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 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 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 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 let user_prompt = build_compression_prompt(
176 &session,
177 start_index,
178 range_end,
179 existing_summary.as_deref(),
180 );
181
182 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 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 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
276pub 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
298fn 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 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 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")); }
401}