diff --git a/src/config/mod.rs b/src/config/mod.rs index 3c71f1f..6c909fd 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -190,8 +190,30 @@ const GITIGNORE_FILE_NAME: &str = ".gitignore"; const CLIENTS_FIELD: &str = "clients"; -const SUMMARIZATION_PROMPT: &str = - "Summarize the discussion briefly in 200 words or less to use as a prompt for future context."; +const SUMMARIZATION_PROMPT: &str = r#"The conversation above is about to be compressed: everything except the most recent messages will be replaced by your summary, which becomes the only memory of that history. Write a handoff summary that lets the assistant continue seamlessly. + +Use exactly these markdown sections, omitting any that do not apply: + +## Goal +The user's overall objective and any stated success criteria. + +## Current state +What has been accomplished so far and what is in progress right now. + +## Key facts +Files and paths touched or read (with a brief note on each), commands run and their outcomes, and identifiers that must survive verbatim: session/agent IDs, branch names, URLs, config keys, error messages. + +## Decisions and constraints +Choices made and why, user preferences and directives, and approaches that were rejected or failed and must not be retried. + +## Next steps +Remaining work in order, plus any open questions awaiting the user. + +Rules: +- If the context already contains a summary of earlier history, fold its still-relevant content into this summary — it will be discarded otherwise. +- Copy identifiers, paths, and error text verbatim; never paraphrase them. +- Be dense and factual; prefer bullet points; no preamble or commentary before or after the summary. +- Keep it under roughly 400 words unless critical details would otherwise be lost."#; const SUMMARY_CONTEXT_PROMPT: &str = "This is a summary of the chat history as a recap: "; const LEFT_PROMPT: &str = "{color.red}{model}){color.green}{?session {?agent {agent}>}{session}{?role /}}{!session {?agent {agent}>}}{role}{?rag @{rag}}{color.cyan}{?session )}{!session >}{color.reset} "; diff --git a/src/config/request_context.rs b/src/config/request_context.rs index d5b36f9..b8c5cdb 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -969,6 +969,13 @@ impl RequestContext { .unwrap_or_default() } + pub fn compression_keep_last(&self) -> usize { + self.agent + .as_ref() + .and_then(|a| a.compression_keep_last()) + .unwrap_or(self.app.config.compression_keep_last) + } + pub fn role_like_mut(&mut self) -> Option<&mut dyn RoleLike> { if let Some(session) = self.session.as_mut() { Some(session) @@ -5026,11 +5033,7 @@ impl RequestContext { String::new() }; - let keep_last = self - .agent - .as_ref() - .and_then(|a| a.compression_keep_last()) - .unwrap_or(self.app.config.compression_keep_last); + let keep_last = self.compression_keep_last(); if let Some(session) = self.session.as_mut() { session.compress( format!("{todo_prefix}{summary_context_prompt}{summary}"), diff --git a/src/config/session.rs b/src/config/session.rs index b6c8d22..e28e906 100644 --- a/src/config/session.rs +++ b/src/config/session.rs @@ -18,6 +18,11 @@ static RE_AUTONAME_PREFIX: LazyLock = LazyLock::new(|| Regex::new(r"\d{8} pub const INTERRUPTED_RESPONSE_TEXT: &str = "[Response interrupted due to error]"; +/// Auto-compression only fires when at least `threshold / 5` (20%) of the +/// threshold is reclaimable, so each compression folds a meaningful batch +/// instead of thrashing every turn. +const COMPRESSION_MIN_RECLAIM_DIVISOR: usize = 5; + #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct Session { #[serde(rename(serialize = "model", deserialize = "model"))] @@ -84,6 +89,8 @@ pub struct Session { #[serde(default, skip_serializing_if = "Vec::is_empty")] compressed_messages: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + compressed_system_prompt: Option, messages: Vec, #[serde(default, skip_serializing_if = "HashMap::is_empty")] data_urls: HashMap, @@ -587,7 +594,7 @@ impl Session { } } - pub fn needs_compression(&self, global_compression_threshold: usize) -> bool { + pub fn needs_compression(&self, global_compression_threshold: usize, keep_last: usize) -> bool { if self.compressing { return false; } @@ -597,7 +604,29 @@ impl Session { if threshold < 1 { return false; } - self.tokens() > threshold + if self.tokens() <= threshold { + return false; + } + // Compression re-embeds the leading system message's content in the + // summary message and keeps the last `keep_last` messages verbatim, so + // only the messages between them are actually reclaimable. Without this + // guard, a session whose sticky floor (system prompt + kept tail) sits + // near the threshold re-compresses after every single turn, burning a + // summarization LLM call each time for almost no reduction. + self.reclaimable_tokens(keep_last) >= (threshold / COMPRESSION_MIN_RECLAIM_DIVISOR).max(1) + } + + /// Estimated tokens of the messages that compression would actually fold + /// away: everything except the leading system message (whose content is + /// carried into the summary message) and the `keep_last` tail (kept verbatim). + fn reclaimable_tokens(&self, keep_last: usize) -> usize { + let start = usize::from( + self.messages + .first() + .is_some_and(|v| v.role == MessageRole::System), + ); + let end = self.messages.len().saturating_sub(keep_last).max(start); + self.model().total_tokens(&self.messages[start..end]) } pub fn compressing(&self) -> bool { @@ -609,16 +638,25 @@ impl Session { } pub fn compress(&mut self, mut prompt: String, keep_last: usize) { - if let Some(system_prompt) = self.messages.first().and_then(|v| { - if MessageRole::System == v.role { - let content = v.content.to_text(); - if !content.is_empty() { - return Some(content); - } - } - None - }) { - prompt = format!("{system_prompt}\n\n{prompt}",); + // Capture the session's original system prompt the first time we compress. + // On subsequent compressions `messages[0]` is the summary message built + // below; re-reading it would re-embed every prior recap+summary, growing + // the message — and the post-compression token floor — without bound. + if self.compressed_system_prompt.is_none() { + let system_prompt = self + .messages + .first() + .filter(|v| v.role == MessageRole::System) + .map(|v| v.content.to_text()) + .unwrap_or_default(); + self.compressed_system_prompt = Some(system_prompt); + } + if let Some(system_prompt) = self + .compressed_system_prompt + .as_deref() + .filter(|v| !v.is_empty()) + { + prompt = format!("{system_prompt}\n\n{prompt}"); } let messages_to_keep = if keep_last > 0 && keep_last < self.messages.len() { self.messages.split_off(self.messages.len() - keep_last) @@ -804,6 +842,7 @@ impl Session { pub fn clear_messages(&mut self) { self.messages.clear(); self.compressed_messages.clear(); + self.compressed_system_prompt = None; self.data_urls.clear(); self.autoname = None; self.dirty = true; @@ -1205,7 +1244,7 @@ mod tests { fn session_needs_compression_threshold() { let session = Session::default(); - assert!(!session.needs_compression(4000)); + assert!(!session.needs_compression(4000, 0)); } #[test] @@ -1214,14 +1253,36 @@ mod tests { session.set_compressing(true); - assert!(!session.needs_compression(0)); + assert!(!session.needs_compression(0, 0)); } #[test] fn session_needs_compression_returns_false_when_threshold_zero() { let session = Session::default(); - assert!(!session.needs_compression(0)); + assert!(!session.needs_compression(0, 0)); + } + + #[test] + fn session_needs_compression_requires_reclaimable_tokens() { + let mut session = Session::default(); + session.messages.push(Message::new( + MessageRole::System, + MessageContent::Text("x".repeat(4000)), + )); + session.messages.push(Message::new( + MessageRole::User, + MessageContent::Text("y".repeat(4000)), + )); + session.messages.push(Message::new( + MessageRole::Assistant, + MessageContent::Text("ok".to_string()), + )); + session.update_tokens(); + + assert!(session.tokens() > 100); + assert!(!session.needs_compression(100, 2)); + assert!(session.needs_compression(100, 1)); } #[test] @@ -1276,6 +1337,57 @@ mod tests { assert!(session.dirty()); } + #[test] + fn session_compress_does_not_accumulate_summaries() { + let mut session = Session::default(); + session.messages.push(Message::new( + MessageRole::System, + MessageContent::Text("SYSTEM PROMPT".to_string()), + )); + session.messages.push(Message::new( + MessageRole::User, + MessageContent::Text("hello".to_string()), + )); + + session.compress("recap one".to_string(), 0); + + session.messages.push(Message::new( + MessageRole::User, + MessageContent::Text("more".to_string()), + )); + session.messages.push(Message::new( + MessageRole::Assistant, + MessageContent::Text("reply".to_string()), + )); + + session.compress("recap two".to_string(), 0); + + assert_eq!( + session.messages[0].content.to_text(), + "SYSTEM PROMPT\n\nrecap two" + ); + } + + #[test] + fn session_compress_without_system_message_does_not_capture_summary() { + let mut session = Session::default(); + session.messages.push(Message::new( + MessageRole::User, + MessageContent::Text("hello".to_string()), + )); + + session.compress("recap one".to_string(), 0); + + session.messages.push(Message::new( + MessageRole::User, + MessageContent::Text("more".to_string()), + )); + + session.compress("recap two".to_string(), 0); + + assert_eq!(session.messages[0].content.to_text(), "recap two"); + } + #[test] fn session_is_not_empty_after_compress() { let mut session = Session::default(); diff --git a/src/repl/mod.rs b/src/repl/mod.rs index 67f2bb0..7527170 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -1569,10 +1569,11 @@ async fn ask( } } + let keep_last = ctx.compression_keep_last(); let needs_compression = ctx .session .as_ref() - .is_some_and(|s| s.needs_compression(app.compression_threshold)); + .is_some_and(|s| s.needs_compression(app.compression_threshold, keep_last)); if needs_compression { let agent_can_continue_after_compress =