diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index 8431b8acd..276beda35 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -910,6 +910,202 @@ fn capped_tool_result_payload(result: &Value, max_len: usize) -> Value { capped } +/// Maximum total characters for a compaction summary. +const SUMMARY_MAX_CHARS: usize = 1_200; +/// Maximum number of lines in a compaction summary (excluding omission notice). +const SUMMARY_MAX_LINES: usize = 24; +/// Maximum characters per line in a compaction summary. +const SUMMARY_MAX_LINE_CHARS: usize = 160; + +/// Compress a compaction summary to fit within budget constraints. +/// +/// Enforces: max 1,200 chars total, max 24 lines, max 160 chars per line. +/// Deduplicates lines (case-insensitive), preserves headers and bullets, +/// and appends an omission notice when lines are dropped. +#[must_use] +fn compress_summary(text: &str) -> String { + let text = text.trim(); + if text.is_empty() { + return String::new(); + } + + let lines: Vec<&str> = text.lines().collect(); + if lines.is_empty() { + return String::new(); + } + + // Step 1: deduplicate lines (case-insensitive, keep first occurrence) + // and strip blank lines so they don't consume the 24-line budget. + let mut seen = HashSet::new(); + let mut deduped: Vec = Vec::with_capacity(lines.len()); + for line in lines { + let key = line.trim().to_ascii_lowercase(); + // Drop blank lines — they waste budget without adding content. + if key.is_empty() { + continue; + } + if seen.insert(key) { + deduped.push(if line.len() <= SUMMARY_MAX_LINE_CHARS { + line.to_string() + } else { + // Step 2: truncate individual lines exceeding 160 chars. + line[..line.floor_char_boundary(SUMMARY_MAX_LINE_CHARS)].to_string() + }); + } + } + drop(seen); + + // Step 3: check if already within budget. + let joined = deduped.join("\n"); + if deduped.len() <= SUMMARY_MAX_LINES && joined.len() <= SUMMARY_MAX_CHARS { + return joined; + } + + // Step 4: priority-based line dropping. + // Headers (starting with #) get highest priority, then bullets (- * •), then rest. + fn is_header(line: &str) -> bool { + line.trim_start().starts_with('#') + } + fn is_bullet(line: &str) -> bool { + let trimmed = line.trim_start(); + trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("• ") + } + + let mut headers: Vec = Vec::new(); + let mut bullet_lines: Vec = Vec::new(); + let mut other_lines: Vec = Vec::new(); + + for line in deduped { + if is_header(&line) { + headers.push(line); + } else if is_bullet(&line) { + bullet_lines.push(line); + } else { + other_lines.push(line); + } + } + + // Build ordered candidate list: bullets first, then others. + // Headers are always kept. + let mut candidates: Vec = Vec::new(); + candidates.extend(bullet_lines); + candidates.extend(other_lines); + + let header_count = headers.len(); + + // Check if keeping all candidates fits. + if header_count + candidates.len() <= SUMMARY_MAX_LINES { + let total_len = headers.iter().chain(candidates.iter()).fold(0, |acc, l| { + acc + l.len() + 1 // +1 for newline + }) + // fold overcounts by 1 (N newlines vs N-1 for join); subtract to correct. + .saturating_sub(1); + if total_len <= SUMMARY_MAX_CHARS { + let mut result = headers; + result.extend(candidates); + return result.join("\n"); + } + } + + // Need to drop lines from the end of candidates. + // Account for omission notice in budget. + fn make_notice(n: usize) -> String { + format!("[... {n} lines omitted for brevity]") + } + + for drop_count in 1..=candidates.len() { + let keep_count = candidates.len() - drop_count; + let line_count = header_count + keep_count + 1; // +1 for omission notice + if line_count > SUMMARY_MAX_LINES { + continue; + } + + let notice = make_notice(drop_count); + let kept_candidates = &candidates[..keep_count]; + let total_len = headers + .iter() + .chain(kept_candidates.iter()) + .fold(0, |acc, l| acc + l.len() + 1) + // fold overcounts by 1 (N newlines vs N-1 for join); subtract to correct. + .saturating_sub(1) + + notice.len() + + 1; // +1 for newline before notice + + if total_len <= SUMMARY_MAX_CHARS { + let mut result = headers; + result.extend(kept_candidates.iter().cloned()); + result.push(notice); + return result.join("\n"); + } + } + + // Edge case: even dropping all candidates, headers alone are too long. + // Force-truncate headers from the end. Run two passes so the notice + // length is exact: first pass counts dropped headers, second pass + // builds the result with the correct budget. + let base_dropped = candidates.len(); + let mut header_drop_count = 0usize; + { + // First pass: determine how many headers must be dropped. + let mut budget = SUMMARY_MAX_CHARS.saturating_sub(make_notice(base_dropped).len() + 1); + let mut kept = 0usize; + for line in &headers { + let needed = line.len() + if kept == 0 { 0 } else { 1 }; + if needed > budget || kept + 1 >= SUMMARY_MAX_LINES { + header_drop_count += 1; + } else { + budget -= needed; + kept += 1; + } + } + } + + let notice = make_notice(base_dropped + header_drop_count); + // Second pass: rebuild with exact budget including final notice length. + let mut char_budget = SUMMARY_MAX_CHARS.saturating_sub(notice.len() + 1); + let mut result: Vec = Vec::new(); + for line in &headers { + let needed = line.len() + if result.is_empty() { 0 } else { 1 }; + if needed > char_budget || result.len() + 1 >= SUMMARY_MAX_LINES { + continue; + } + char_budget -= needed; + result.push(line.clone()); + } + result.push(notice); + result.join("\n") +} + +/// Apply [`compress_summary`] to any `[Conversation Summary]` or +/// `[Conversation Compacted]` message in a compacted history. +/// +/// Walks each message, detects the summary prefix, compresses the body, and +/// replaces the content in place. Non-summary messages are passed through +/// unchanged, preserving the head/tail structure of modes like +/// `recency_preserving`. +fn compress_summary_in_history(mut history: Vec) -> Vec { + for msg in &mut history { + let Some(content) = msg.get("content").and_then(Value::as_str).map(String::from) else { + continue; + }; + for prefix in ["[Conversation Summary]\n\n", "[Conversation Compacted]\n\n"] { + if let Some(body) = content.strip_prefix(prefix) { + let compressed = compress_summary(body); + if compressed.len() < body.len() { + if let Some(obj) = msg.as_object_mut() { + obj.insert( + "content".into(), + Value::String(format!("{prefix}{compressed}")), + ); + } + } + break; + } + } + } + history +} + fn shell_reply_text_from_exec_result(result: &Value) -> String { let stdout = result .get("stdout") @@ -5011,6 +5207,11 @@ impl ChatService for LiveChatService { "chat.compact: strategy dispatched" ); + // Enforce summary budget discipline: max 1,200 chars, 24 lines, + // 160 chars/line. Mutate the compacted history in place so the + // compressed text is what gets persisted and broadcast. + let compacted = compress_summary_in_history(compacted); + // Replace the session history BEFORE broadcasting or notifying // channels. If we did it the other way around, a concurrent // `send()` RPC that landed between the broadcast and the store @@ -7717,10 +7918,13 @@ async fn compact_session( .await .map_err(|source| error::Error::external("failed to read session history", source))?; - let outcome = compaction_run::run_compaction(&history, config, provider) + let mut outcome = compaction_run::run_compaction(&history, config, provider) .await .map_err(|e| error::Error::message(e.to_string()))?; + // Enforce summary budget discipline on the compacted history. + outcome.history = compress_summary_in_history(outcome.history); + store .replace_history(session_key, outcome.history.clone()) .await @@ -15348,6 +15552,133 @@ mod tests { ); } + // ── compress_summary tests ────────────────────────────────────────────── + + #[test] + fn compress_summary_under_budget_returns_unchanged() { + let input = "# Summary\n- Key point one\n- Key point two\nDone."; + let result = compress_summary(input); + assert_eq!(result, input); + } + + #[test] + fn compress_summary_strips_blank_lines() { + let input = "# Summary\n\n- Point one\n\n- Point two\n\nDone."; + let result = compress_summary(input); + assert_eq!(result, "# Summary\n- Point one\n- Point two\nDone."); + } + + #[test] + fn compress_summary_over_char_limit() { + let mut lines = vec!["# Summary".to_string()]; + for i in 0..30 { + lines.push(format!( + "- This is line {i} with some padding text to make it longer than usual" + )); + } + let input = lines.join("\n"); + assert!(input.len() > 1_200, "input should exceed 1200 chars"); + + let result = compress_summary(&input); + assert!( + result.len() <= 1_200, + "result must be <= 1200 chars, got {}", + result.len() + ); + assert!( + result.contains("lines omitted"), + "should have omission notice" + ); + } + + #[test] + fn compress_summary_over_line_count() { + let mut lines = vec!["# Summary".to_string()]; + for i in 0..40 { + lines.push(format!("Line {i}")); + } + let input = lines.join("\n"); + + let result = compress_summary(&input); + let result_lines: Vec<&str> = result.lines().collect(); + assert!( + result_lines.len() <= 25, + "result should be <= 25 lines (24 + notice), got {}", + result_lines.len() + ); + assert!(result.contains("lines omitted")); + } + + #[test] + fn compress_summary_long_line_truncation() { + let long_line: String = "x".repeat(200); + let input = format!("Header\n{long_line}"); + let result = compress_summary(&input); + + let result_lines: Vec<&str> = result.lines().collect(); + assert_eq!(result_lines.len(), 2); + // The long line should be truncated to 160 chars. + assert!( + result_lines[1].len() <= 160, + "long line should be <= 160 chars, got {}", + result_lines[1].len() + ); + } + + #[test] + fn compress_summary_deduplication() { + let input = "Alpha\nalpha\nBeta\nBETA\nGamma"; + let result = compress_summary(input); + // Case-insensitive dedup: should keep first occurrence of each. + let result_lines: Vec<&str> = result.lines().collect(); + assert_eq!(result_lines, vec!["Alpha", "Beta", "Gamma"]); + } + + #[test] + fn compress_summary_header_preservation() { + let mut lines = vec!["# Section One".to_string()]; + for i in 0..30 { + lines.push(format!( + "Body line {i} with enough text to fill up space here" + )); + } + lines.push("## Section Two".to_string()); + for i in 0..10 { + lines.push(format!("- Bullet {i} important")); + } + let input = lines.join("\n"); + + let result = compress_summary(&input); + assert!( + result.contains("# Section One"), + "headers should be preserved" + ); + assert!( + result.contains("## Section Two"), + "second header should be preserved" + ); + assert!(result.contains("lines omitted")); + } + + #[test] + fn compress_summary_empty_input() { + assert_eq!(compress_summary(""), ""); + assert_eq!(compress_summary(" "), ""); + } + + #[test] + fn compress_summary_single_very_long_line() { + let long_line = "a".repeat(2_000); + let result = compress_summary(&long_line); + + // Should be truncated to 160 chars. + assert!( + result.len() <= 160, + "single long line should be truncated, got {} chars", + result.len() + ); + } + /// Serializes tests that mutate the global `moltis_config` data_dir /// override so they don't race within the chat crate's test binary. /// A `Semaphore` with a single permit is used here instead of