1use crate::context_analysis;
14use crate::db::Database;
15use crate::inference_helpers::CHARS_PER_TOKEN;
16use crate::persistence::{Message, Persistence, Role};
17use anyhow::Result;
18use std::collections::HashMap;
19
20pub const CLEARED_MESSAGE: &str = "[Old tool result content cleared]";
22
23const COMPACTABLE_TOOLS: &[&str] = &[
25 "Read",
26 "read",
27 "Bash",
28 "bash",
29 "Grep",
30 "grep",
31 "Glob",
32 "glob",
33 "ListFiles",
34 "list_files",
35 "WebSearch",
36 "web_search",
37 "WebFetch",
38 "web_fetch",
39];
40
41const KEEP_RECENT: usize = 5;
46
47const GAP_THRESHOLD_SECS: i64 = 300;
55
56const MIN_TOKENS_TO_CLEAR: usize = 50;
59
60#[derive(Debug, Clone)]
62pub struct MicrocompactResult {
63 pub cleared: usize,
65 pub tokens_saved: usize,
67}
68
69pub async fn microcompact_session(
75 db: &Database,
76 session_id: &str,
77) -> Result<Option<MicrocompactResult>> {
78 let gap = db.seconds_since_last_assistant(session_id).await?;
81 match gap {
82 None => return Ok(None), Some(s) if s < GAP_THRESHOLD_SECS => return Ok(None),
84 _ => {} }
86
87 let history = db.load_context(session_id).await?;
88 if history.len() < KEEP_RECENT + 2 {
89 return Ok(None);
90 }
91
92 let id_to_tool = build_tool_id_map(&history);
94
95 let compactable: Vec<CompactableResult> = history
97 .iter()
98 .filter_map(|msg| {
99 if msg.role != Role::Tool {
100 return None;
101 }
102 let tool_call_id = msg.tool_call_id.as_deref()?;
103 let tool_name = id_to_tool.get(tool_call_id)?;
104 if !is_compactable(tool_name) {
105 return None;
106 }
107 let content = msg.content.as_deref().unwrap_or("");
109 if content == CLEARED_MESSAGE {
110 return None;
111 }
112 let tokens = estimate_tokens(content);
113 if tokens < MIN_TOKENS_TO_CLEAR {
114 return None;
115 }
116 Some(CompactableResult {
117 message_id: msg.id,
118 tokens,
119 })
120 })
121 .collect();
122
123 if compactable.len() <= KEEP_RECENT {
124 return Ok(None);
125 }
126
127 let to_clear = &compactable[..compactable.len() - KEEP_RECENT];
129
130 let mut tokens_saved = 0usize;
131 let mut cleared = 0usize;
132
133 for batch in to_clear.chunks(100) {
134 let ids: Vec<i64> = batch.iter().map(|c| c.message_id).collect();
135 db.clear_message_content(&ids, CLEARED_MESSAGE).await?;
136 tokens_saved += batch.iter().map(|c| c.tokens).sum::<usize>();
137 cleared += batch.len();
138 }
139
140 if cleared == 0 {
141 return Ok(None);
142 }
143
144 tracing::info!("Microcompact: cleared {cleared} tool results, saved ~{tokens_saved} tokens");
145
146 Ok(Some(MicrocompactResult {
147 cleared,
148 tokens_saved,
149 }))
150}
151
152pub fn diagnosis(messages: &[Message]) -> Option<String> {
156 let analysis = context_analysis::analyze_context(messages);
157 let top = analysis.top_tool_results(3);
158 if top.is_empty() || analysis.total_tool_result_tokens() < 500 {
159 return None;
160 }
161
162 let parts: Vec<String> = top
163 .iter()
164 .filter(|(name, _)| is_compactable(name))
165 .map(|(name, tokens)| format!("{name}: ~{tokens} tok"))
166 .collect();
167
168 if parts.is_empty() {
169 return None;
170 }
171
172 Some(parts.join(", "))
173}
174
175struct CompactableResult {
180 message_id: i64,
181 tokens: usize,
182}
183
184fn is_compactable(tool_name: &str) -> bool {
185 COMPACTABLE_TOOLS.contains(&tool_name)
186}
187
188fn estimate_tokens(content: &str) -> usize {
189 (content.len() as f64 / CHARS_PER_TOKEN) as usize
190}
191
192fn build_tool_id_map(messages: &[Message]) -> HashMap<String, String> {
194 let mut map = HashMap::new();
195 for msg in messages {
196 if msg.role == Role::Assistant
197 && let Some(ref tc_json) = msg.tool_calls
198 && let Ok(calls) = serde_json::from_str::<Vec<serde_json::Value>>(tc_json)
199 {
200 for call in &calls {
201 let id = call.get("id").and_then(|v| v.as_str()).unwrap_or_default();
202 let name = call
203 .get("function_name")
204 .or_else(|| call.get("name"))
205 .and_then(|v| v.as_str())
206 .unwrap_or("unknown");
207 if !id.is_empty() {
208 map.insert(id.to_string(), name.to_string());
209 }
210 }
211 }
212 }
213 map
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use crate::persistence::{Message, Role};
220
221 fn msg(
222 id: i64,
223 role: Role,
224 content: Option<&str>,
225 tool_calls: Option<&str>,
226 tool_call_id: Option<&str>,
227 ) -> Message {
228 Message {
229 id,
230 session_id: String::new(),
231 role,
232 content: content.map(String::from),
233 full_content: None,
234 tool_calls: tool_calls.map(String::from),
235 tool_call_id: tool_call_id.map(String::from),
236 prompt_tokens: None,
237 completion_tokens: None,
238 cache_read_tokens: None,
239 cache_creation_tokens: None,
240 thinking_tokens: None,
241 thinking_content: None,
242 created_at: None,
243 }
244 }
245
246 #[test]
247 fn test_is_compactable() {
248 assert!(is_compactable("Read"));
249 assert!(is_compactable("Bash"));
250 assert!(is_compactable("Grep"));
251 assert!(is_compactable("Glob"));
252 assert!(is_compactable("WebSearch"));
253 assert!(is_compactable("WebFetch"));
254 assert!(!is_compactable("InvokeAgent"));
255 assert!(!is_compactable("TodoWrite"));
256 assert!(!is_compactable("AskUser"));
257 }
258
259 #[test]
260 fn test_build_tool_id_map() {
261 let tc = r#"[{"id":"tc_1","function_name":"Read","arguments":"{}"},{"id":"tc_2","function_name":"Bash","arguments":"{}"}]"#;
262 let messages = vec![msg(1, Role::Assistant, None, Some(tc), None)];
263 let map = build_tool_id_map(&messages);
264 assert_eq!(map.get("tc_1").unwrap(), "Read");
265 assert_eq!(map.get("tc_2").unwrap(), "Bash");
266 }
267
268 #[test]
269 fn test_already_cleared_skipped() {
270 let tc = r#"[{"id":"tc_1","function_name":"Read","arguments":"{}"}]"#;
271 let messages = vec![
272 msg(1, Role::Assistant, None, Some(tc), None),
273 msg(2, Role::Tool, Some(CLEARED_MESSAGE), None, Some("tc_1")),
274 ];
275 let _id_map = build_tool_id_map(&messages);
276 let compactable: Vec<_> = messages
277 .iter()
278 .filter(|m| m.role == Role::Tool)
279 .filter(|m| {
280 let content = m.content.as_deref().unwrap_or("");
281 content != CLEARED_MESSAGE
282 })
283 .collect();
284 assert!(compactable.is_empty());
285 }
286
287 #[test]
288 fn test_diagnosis_with_results() {
289 let tc1 = r#"[{"id":"tc_1","function_name":"Read","arguments":"{}"}]"#;
290 let tc2 = r#"[{"id":"tc_2","function_name":"Bash","arguments":"{}"}]"#;
291 let long = "x".repeat(2000);
292 let messages = vec![
293 msg(1, Role::User, Some("hi"), None, None),
294 msg(2, Role::Assistant, None, Some(tc1), None),
295 msg(3, Role::Tool, Some(&long), None, Some("tc_1")),
296 msg(4, Role::Assistant, None, Some(tc2), None),
297 msg(5, Role::Tool, Some(&long), None, Some("tc_2")),
298 ];
299 let diag = diagnosis(&messages);
300 assert!(diag.is_some());
301 let text = diag.unwrap();
302 assert!(text.contains("Read") || text.contains("Bash"));
303 }
304
305 #[test]
306 fn test_diagnosis_empty() {
307 let messages = vec![
308 msg(1, Role::User, Some("hi"), None, None),
309 msg(2, Role::Assistant, Some("hello"), None, None),
310 ];
311 assert!(diagnosis(&messages).is_none());
312 }
313
314 #[tokio::test]
317 async fn test_microcompact_session_integration() {
318 let tmp = tempfile::TempDir::new().unwrap();
319 let db_path = tmp.path().join("test.db");
320 let db = crate::db::Database::open(&db_path).await.unwrap();
321 let session = db.create_session("default", tmp.path()).await.unwrap();
322
323 let long_content = "x".repeat(500);
324
325 for i in 0..(KEEP_RECENT + 3) {
327 let tc_id = format!("tc_{i}");
328 let tc_json =
329 format!(r#"[{{"id":"{tc_id}","function_name":"Read","arguments":"{{}}"}}]"#);
330 let mid = db
331 .insert_message(&session, &Role::Assistant, None, Some(&tc_json), None, None)
332 .await
333 .unwrap();
334 db.mark_message_complete(mid).await.unwrap();
336 db.insert_message(
337 &session,
338 &Role::Tool,
339 Some(&long_content),
340 None,
341 Some(&tc_id),
342 None,
343 )
344 .await
345 .unwrap();
346 }
347
348 let result = microcompact_session(&db, &session).await.unwrap();
350 assert!(result.is_none(), "should not trigger for fresh messages");
351
352 sqlx::query(
354 "UPDATE messages SET created_at = datetime('now', '-10 minutes') \
355 WHERE session_id = ? AND role = 'assistant' \
356 AND id = (SELECT MAX(id) FROM messages WHERE session_id = ? AND role = 'assistant')",
357 )
358 .bind(&session)
359 .bind(&session)
360 .execute(db.pool())
361 .await
362 .unwrap();
363
364 let result = microcompact_session(&db, &session).await.unwrap();
366 assert!(result.is_some(), "should trigger after gap threshold");
367 let mc = result.unwrap();
368 assert_eq!(mc.cleared, 3); assert!(mc.tokens_saved > 0);
370
371 let history = db.load_context(&session).await.unwrap();
373 let tool_msgs: Vec<_> = history.iter().filter(|m| m.role == Role::Tool).collect();
374
375 for m in &tool_msgs[..3] {
377 assert_eq!(m.content.as_deref().unwrap(), CLEARED_MESSAGE);
378 }
379 for m in &tool_msgs[3..] {
381 assert_eq!(m.content.as_deref().unwrap(), long_content);
382 }
383
384 let result2 = microcompact_session(&db, &session).await.unwrap();
386 assert!(result2.is_none());
387 }
388
389 #[test]
392 fn test_estimate_tokens_proportional_to_chars() {
393 let short = estimate_tokens("hello");
394 let long = estimate_tokens(&"x".repeat(400));
395 assert!(long > short, "more chars should estimate more tokens");
396 }
397
398 #[test]
399 fn test_estimate_tokens_empty_string() {
400 assert_eq!(estimate_tokens(""), 0);
401 }
402
403 #[test]
404 fn test_estimate_tokens_below_min_threshold() {
405 let tiny = "hi";
407 let tokens = estimate_tokens(tiny);
408 assert!(
409 tokens < MIN_TOKENS_TO_CLEAR,
410 "tiny content ({tokens} tokens) should be below MIN_TOKENS_TO_CLEAR ({MIN_TOKENS_TO_CLEAR})"
411 );
412 }
413
414 #[test]
417 fn test_is_not_compactable_write() {
418 assert!(!is_compactable("Write"));
419 assert!(!is_compactable("write"));
420 }
421
422 #[test]
423 fn test_is_not_compactable_edit() {
424 assert!(!is_compactable("Edit"));
425 assert!(!is_compactable("edit"));
426 }
427
428 #[test]
429 fn test_is_not_compactable_unknown_tool() {
430 assert!(!is_compactable("FancyCustomTool"));
431 assert!(!is_compactable(""));
432 }
433
434 #[test]
437 fn test_diagnosis_returns_none_below_token_threshold() {
438 let tc = r#"[{"id":"tc_1","function_name":"Bash","arguments":"{}"}]"#;
440 let messages = vec![
441 msg(1, Role::Assistant, None, Some(tc), None),
442 msg(2, Role::Tool, Some("tiny result"), None, Some("tc_1")),
443 ];
444 assert!(diagnosis(&messages).is_none());
445 }
446
447 #[test]
448 fn test_diagnosis_includes_compactable_tools_only() {
449 let tc_write = r#"[{"id":"tc_w","function_name":"Write","arguments":"{}"}]"#;
451 let tc_read = r#"[{"id":"tc_r","function_name":"Read","arguments":"{}"}]"#;
452 let big = "X".repeat(3000);
453 let messages = vec![
454 msg(1, Role::Assistant, None, Some(tc_write), None),
455 msg(2, Role::Tool, Some(&big), None, Some("tc_w")),
456 msg(3, Role::Assistant, None, Some(tc_read), None),
457 msg(4, Role::Tool, Some(&big), None, Some("tc_r")),
458 ];
459 let d = diagnosis(&messages);
460 assert!(d.is_some());
461 let text = d.unwrap();
462 assert!(
463 !text.contains("Write"),
464 "Write should not appear in diagnosis"
465 );
466 assert!(text.contains("Read"), "Read should appear in diagnosis");
467 }
468
469 #[test]
470 fn test_diagnosis_returns_none_when_all_tools_non_compactable() {
471 let tc = r#"[{"id":"tc_w","function_name":"Write","arguments":"{}"}]"#;
473 let big = "W".repeat(3000);
474 let messages = vec![
475 msg(1, Role::Assistant, None, Some(tc), None),
476 msg(2, Role::Tool, Some(&big), None, Some("tc_w")),
477 ];
478 assert!(diagnosis(&messages).is_none());
479 }
480
481 #[test]
484 fn test_build_tool_id_map_accepts_name_key_variant() {
485 let tc = r#"[{"id":"tc_x","name":"Grep","arguments":"{}"}]"#;
487 let messages = vec![msg(1, Role::Assistant, None, Some(tc), None)];
488 let map = build_tool_id_map(&messages);
489 assert_eq!(map.get("tc_x").map(|s| s.as_str()), Some("Grep"));
490 }
491
492 #[test]
493 fn test_build_tool_id_map_ignores_non_assistant_messages() {
494 let tc = r#"[{"id":"tc_y","function_name":"Bash","arguments":"{}"}]"#;
495 let messages = vec![msg(1, Role::Tool, None, Some(tc), None)];
497 let map = build_tool_id_map(&messages);
498 assert!(map.is_empty());
499 }
500}