Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
243 changes: 211 additions & 32 deletions src-tauri/src/assistant/compaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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::<Vec<_>>()
.join("\n\n");
}

let omitted = tail_start - head_end;
Expand All @@ -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::<Vec<_>>()
.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,
Expand All @@ -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()
}
Expand Down Expand Up @@ -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");
Expand Down
Loading