Add agent context compaction

This commit is contained in:
Georg Bauer
2026-07-26 10:48:29 +02:00
parent 1dcadeb882
commit 6aa45b2cf0
12 changed files with 625 additions and 2 deletions

View File

@@ -18,6 +18,18 @@ pub(super) struct TitleRequest {
content: String,
}
#[cfg(target_os = "macos")]
pub(super) enum PendingContinuation {
User(String),
Tool(String),
}
#[cfg(target_os = "macos")]
pub(super) struct CompactionRequest {
active: ActiveGeneration,
pending: PendingContinuation,
}
#[derive(Clone, Debug)]
pub(crate) struct ChatMessage {
pub(super) id: i32,
@@ -79,6 +91,18 @@ impl App {
if prompt.is_empty() {
return;
}
#[cfg(target_os = "macos")]
if !std::mem::take(&mut self.skip_compaction_once)
&& crate::compaction::should_compact(self.context_used, self.context_limit)
{
if let Err(error) = self.start_compaction(
PendingContinuation::User(prompt),
"soft limit before user turn",
) {
self.error = Some(error);
}
return;
}
let model = self.config.model;
let effective = crate::settings::effective_settings(
model,
@@ -95,6 +119,10 @@ impl App {
};
effective.turn.system_prompt =
crate::agent::system_prompt(model, &effective.turn.system_prompt);
effective.turn.system_prompt = crate::compaction::summary_system_prompt(
&effective.turn.system_prompt,
self.compaction_summary(),
);
let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct;
#[cfg(target_os = "macos")]
let mut messages = self
@@ -191,6 +219,10 @@ impl App {
}
pub(super) fn poll_generation(&mut self) -> bool {
#[cfg(target_os = "macos")]
if self.active_compaction.is_some() {
return self.poll_compaction();
}
#[cfg(target_os = "macos")]
if let Some(active) = &self.active_tools {
match crate::agent::try_tool_result(active) {
@@ -229,6 +261,11 @@ impl App {
loop {
match active.events.try_recv() {
Ok(GenerationEvent::Loading) => {}
Ok(GenerationEvent::Compacted(_)) => {
self.generating = false;
self.error =
Some("The model runtime returned an unexpected compaction event.".into());
}
Ok(GenerationEvent::Chunk { reasoning, content }) => {
if let Some(message) = self.conversation.last_mut()
&& !message.user
@@ -363,6 +400,18 @@ impl App {
let session_id = self
.selected_session
.ok_or_else(|| "The active session is unavailable.".to_owned())?;
if !std::mem::take(&mut self.skip_compaction_once)
&& crate::compaction::tool_result_needs_compaction(
self.context_used,
self.context_limit,
result,
)
{
return self.start_compaction(
PendingContinuation::Tool(result.to_owned()),
"context pressure before tool continuation",
);
}
let model = self.config.model;
let mut effective = crate::settings::effective_settings(
model,
@@ -372,6 +421,10 @@ impl App {
)?;
effective.turn.system_prompt =
crate::agent::system_prompt(model, &effective.turn.system_prompt);
effective.turn.system_prompt = crate::compaction::summary_system_prompt(
&effective.turn.system_prompt,
self.compaction_summary(),
);
let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct;
let saved = self
.database
@@ -412,6 +465,175 @@ impl App {
Ok(())
}
#[cfg(target_os = "macos")]
fn start_compaction(
&mut self,
pending: PendingContinuation,
reason: &str,
) -> Result<(), String> {
let model = self.config.model;
let mut effective = crate::settings::effective_settings(
model,
&self.config.generation,
&self.config.runtime,
&models_path(),
)?;
effective.turn.system_prompt =
crate::agent::system_prompt(model, &effective.turn.system_prompt);
effective.turn.system_prompt = crate::compaction::summary_system_prompt(
&effective.turn.system_prompt,
self.compaction_summary(),
);
let messages = self
.conversation
.iter()
.map(|message| ChatTurn {
user: message.user,
tool: message.tool,
skip_previous_eos: false,
reasoning: message.reasoning.clone(),
reasoning_complete: message.reasoning_complete,
content: message.content.clone(),
})
.collect();
let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60);
let active = self
.generation_service
.as_ref()
.ok_or_else(|| "The model runtime is unavailable.".to_owned())?
.compact(
effective.engine,
effective.turn,
messages,
reason,
idle_timeout,
)?;
self.active_compaction = Some(CompactionRequest { active, pending });
self.generating = true;
self.activity = Some("Compacting durable task state…".into());
self.tokens_per_second = None;
Ok(())
}
#[cfg(target_os = "macos")]
fn poll_compaction(&mut self) -> bool {
let Some(request) = &mut self.active_compaction else {
return false;
};
loop {
match request.active.events.try_recv() {
Ok(GenerationEvent::Loading) => self.activity = Some("Loading model…".into()),
Ok(GenerationEvent::Context {
used,
limit,
tokens_per_second,
}) => {
self.context_used = used;
self.context_limit = limit;
self.tokens_per_second = tokens_per_second;
}
Ok(GenerationEvent::Compacted(result)) => {
let request = self.active_compaction.take().unwrap();
match result {
Ok(compacted) => {
if let Err(error) = self.apply_compaction(&compacted) {
self.generating = false;
self.activity = None;
self.error = Some(error);
return false;
}
self.context_used = compacted.context_tokens;
self.generating = false;
self.activity = None;
match request.pending {
PendingContinuation::User(prompt) => {
self.composer = prompt;
self.skip_compaction_once = true;
self.start_generation();
}
PendingContinuation::Tool(result) => {
let result = crate::compaction::bounded_tool_result(
self.context_used,
self.context_limit,
result,
);
self.skip_compaction_once = true;
if let Err(error) = self.continue_after_tool_result(&result) {
self.generating = false;
self.error = Some(error);
}
}
}
return true;
}
Err(error) => {
self.generating = false;
self.activity = None;
self.error = Some(error);
return false;
}
}
}
Ok(GenerationEvent::Finished(_)) | Ok(GenerationEvent::Chunk { .. }) => {}
Err(TryRecvError::Empty) => return false,
Err(TryRecvError::Disconnected) => {
self.active_compaction = None;
self.generating = false;
self.activity = None;
self.error = Some("The model runtime stopped unexpectedly.".into());
return false;
}
}
}
}
#[cfg(target_os = "macos")]
fn apply_compaction(
&mut self,
compacted: &crate::engine::CompactionOutput,
) -> Result<(), String> {
let session_id = self
.selected_session
.ok_or_else(|| "The active session is unavailable.".to_owned())?;
let tail = compacted
.tail
.iter()
.map(|message| crate::database::MessageDraft {
user: message.user,
tool: message.tool,
reasoning: message.reasoning.clone(),
reasoning_complete: message.reasoning_complete,
content: message.content.clone(),
})
.collect::<Vec<_>>();
let messages = self
.database
.as_mut()
.ok_or_else(|| "The project database is unavailable.".to_owned())?
.replace_with_compacted_transcript(session_id, &compacted.summary, &tail)
.map_err(|error| format!("Could not save compacted conversation: {error}"))?;
self.conversation = messages.into_iter().map(ChatMessage::from).collect();
if let Some(session) = self
.projects
.iter_mut()
.flat_map(|project| &mut project.sessions)
.find(|session| session.id == session_id)
{
session.compacted_summary = Some(compacted.summary.clone());
}
let _ = fs::remove_file(session_checkpoint_path(session_id));
Ok(())
}
pub(super) fn compaction_summary(&self) -> Option<&str> {
let session_id = self.selected_session?;
self.projects
.iter()
.flat_map(|project| &project.sessions)
.find(|session| session.id == session_id)
.and_then(|session| session.compacted_summary.as_deref())
}
/// Asks the model for a session title without opening a session for it: the
/// turn runs against the shared transient KV cache and only its text is kept.
///

View File

@@ -56,6 +56,20 @@ impl App {
);
} else {
let markdown_style = markdown::Style::from_palette(app_theme().palette());
if let Some(summary) = self.compaction_summary() {
messages = messages.push(
container(
column![
text("Compacted task state").size(11),
text(summary).size(13).color(muted_text()),
]
.spacing(5),
)
.padding(14)
.width(Length::Fill)
.style(preference_group_style),
);
}
for (index, message) in self.conversation.iter().enumerate() {
let label = if message.user {
"You"
@@ -113,7 +127,9 @@ impl App {
);
}
} else if active && message.reasoning.is_none() {
body = body.push(text("Loading model…").size(14));
body = body.push(
text(self.activity.as_deref().unwrap_or("Loading model…")).size(14),
);
}
if !message.user && !message.tool {
for summary in