diff --git a/src-tauri/src/assistant/compaction.rs b/src-tauri/src/assistant/compaction.rs index 81183b2..a309266 100644 --- a/src-tauri/src/assistant/compaction.rs +++ b/src-tauri/src/assistant/compaction.rs @@ -402,17 +402,47 @@ Preserve: Do not include filler, greetings, or obsolete intermediate details. Do not invent facts. Write a compact but complete continuation summary."#; +/// Preamble prepended to every stored compaction summary message. +const SUMMARY_MESSAGE_PREAMBLE: &str = + "Conversation summary generated by CLAI compaction. Treat this as the \ + authoritative summary of the compacted earlier messages. If you are \ + missing context needed to continue, recover it before acting rather \ + than asking the user to repeat anything: your durable state is in \ + `.clai/memory/` and the full verbatim history (every message and tool \ + result) is in `.clai/data.sqlite` — query it with the read-only \ + `history_query` tool (no approval needed) to recover specifics."; + +/// Preamble that opens a deterministic (non-model) digest body. +const FALLBACK_DIGEST_PREAMBLE: &str = + "Deterministic compaction summary: the previous conversation was \ + compacted without a model-generated summary. Below is a noise-reduced \ + transcript digest — tool payloads are truncated, and when the history \ + is too long the opening exchanges (the original goal) and the most \ + recent exchanges are kept while the middle is dropped. Continue from \ + it, and recover anything that was elided from `.clai/memory/` or by \ + querying the full history with the read-only `history_query` tool \ + (it reads `.clai/data.sqlite` and needs no approval)."; + fn summary_message_text(summary: &str) -> String { - format!( - "Conversation summary generated by CLAI compaction. Treat this as the \ - authoritative summary of the compacted earlier messages. If you are \ - missing context needed to continue, recover it before acting rather \ - than asking the user to repeat anything: your durable state is in \ - `.clai/memory/` and the full verbatim history (every message and tool \ - result) is in `.clai/data.sqlite` — query it with the read-only \ - `history_query` tool (no approval needed) to recover specifics.\n\n{}", - summary.trim() - ) + format!("{}\n\n{}", SUMMARY_MESSAGE_PREAMBLE, summary.trim()) +} + +/// A chained compaction feeds the previous summary message back through the +/// renderer. Its preambles are pure boilerplate that the *new* summary message +/// re-adds anyway, so carrying them forward stacks one more copy per rotation +/// while telling the model nothing it isn't already told at the top. +fn strip_compaction_preambles(text: &str) -> String { + let mut out = text.trim_start(); + loop { + let next = out + .strip_prefix(SUMMARY_MESSAGE_PREAMBLE) + .or_else(|| out.strip_prefix(FALLBACK_DIGEST_PREAMBLE)); + match next { + Some(rest) => out = rest.trim_start(), + None => break, + } + } + out.to_string() } fn transcript_for_summary(messages: &[AssistantMessage]) -> String { @@ -438,17 +468,7 @@ fn fallback_summary(messages: &[AssistantMessage]) -> String { DIGEST_TOOL_RESULT_MAX_CHARS, ); let body = select_head_and_tail(&rendered, SUMMARY_TRANSCRIPT_MAX_CHARS); - format!( - "Deterministic compaction summary: the previous conversation was \ - compacted without a model-generated summary. Below is a noise-reduced \ - transcript digest — tool payloads are truncated, and when the history \ - is too long the opening exchanges (the original goal) and the most \ - recent exchanges are kept while the middle is dropped. Continue from \ - it, and recover anything that was elided from `.clai/memory/` or by \ - querying the full history with the read-only `history_query` tool \ - (it reads `.clai/data.sqlite` and needs no approval).\n\n{}", - body - ) + format!("{}\n\n{}", FALLBACK_DIGEST_PREAMBLE, body) } /// Join rendered messages within `budget`, always at whole-message boundaries. @@ -493,9 +513,16 @@ fn select_head_and_tail(rendered: &[String], budget: usize) -> String { tail_start -= 1; } + let head = clamp_slice(&rendered[..head_end], head_budget, Keep::Start); + let tail = clamp_slice(&rendered[tail_start..], tail_budget, Keep::End); + // Head and tail meet (everything fits across the two slices): no omission. if tail_start <= head_end { - return rendered.join("\n\n"); + return [head, tail] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n\n"); } let omitted = tail_start - head_end; @@ -504,13 +531,48 @@ fn select_head_and_tail(rendered: &[String], budget: usize) -> String { "{}\n\n[... {} middle message{} omitted during compaction; recover the \ full verbatim history with the read-only `history_query` tool \ (`.clai/data.sqlite`) ...]\n\n{}", - rendered[..head_end].join("\n\n"), - omitted, - suffix, - rendered[tail_start..].join("\n\n") + head, omitted, suffix, tail ) } +const MESSAGE_TRUNCATION_NOTE: &str = + "\n\n[... this message was truncated during compaction; recover its full \ + text with the read-only `history_query` tool (`.clai/data.sqlite`) ...]\n\n"; + +/// Join a selected slice, capping any single message at the slice budget. +/// +/// The "keep at least one message" rules above accept one oversized message per +/// slice, which is how a chained compaction used to grow without bound: message +/// 0 of a rotation window is the *previous* digest, so an uncapped head copied +/// the whole predecessor into every successor. Truncate rather than drop — the +/// opening message still has to survive, just not at any size. +fn clamp_slice(messages: &[String], budget: usize, keep: Keep) -> String { + messages + .iter() + .map(|message| clamp_message(message, budget, keep)) + .collect::>() + .join("\n\n") +} + +/// Which end of an oversized message to keep: the head slice exists to preserve +/// the opening goal, the tail slice to preserve the most recent exchange. +#[derive(Clone, Copy)] +enum Keep { + Start, + End, +} + +fn clamp_message(message: &str, budget: usize, keep: Keep) -> String { + if message.len() <= budget { + return message.to_string(); + } + let room = budget.saturating_sub(MESSAGE_TRUNCATION_NOTE.len()); + match keep { + Keep::Start => format!("{}{}", safe_prefix(message, room), MESSAGE_TRUNCATION_NOTE), + Keep::End => format!("{}{}", MESSAGE_TRUNCATION_NOTE, safe_suffix(message, room)), + } +} + fn render_transcript(messages: &[AssistantMessage]) -> String { render_messages( messages, @@ -537,12 +599,13 @@ fn render_messages( MessageRole::Assistant => "assistant", MessageRole::Tool => "tool", }; - format!( - "[{} message {}]\n{}", - role, - message.id, - render_content_parts(&message.content, tool_call_max, tool_result_max) - ) + let body = render_content_parts(&message.content, tool_call_max, tool_result_max); + let body = if is_compaction_summary_message(message) { + strip_compaction_preambles(&body) + } else { + body + }; + format!("[{} message {}]\n{}", role, message.id, body) }) .collect() } @@ -746,6 +809,122 @@ mod tests { assert!(out.contains("history_query")); } + fn summary_msg(id: &str, body: &str) -> AssistantMessage { + AssistantMessage { + provider_metadata: Some(serde_json::json!({ "source": COMPACTION_METADATA_SOURCE })), + ..msg( + id, + MessageRole::System, + vec![text(&summary_message_text(body))], + ) + } + } + + #[test] + fn select_head_and_tail_never_exceeds_budget_on_one_huge_message() { + // Message 0 of a rotation window is the previous digest: it can be far + // larger than the whole budget on its own. It must be truncated, not + // copied through, or every digest inherits its predecessor's size. + let budget = 4_000; + let rendered = vec![ + "H".repeat(500_000), + "middle".to_string(), + "T".repeat(500_000), + ]; + let out = select_head_and_tail(&rendered, budget); + assert!( + out.len() <= budget + 400, + "digest blew the budget: {} chars for a {budget}-char budget", + out.len() + ); + assert!(out.starts_with('H'), "head dropped entirely"); + assert!(out.trim_end().ends_with('T'), "tail dropped entirely"); + assert!(out.contains("truncated during compaction"), "{out}"); + } + + #[test] + fn select_head_and_tail_bounds_output_when_head_and_tail_meet() { + // Two messages, both oversized: head and tail meet, so the omission + // branch never runs — the clamp still has to hold. + let budget = 2_000; + let rendered = vec!["H".repeat(80_000), "T".repeat(80_000)]; + let out = select_head_and_tail(&rendered, budget); + assert!( + out.len() <= budget + 400, + "digest blew the budget: {} chars", + out.len() + ); + } + + #[test] + fn chained_digests_do_not_grow_without_bound() { + // Simulate repeated session rotations: each digest becomes message 0 of + // the next window. Before the fix this grew by roughly the size of the + // previous digest every round (measured: 56 KB -> 960 KB over 20 + // rotations against a 96 KB budget). + let mut carried = fallback_summary(&[msg( + "seed", + MessageRole::User, + vec![text(&"the original goal ".repeat(2_000))], + )]); + let mut sizes = Vec::new(); + for round in 0..12 { + let mut window = vec![summary_msg(&format!("digest-{round}"), &carried)]; + for i in 0..30 { + window.push(msg( + &format!("m-{round}-{i}"), + MessageRole::Assistant, + vec![text(&format!("turn {i} of round {round} ").repeat(200))], + )); + } + carried = fallback_summary(&window); + sizes.push(carried.len()); + } + let last = *sizes.last().unwrap(); + assert!( + last <= SUMMARY_TRANSCRIPT_MAX_CHARS + 2_000, + "digest exceeded its budget after chaining: {last} chars, sizes {sizes:?}" + ); + // And it is not creeping upward round over round. + assert!( + last <= sizes[1] + 2_000, + "digest grew across rotations: {sizes:?}" + ); + } + + #[test] + fn chained_digests_do_not_stack_preambles() { + // One boilerplate preamble per digest, no matter how many rotations. + let mut carried = fallback_summary(&[msg("seed", MessageRole::User, vec![text("goal")])]); + for round in 0..6 { + let window = vec![ + summary_msg(&format!("digest-{round}"), &carried), + msg("u", MessageRole::User, vec![text("next")]), + ]; + carried = fallback_summary(&window); + } + let stored = summary_message_text(&carried); + assert_eq!( + stored + .matches("Conversation summary generated by CLAI") + .count(), + 1, + "summary preamble stacked: {stored}" + ); + assert_eq!( + stored.matches("Deterministic compaction summary").count(), + 1, + "digest preamble stacked: {stored}" + ); + } + + #[test] + fn strip_compaction_preambles_leaves_ordinary_text_alone() { + assert_eq!(strip_compaction_preambles("plain body"), "plain body"); + let wrapped = summary_message_text("real content here"); + assert_eq!(strip_compaction_preambles(&wrapped), "real content here"); + } + #[test] fn summary_message_text_includes_recovery_guidance() { let out = summary_message_text("the summary body");