From f28f0ba84cf8afc9c3fdffb084649cbee7a01ff1 Mon Sep 17 00:00:00 2001 From: juan <2930882+juacker@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:40:05 +0200 Subject: [PATCH] fix(assistant): stop chained compaction from growing the digest without bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session-rotation compaction grew instead of compacting. Measured over 25 recorded compactions: the digest went 56 KB -> 960 KB across 20 rotations, adding exactly one nested boilerplate preamble each time, against a SUMMARY_TRANSCRIPT_MAX_CHARS budget of 96,000 — 10x over. Two compounding causes, both in the deterministic digest path: - select_head_and_tail never enforced its own budget. Its head loop always keeps at least one message, and message 0 of a rotation window is the *previous* digest, so each digest copied its predecessor whole and then appended the tail. The head/tail-meet early return had the same hole. - The prior digest was rendered back in with its own preambles intact while the new summary message added a fresh copy on top, so boilerplate stacked one layer per rotation. Fix: clamp any single message to the budget of the slice it lands in (head keeps the opening, tail keeps the most recent text, both noting where to recover the full text), and strip a prior summary's preambles when it is rendered into a new digest. Preambles become named constants so the strip is exact. No behaviour change when the history already fits the budget. Adds 5 tests, including a 12-rotation chaining test asserting the digest stays within budget and does not creep upward. --- src-tauri/src/assistant/compaction.rs | 243 ++++++++++++++++++++++---- 1 file changed, 211 insertions(+), 32 deletions(-) diff --git a/src-tauri/src/assistant/compaction.rs b/src-tauri/src/assistant/compaction.rs index 81183b24..a3092662 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");