fix: Don't infinitely compress a session once it passes a threshold when the threshold is close to the max token count

This commit is contained in:
Dark-Alex-17 committed 2026-09-11 14:02:28 -06:00
1 parent e744da8870
commit b50ec42868
4 files changed
+160 -22

No files matched your search

+24 -2
View File
@@ -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} ";
+8 -5
View File
@@ -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}"),
+126 -14
View File
@@ -18,6 +18,11 @@ static RE_AUTONAME_PREFIX: LazyLock<Regex> = 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<Message>,
#[serde(default, skip_serializing_if = "Option::is_none")]
compressed_system_prompt: Option<String>,
messages: Vec<Message>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
data_urls: HashMap<String, String>,
@@ -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);
// 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);
}
}
None
}) {
prompt = format!("{system_prompt}\n\n{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();
+2 -1
View File
@@ -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 =