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:
4 files changed
+160
-22
No files matched your search
+24
-2
@@ -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} ";
|
||||
|
||||
@@ -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
@@ -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
@@ -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 =
|
||||
|
||||
Reference in new issue
Block a user