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
+161
-23
No files matched your search
+24
-2
@@ -190,8 +190,30 @@ const GITIGNORE_FILE_NAME: &str = ".gitignore";
|
|||||||
|
|
||||||
const CLIENTS_FIELD: &str = "clients";
|
const CLIENTS_FIELD: &str = "clients";
|
||||||
|
|
||||||
const SUMMARIZATION_PROMPT: &str =
|
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.
|
||||||
"Summarize the discussion briefly in 200 words or less to use as a prompt for future context.";
|
|
||||||
|
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 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} ";
|
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()
|
.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> {
|
pub fn role_like_mut(&mut self) -> Option<&mut dyn RoleLike> {
|
||||||
if let Some(session) = self.session.as_mut() {
|
if let Some(session) = self.session.as_mut() {
|
||||||
Some(session)
|
Some(session)
|
||||||
@@ -5026,11 +5033,7 @@ impl RequestContext {
|
|||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
let keep_last = self
|
let keep_last = self.compression_keep_last();
|
||||||
.agent
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|a| a.compression_keep_last())
|
|
||||||
.unwrap_or(self.app.config.compression_keep_last);
|
|
||||||
if let Some(session) = self.session.as_mut() {
|
if let Some(session) = self.session.as_mut() {
|
||||||
session.compress(
|
session.compress(
|
||||||
format!("{todo_prefix}{summary_context_prompt}{summary}"),
|
format!("{todo_prefix}{summary_context_prompt}{summary}"),
|
||||||
|
|||||||
+127
-15
@@ -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]";
|
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)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
#[serde(rename(serialize = "model", deserialize = "model"))]
|
#[serde(rename(serialize = "model", deserialize = "model"))]
|
||||||
@@ -84,6 +89,8 @@ pub struct Session {
|
|||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
compressed_messages: Vec<Message>,
|
compressed_messages: Vec<Message>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
compressed_system_prompt: Option<String>,
|
||||||
messages: Vec<Message>,
|
messages: Vec<Message>,
|
||||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||||
data_urls: HashMap<String, String>,
|
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 {
|
if self.compressing {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -597,7 +604,29 @@ impl Session {
|
|||||||
if threshold < 1 {
|
if threshold < 1 {
|
||||||
return false;
|
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 {
|
pub fn compressing(&self) -> bool {
|
||||||
@@ -609,16 +638,25 @@ impl Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn compress(&mut self, mut prompt: String, keep_last: usize) {
|
pub fn compress(&mut self, mut prompt: String, keep_last: usize) {
|
||||||
if let Some(system_prompt) = self.messages.first().and_then(|v| {
|
// Capture the session's original system prompt the first time we compress.
|
||||||
if MessageRole::System == v.role {
|
// On subsequent compressions `messages[0]` is the summary message built
|
||||||
let content = v.content.to_text();
|
// below; re-reading it would re-embed every prior recap+summary, growing
|
||||||
if !content.is_empty() {
|
// the message — and the post-compression token floor — without bound.
|
||||||
return Some(content);
|
if self.compressed_system_prompt.is_none() {
|
||||||
}
|
let system_prompt = self
|
||||||
}
|
.messages
|
||||||
None
|
.first()
|
||||||
}) {
|
.filter(|v| v.role == MessageRole::System)
|
||||||
prompt = format!("{system_prompt}\n\n{prompt}",);
|
.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() {
|
let messages_to_keep = if keep_last > 0 && keep_last < self.messages.len() {
|
||||||
self.messages.split_off(self.messages.len() - keep_last)
|
self.messages.split_off(self.messages.len() - keep_last)
|
||||||
@@ -804,6 +842,7 @@ impl Session {
|
|||||||
pub fn clear_messages(&mut self) {
|
pub fn clear_messages(&mut self) {
|
||||||
self.messages.clear();
|
self.messages.clear();
|
||||||
self.compressed_messages.clear();
|
self.compressed_messages.clear();
|
||||||
|
self.compressed_system_prompt = None;
|
||||||
self.data_urls.clear();
|
self.data_urls.clear();
|
||||||
self.autoname = None;
|
self.autoname = None;
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
@@ -1205,7 +1244,7 @@ mod tests {
|
|||||||
fn session_needs_compression_threshold() {
|
fn session_needs_compression_threshold() {
|
||||||
let session = Session::default();
|
let session = Session::default();
|
||||||
|
|
||||||
assert!(!session.needs_compression(4000));
|
assert!(!session.needs_compression(4000, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1214,14 +1253,36 @@ mod tests {
|
|||||||
|
|
||||||
session.set_compressing(true);
|
session.set_compressing(true);
|
||||||
|
|
||||||
assert!(!session.needs_compression(0));
|
assert!(!session.needs_compression(0, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn session_needs_compression_returns_false_when_threshold_zero() {
|
fn session_needs_compression_returns_false_when_threshold_zero() {
|
||||||
let session = Session::default();
|
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]
|
#[test]
|
||||||
@@ -1276,6 +1337,57 @@ mod tests {
|
|||||||
assert!(session.dirty());
|
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]
|
#[test]
|
||||||
fn session_is_not_empty_after_compress() {
|
fn session_is_not_empty_after_compress() {
|
||||||
let mut session = Session::default();
|
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
|
let needs_compression = ctx
|
||||||
.session
|
.session
|
||||||
.as_ref()
|
.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 {
|
if needs_compression {
|
||||||
let agent_can_continue_after_compress =
|
let agent_can_continue_after_compress =
|
||||||
|
|||||||
Reference in new issue
Block a user