Add agent context compaction
This commit is contained in:
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user