Finish long-running agent parity
This commit is contained in:
@@ -29,6 +29,22 @@ pub(super) enum PendingContinuation {
|
||||
pub(super) struct CompactionRequest {
|
||||
pub(super) active: ActiveGeneration,
|
||||
pending: PendingContinuation,
|
||||
message_ids: Vec<i32>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[derive(Clone, Copy)]
|
||||
enum ToolCheckStage {
|
||||
Initial,
|
||||
AfterCompaction,
|
||||
BoundedError,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) struct ToolResultCheck {
|
||||
pub(super) active: ActiveGeneration,
|
||||
result: String,
|
||||
stage: ToolCheckStage,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -37,6 +53,8 @@ pub(crate) struct ChatMessage {
|
||||
pub(super) user: bool,
|
||||
pub(super) tool: bool,
|
||||
pub(super) system: bool,
|
||||
pub(super) compaction: bool,
|
||||
pub(super) compaction_tail_start: Option<i32>,
|
||||
pub(super) reasoning: Option<String>,
|
||||
pub(super) reasoning_complete: bool,
|
||||
pub(super) reasoning_open: bool,
|
||||
@@ -74,6 +92,8 @@ impl From<StoredMessage> for ChatMessage {
|
||||
user: message.user,
|
||||
tool: message.tool,
|
||||
system: message.system,
|
||||
compaction: message.compaction,
|
||||
compaction_tail_start: message.compaction_tail_start,
|
||||
reasoning: message.reasoning,
|
||||
reasoning_complete: message.reasoning_complete,
|
||||
reasoning_open: false,
|
||||
@@ -85,7 +105,75 @@ impl From<StoredMessage> for ChatMessage {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn chat_turn(message: &ChatMessage) -> ChatTurn {
|
||||
ChatTurn {
|
||||
user: message.user,
|
||||
tool: message.tool,
|
||||
system: message.system,
|
||||
skip_previous_eos: false,
|
||||
reasoning: message.reasoning.clone(),
|
||||
reasoning_complete: message.reasoning_complete,
|
||||
content: message.content.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn queued_prompt(messages: impl IntoIterator<Item = String>) -> Option<String> {
|
||||
let messages = messages.into_iter().collect::<Vec<_>>();
|
||||
match messages.as_slice() {
|
||||
[] => None,
|
||||
[message] => Some(message.clone()),
|
||||
_ => Some(
|
||||
messages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, message)| format!("Queued user message {}:\n{message}", index + 1))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn compacted_context_start(messages: &[ChatMessage]) -> usize {
|
||||
let Some(marker_index) = messages.iter().rposition(|message| message.compaction) else {
|
||||
return 0;
|
||||
};
|
||||
messages[marker_index]
|
||||
.compaction_tail_start
|
||||
.and_then(|id| {
|
||||
messages[..marker_index]
|
||||
.iter()
|
||||
.position(|message| message.id == id)
|
||||
})
|
||||
.unwrap_or(marker_index + 1)
|
||||
}
|
||||
|
||||
fn has_chat_after_last_compaction(messages: &[ChatMessage]) -> bool {
|
||||
let start = messages
|
||||
.iter()
|
||||
.rposition(|message| message.compaction)
|
||||
.map_or(0, |index| index + 1);
|
||||
messages[start..].iter().any(|message| {
|
||||
!message.system
|
||||
&& !message.compaction
|
||||
&& !message
|
||||
.content
|
||||
.starts_with(crate::agent::COMPACTION_OBSERVATION_PREFIX)
|
||||
&& (!message.content.trim().is_empty()
|
||||
|| message
|
||||
.reasoning
|
||||
.as_deref()
|
||||
.is_some_and(|reasoning| !reasoning.trim().is_empty()))
|
||||
})
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn can_compact_session(&self, session_id: i32) -> bool {
|
||||
!self.generating
|
||||
&& self.selected_session == Some(session_id)
|
||||
&& has_chat_after_last_compaction(&self.conversation)
|
||||
}
|
||||
|
||||
pub(super) fn start_generation(&mut self) {
|
||||
if self.selected_project.is_none() {
|
||||
return;
|
||||
@@ -94,6 +182,19 @@ impl App {
|
||||
if prompt.is_empty() {
|
||||
return;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if prompt == "/compact" {
|
||||
self.composer.clear();
|
||||
if self.generating {
|
||||
self.manual_compaction_queued = true;
|
||||
self.activity = Some("Compaction queued for the next safe point…".into());
|
||||
} else if let Err(error) =
|
||||
self.start_compaction(PendingContinuation::None, "manual /compact request")
|
||||
{
|
||||
self.error = Some(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.generating {
|
||||
self.queued_inputs.push_back(prompt);
|
||||
self.composer.clear();
|
||||
@@ -109,16 +210,6 @@ impl App {
|
||||
return;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if prompt == "/compact" {
|
||||
self.composer.clear();
|
||||
if let Err(error) =
|
||||
self.start_compaction(PendingContinuation::None, "manual /compact request")
|
||||
{
|
||||
self.error = Some(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if !std::mem::take(&mut self.skip_compaction_once)
|
||||
&& crate::compaction::should_compact(self.context_used, self.context_limit)
|
||||
{
|
||||
@@ -163,24 +254,13 @@ impl App {
|
||||
let reminder_injected = self.system_prompt_reminder_due();
|
||||
#[cfg(target_os = "macos")]
|
||||
if reminder_injected {
|
||||
injected_system.push(crate::agent::system_prompt_reminder(
|
||||
model,
|
||||
&self.config.generation.system_prompt,
|
||||
));
|
||||
injected_system.extend(self.system_prompt_reminders(model));
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut messages = self
|
||||
.conversation
|
||||
.iter()
|
||||
.map(|message| ChatTurn {
|
||||
user: message.user,
|
||||
tool: message.tool,
|
||||
system: message.system,
|
||||
skip_previous_eos: false,
|
||||
reasoning: message.reasoning.clone(),
|
||||
reasoning_complete: message.reasoning_complete,
|
||||
content: message.content.clone(),
|
||||
})
|
||||
.model_chat_messages()
|
||||
.into_iter()
|
||||
.map(chat_turn)
|
||||
.collect::<Vec<_>>();
|
||||
#[cfg(target_os = "macos")]
|
||||
messages.extend(injected_system.iter().map(|content| ChatTurn {
|
||||
@@ -290,12 +370,24 @@ impl App {
|
||||
crate::agent::prompt_reminder_due(self.context_used, self.system_prompt_seen_at)
|
||||
}
|
||||
|
||||
fn system_prompt_reminders(&self, model: ModelChoice) -> Vec<String> {
|
||||
let mut reminders = vec![crate::agent::system_prompt_reminder(model)];
|
||||
if !self.config.generation.system_prompt.trim().is_empty() {
|
||||
reminders.push(self.config.generation.system_prompt.clone());
|
||||
}
|
||||
reminders
|
||||
}
|
||||
|
||||
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 self.active_tool_check.is_some() {
|
||||
return self.poll_tool_result_check();
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(active) = &self.active_tools {
|
||||
match crate::agent::try_tool_result(active) {
|
||||
Ok(Some(result)) => {
|
||||
@@ -304,10 +396,21 @@ impl App {
|
||||
if cancelled {
|
||||
self.generating = false;
|
||||
self.activity = Some("Stopped".into());
|
||||
self.start_next_queued();
|
||||
return false;
|
||||
}
|
||||
if let Err(error) = self.continue_after_tool_result(&result) {
|
||||
let continuation = if self.manual_compaction_queued {
|
||||
self.manual_compaction_queued = false;
|
||||
self.start_compaction(
|
||||
PendingContinuation::Tool(result),
|
||||
"queued manual compaction",
|
||||
)
|
||||
} else {
|
||||
self.start_tool_result_check(result, ToolCheckStage::Initial)
|
||||
};
|
||||
if let Err(error) = continuation {
|
||||
self.generating = false;
|
||||
self.activity = Some("Failed".into());
|
||||
self.error = Some(error);
|
||||
}
|
||||
return true;
|
||||
@@ -337,6 +440,9 @@ impl App {
|
||||
loop {
|
||||
match active.events.try_recv() {
|
||||
Ok(GenerationEvent::Loading) => {}
|
||||
Ok(GenerationEvent::Activity(activity)) => {
|
||||
self.activity = Some(activity.into());
|
||||
}
|
||||
Ok(GenerationEvent::Compacted(_)) => {
|
||||
self.generating = false;
|
||||
self.error =
|
||||
@@ -358,6 +464,16 @@ impl App {
|
||||
self.context_used = used;
|
||||
self.context_limit = limit;
|
||||
self.tokens_per_second = tokens_per_second;
|
||||
if !self.stop_requested {
|
||||
self.activity = Some(
|
||||
if tokens_per_second.is_some() {
|
||||
"Generating…"
|
||||
} else {
|
||||
"Reading conversation…"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
context_changed = true;
|
||||
}
|
||||
Ok(GenerationEvent::Finished(result)) => {
|
||||
@@ -365,6 +481,8 @@ impl App {
|
||||
Ok(_) if self.stop_requested => {
|
||||
self.generating = false;
|
||||
self.activity = Some("Stopped".into());
|
||||
start_queued =
|
||||
!self.queued_inputs.is_empty() || self.manual_compaction_queued;
|
||||
}
|
||||
Ok(_) => {
|
||||
let model = self.config.model;
|
||||
@@ -383,7 +501,8 @@ impl App {
|
||||
Ok(_) => {
|
||||
self.generating = false;
|
||||
self.activity = None;
|
||||
start_queued = !self.queued_inputs.is_empty();
|
||||
start_queued = !self.queued_inputs.is_empty()
|
||||
|| self.manual_compaction_queued;
|
||||
}
|
||||
Err(error) => {
|
||||
self.active_tools = Some(crate::agent::error_async(error));
|
||||
@@ -403,6 +522,13 @@ impl App {
|
||||
self.active_generation = None;
|
||||
break;
|
||||
}
|
||||
Ok(GenerationEvent::Measured(_)) => {
|
||||
self.generating = false;
|
||||
self.activity = Some("Failed".into());
|
||||
self.error = Some(
|
||||
"The model runtime returned an unexpected context measurement.".into(),
|
||||
);
|
||||
}
|
||||
Err(TryRecvError::Empty) => break,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
self.generating = false;
|
||||
@@ -467,7 +593,17 @@ impl App {
|
||||
}
|
||||
|
||||
fn start_next_queued(&mut self) {
|
||||
if let Some(prompt) = self.queued_inputs.pop_front() {
|
||||
#[cfg(target_os = "macos")]
|
||||
if std::mem::take(&mut self.manual_compaction_queued) {
|
||||
if let Err(error) =
|
||||
self.start_compaction(PendingContinuation::None, "queued manual compaction")
|
||||
{
|
||||
self.activity = Some("Failed".into());
|
||||
self.error = Some(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(prompt) = queued_prompt(self.queued_inputs.drain(..)) {
|
||||
self.composer = prompt;
|
||||
self.start_generation();
|
||||
}
|
||||
@@ -493,6 +629,7 @@ impl App {
|
||||
}
|
||||
let tools = Arc::clone(&self.agent_tools.as_ref().unwrap().1);
|
||||
self.active_tools = Some(crate::agent::execute_async(tools, calls));
|
||||
self.activity = Some("Running tools…".into());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -501,18 +638,7 @@ 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",
|
||||
);
|
||||
}
|
||||
self.skip_compaction_once = false;
|
||||
let model = self.config.model;
|
||||
let mut effective = crate::settings::effective_settings(
|
||||
model,
|
||||
@@ -527,10 +653,12 @@ impl App {
|
||||
self.compaction_summary(),
|
||||
);
|
||||
let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct;
|
||||
let queued = self.queued_inputs.iter().cloned().collect::<Vec<_>>();
|
||||
let reminder = self.system_prompt_reminder_due().then(|| {
|
||||
crate::agent::system_prompt_reminder(model, &self.config.generation.system_prompt)
|
||||
});
|
||||
let queued = queued_prompt(self.queued_inputs.drain(..));
|
||||
let reminders = if self.system_prompt_reminder_due() {
|
||||
self.system_prompt_reminders(model)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut saved = self
|
||||
.database
|
||||
.as_mut()
|
||||
@@ -538,13 +666,12 @@ impl App {
|
||||
.continue_tool_turn(
|
||||
session_id,
|
||||
result,
|
||||
&queued,
|
||||
reminder.as_deref(),
|
||||
queued.as_deref(),
|
||||
&reminders,
|
||||
assistant_reasoning,
|
||||
)
|
||||
.map_err(|error| format!("Could not save the tool turn: {error}"))?;
|
||||
self.queued_inputs.clear();
|
||||
if reminder.is_some() {
|
||||
if !reminders.is_empty() {
|
||||
self.system_prompt_seen_at = self.context_used;
|
||||
}
|
||||
let mut assistant = ChatMessage::from(saved.pop().unwrap());
|
||||
@@ -552,17 +679,9 @@ impl App {
|
||||
self.conversation.push(ChatMessage::from(message));
|
||||
}
|
||||
let messages = self
|
||||
.conversation
|
||||
.iter()
|
||||
.map(|message| ChatTurn {
|
||||
user: message.user,
|
||||
tool: message.tool,
|
||||
system: message.system,
|
||||
skip_previous_eos: false,
|
||||
reasoning: message.reasoning.clone(),
|
||||
reasoning_complete: message.reasoning_complete,
|
||||
content: message.content.clone(),
|
||||
})
|
||||
.model_chat_messages()
|
||||
.into_iter()
|
||||
.map(chat_turn)
|
||||
.collect();
|
||||
assistant.reasoning_open = assistant_reasoning;
|
||||
self.conversation.push(assistant);
|
||||
@@ -585,6 +704,163 @@ impl App {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn start_tool_result_check(
|
||||
&mut self,
|
||||
result: String,
|
||||
stage: ToolCheckStage,
|
||||
) -> Result<(), String> {
|
||||
if matches!(stage, ToolCheckStage::Initial)
|
||||
&& crate::compaction::should_compact(self.context_used, self.context_limit)
|
||||
{
|
||||
return self.start_compaction(
|
||||
PendingContinuation::Tool(result),
|
||||
"soft limit before tool continuation",
|
||||
);
|
||||
}
|
||||
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 mut messages = self
|
||||
.model_chat_messages()
|
||||
.into_iter()
|
||||
.map(chat_turn)
|
||||
.collect::<Vec<_>>();
|
||||
messages.push(ChatTurn {
|
||||
user: false,
|
||||
tool: true,
|
||||
system: false,
|
||||
skip_previous_eos: false,
|
||||
reasoning: None,
|
||||
reasoning_complete: true,
|
||||
content: result.clone(),
|
||||
});
|
||||
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())?
|
||||
.measure_context(effective.engine, effective.turn, messages, idle_timeout)?;
|
||||
self.active_tool_check = Some(ToolResultCheck {
|
||||
active,
|
||||
result,
|
||||
stage,
|
||||
});
|
||||
self.activity = Some("Checking tool result context…".into());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn poll_tool_result_check(&mut self) -> bool {
|
||||
let event = self
|
||||
.active_tool_check
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.active
|
||||
.events
|
||||
.try_recv();
|
||||
match event {
|
||||
Ok(GenerationEvent::Loading) => {
|
||||
self.activity = Some("Loading model…".into());
|
||||
false
|
||||
}
|
||||
Ok(GenerationEvent::Measured(Ok(projected))) => {
|
||||
let check = self.active_tool_check.take().unwrap();
|
||||
if self.manual_compaction_queued && matches!(check.stage, ToolCheckStage::Initial) {
|
||||
self.manual_compaction_queued = false;
|
||||
if let Err(error) = self.start_compaction(
|
||||
PendingContinuation::Tool(check.result),
|
||||
"queued manual compaction",
|
||||
) {
|
||||
self.generating = false;
|
||||
self.activity = Some("Failed".into());
|
||||
self.error = Some(error);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let reserve = if matches!(check.stage, ToolCheckStage::BoundedError) {
|
||||
16
|
||||
} else {
|
||||
crate::compaction::tool_result_reserve(self.context_limit)
|
||||
};
|
||||
if crate::compaction::tool_result_fits(projected, self.context_limit, reserve) {
|
||||
if let Err(error) = self.continue_after_tool_result(&check.result) {
|
||||
self.generating = false;
|
||||
self.activity = Some("Failed".into());
|
||||
self.error = Some(error);
|
||||
}
|
||||
} else {
|
||||
match check.stage {
|
||||
ToolCheckStage::Initial => {
|
||||
if let Err(error) = self.start_compaction(
|
||||
PendingContinuation::Tool(check.result),
|
||||
"tool result would exceed context",
|
||||
) {
|
||||
self.generating = false;
|
||||
self.activity = Some("Failed".into());
|
||||
self.error = Some(error);
|
||||
}
|
||||
}
|
||||
ToolCheckStage::AfterCompaction => {
|
||||
let error = crate::compaction::bounded_tool_error(
|
||||
projected,
|
||||
self.context_limit,
|
||||
reserve,
|
||||
);
|
||||
if let Err(error) =
|
||||
self.start_tool_result_check(error, ToolCheckStage::BoundedError)
|
||||
{
|
||||
self.generating = false;
|
||||
self.activity = Some("Failed".into());
|
||||
self.error = Some(error);
|
||||
}
|
||||
}
|
||||
ToolCheckStage::BoundedError => {
|
||||
self.generating = false;
|
||||
self.activity = Some("Failed".into());
|
||||
self.error = Some("context full after compaction".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
Ok(GenerationEvent::Measured(Err(error))) => {
|
||||
self.active_tool_check = None;
|
||||
self.generating = false;
|
||||
if self.stop_requested {
|
||||
self.activity = Some("Stopped".into());
|
||||
} else {
|
||||
self.activity = Some("Failed".into());
|
||||
self.error = Some(error);
|
||||
}
|
||||
false
|
||||
}
|
||||
Ok(GenerationEvent::Activity(activity)) => {
|
||||
self.activity = Some(activity.into());
|
||||
false
|
||||
}
|
||||
Ok(_) => false,
|
||||
Err(TryRecvError::Empty) => false,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
self.active_tool_check = None;
|
||||
self.generating = false;
|
||||
self.activity = Some("Failed".into());
|
||||
self.error = Some("The model runtime stopped unexpectedly.".into());
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) fn start_compaction(
|
||||
&mut self,
|
||||
@@ -600,23 +876,14 @@ impl App {
|
||||
)?;
|
||||
effective.turn.system_prompt =
|
||||
crate::agent::system_prompt(model, &effective.turn.system_prompt);
|
||||
let rebuild_system_prompt = effective.turn.system_prompt.clone();
|
||||
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,
|
||||
system: message.system,
|
||||
skip_previous_eos: false,
|
||||
reasoning: message.reasoning.clone(),
|
||||
reasoning_complete: message.reasoning_complete,
|
||||
content: message.content.clone(),
|
||||
})
|
||||
.collect();
|
||||
let model_messages = self.model_chat_messages();
|
||||
let message_ids = model_messages.iter().map(|message| message.id).collect();
|
||||
let messages = model_messages.into_iter().map(chat_turn).collect();
|
||||
let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60);
|
||||
let active = self
|
||||
.generation_service
|
||||
@@ -627,13 +894,19 @@ impl App {
|
||||
effective.turn,
|
||||
messages,
|
||||
reason,
|
||||
rebuild_system_prompt,
|
||||
session_compaction_checkpoint_path(
|
||||
self.selected_session
|
||||
.ok_or_else(|| "The active session is unavailable.".to_owned())?,
|
||||
),
|
||||
idle_timeout,
|
||||
)?;
|
||||
self.active_compaction = Some(CompactionRequest { active, pending });
|
||||
self.active_compaction = Some(CompactionRequest {
|
||||
active,
|
||||
pending,
|
||||
message_ids,
|
||||
});
|
||||
self.manual_compaction_queued = false;
|
||||
self.generating = true;
|
||||
self.activity = Some("Compacting durable task state…".into());
|
||||
self.tokens_per_second = None;
|
||||
@@ -648,6 +921,7 @@ impl App {
|
||||
loop {
|
||||
match request.active.events.try_recv() {
|
||||
Ok(GenerationEvent::Loading) => self.activity = Some("Loading model…".into()),
|
||||
Ok(GenerationEvent::Activity(activity)) => self.activity = Some(activity.into()),
|
||||
Ok(GenerationEvent::Context {
|
||||
used,
|
||||
limit,
|
||||
@@ -661,10 +935,12 @@ impl App {
|
||||
let request = self.active_compaction.take().unwrap();
|
||||
match result {
|
||||
Ok(compacted) => {
|
||||
if let Err(error) = self.apply_compaction(&compacted) {
|
||||
if let Err(error) =
|
||||
self.apply_compaction(&compacted, &request.message_ids)
|
||||
{
|
||||
let _ = fs::remove_file(&compacted.checkpoint);
|
||||
self.generating = false;
|
||||
self.activity = None;
|
||||
self.activity = Some("Failed".into());
|
||||
self.error = Some(error);
|
||||
return false;
|
||||
}
|
||||
@@ -680,14 +956,12 @@ impl App {
|
||||
self.start_generation();
|
||||
}
|
||||
PendingContinuation::Tool(result) => {
|
||||
let result = crate::compaction::bounded_tool_result(
|
||||
self.context_used,
|
||||
self.context_limit,
|
||||
if let Err(error) = self.start_tool_result_check(
|
||||
result,
|
||||
);
|
||||
self.skip_compaction_once = true;
|
||||
if let Err(error) = self.continue_after_tool_result(&result) {
|
||||
ToolCheckStage::AfterCompaction,
|
||||
) {
|
||||
self.generating = false;
|
||||
self.activity = Some("Failed".into());
|
||||
self.error = Some(error);
|
||||
}
|
||||
}
|
||||
@@ -696,6 +970,13 @@ impl App {
|
||||
}
|
||||
Err(error) => {
|
||||
self.generating = false;
|
||||
if let PendingContinuation::User(prompt) = request.pending {
|
||||
if self.composer.trim().is_empty() {
|
||||
self.composer = prompt;
|
||||
} else {
|
||||
self.queued_inputs.push_front(prompt);
|
||||
}
|
||||
}
|
||||
if self.stop_requested {
|
||||
self.activity = Some("Stopped".into());
|
||||
} else {
|
||||
@@ -706,12 +987,14 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(GenerationEvent::Finished(_)) | Ok(GenerationEvent::Chunk { .. }) => {}
|
||||
Ok(GenerationEvent::Finished(_))
|
||||
| Ok(GenerationEvent::Chunk { .. })
|
||||
| Ok(GenerationEvent::Measured(_)) => {}
|
||||
Err(TryRecvError::Empty) => return false,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
self.active_compaction = None;
|
||||
self.generating = false;
|
||||
self.activity = None;
|
||||
self.activity = Some("Failed".into());
|
||||
self.error = Some("The model runtime stopped unexpectedly.".into());
|
||||
return false;
|
||||
}
|
||||
@@ -723,51 +1006,37 @@ impl App {
|
||||
fn apply_compaction(
|
||||
&mut self,
|
||||
compacted: &crate::engine::CompactionOutput,
|
||||
message_ids: &[i32],
|
||||
) -> Result<(), String> {
|
||||
let session_id = self
|
||||
.selected_session
|
||||
.ok_or_else(|| "The active session is unavailable.".to_owned())?;
|
||||
let mut tail = compacted
|
||||
.tail
|
||||
.iter()
|
||||
.map(|message| crate::database::MessageDraft {
|
||||
user: message.user,
|
||||
tool: message.tool,
|
||||
system: message.system,
|
||||
reasoning: message.reasoning.clone(),
|
||||
reasoning_complete: message.reasoning_complete,
|
||||
content: message.content.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if let Some((tools_session, tools)) = &self.agent_tools
|
||||
let running_jobs = if let Some((tools_session, tools)) = &self.agent_tools
|
||||
&& *tools_session == session_id
|
||||
&& let Some(observation) = tools
|
||||
{
|
||||
tools
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.compaction_observation()
|
||||
{
|
||||
tail.push(crate::database::MessageDraft {
|
||||
user: false,
|
||||
tool: true,
|
||||
system: false,
|
||||
reasoning: None,
|
||||
reasoning_complete: true,
|
||||
content: observation,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let tail_start = message_ids.get(compacted.tail_start).copied();
|
||||
let messages = self
|
||||
.database
|
||||
.as_mut()
|
||||
.ok_or_else(|| "The project database is unavailable.".to_owned())?
|
||||
.replace_with_compacted_transcript(
|
||||
.record_compaction(
|
||||
session_id,
|
||||
&compacted.summary,
|
||||
&tail,
|
||||
tail_start,
|
||||
running_jobs.as_deref(),
|
||||
compacted.context_tokens,
|
||||
self.context_limit,
|
||||
)
|
||||
.map_err(|error| format!("Could not save compacted conversation: {error}"))?;
|
||||
self.conversation = messages.into_iter().map(ChatMessage::from).collect();
|
||||
self.conversation
|
||||
.extend(messages.into_iter().map(ChatMessage::from));
|
||||
if let Some(session) = self
|
||||
.projects
|
||||
.iter_mut()
|
||||
@@ -780,15 +1049,24 @@ impl App {
|
||||
session.last_tokens_per_second = None;
|
||||
}
|
||||
let final_checkpoint = session_checkpoint_path(session_id);
|
||||
fs::rename(&compacted.checkpoint, &final_checkpoint).map_err(|error| {
|
||||
format!(
|
||||
if let Err(error) = fs::rename(&compacted.checkpoint, &final_checkpoint) {
|
||||
let _ = fs::remove_file(&compacted.checkpoint);
|
||||
self.error = Some(format!(
|
||||
"The compacted conversation was saved, but its checkpoint could not be promoted: {error}. It will rebuild on next use."
|
||||
)
|
||||
})?;
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn compaction_summary(&self) -> Option<&str> {
|
||||
if let Some(summary) = self
|
||||
.conversation
|
||||
.iter()
|
||||
.rfind(|message| message.compaction)
|
||||
.map(|message| message.content.as_str())
|
||||
{
|
||||
return Some(summary);
|
||||
}
|
||||
let session_id = self.selected_session?;
|
||||
self.projects
|
||||
.iter()
|
||||
@@ -797,6 +1075,14 @@ impl App {
|
||||
.and_then(|session| session.compacted_summary.as_deref())
|
||||
}
|
||||
|
||||
fn model_chat_messages(&self) -> Vec<&ChatMessage> {
|
||||
let start = compacted_context_start(&self.conversation);
|
||||
self.conversation[start..]
|
||||
.iter()
|
||||
.filter(|message| !message.compaction)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
@@ -826,7 +1112,7 @@ impl App {
|
||||
// pass summarize the user's opening message on its own.
|
||||
let mut messages = stored
|
||||
.into_iter()
|
||||
.filter(|message| !message.content.trim().is_empty())
|
||||
.filter(|message| !message.compaction && !message.content.trim().is_empty())
|
||||
.map(|message| ChatTurn {
|
||||
user: message.user,
|
||||
tool: message.tool,
|
||||
@@ -980,3 +1266,89 @@ pub(super) fn session_title(reply: &str) -> Option<String> {
|
||||
None => title.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ChatMessage, compacted_context_start, has_chat_after_last_compaction, queued_prompt,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn queued_guidance_is_one_reference_style_user_turn() {
|
||||
assert_eq!(queued_prompt(Vec::new()), None);
|
||||
assert_eq!(queued_prompt(["one".to_owned()]), Some("one".to_owned()));
|
||||
assert_eq!(
|
||||
queued_prompt(["one".to_owned(), "two".to_owned()]),
|
||||
Some("Queued user message 1:\none\n\nQueued user message 2:\ntwo".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_compaction_selects_its_tail_without_hiding_history() {
|
||||
let message = |id: i32, compaction: bool, tail: Option<i32>| ChatMessage {
|
||||
id,
|
||||
user: !compaction,
|
||||
tool: false,
|
||||
system: compaction,
|
||||
compaction,
|
||||
compaction_tail_start: tail,
|
||||
reasoning: None,
|
||||
reasoning_complete: true,
|
||||
reasoning_open: false,
|
||||
content: format!("message {id}"),
|
||||
markdown: Vec::new(),
|
||||
};
|
||||
let history = vec![
|
||||
message(1, false, None),
|
||||
message(2, false, None),
|
||||
message(3, true, Some(2)),
|
||||
message(4, false, None),
|
||||
message(5, true, Some(4)),
|
||||
message(6, false, None),
|
||||
message(7, true, Some(6)),
|
||||
message(8, false, None),
|
||||
];
|
||||
|
||||
assert_eq!(history.len(), 8);
|
||||
assert_eq!(compacted_context_start(&history), 5);
|
||||
assert_eq!(history[compacted_context_start(&history)].id, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_compaction_requires_new_visible_chat() {
|
||||
let message =
|
||||
|id: i32, user: bool, tool: bool, system: bool, compaction: bool| ChatMessage {
|
||||
id,
|
||||
user,
|
||||
tool,
|
||||
system,
|
||||
compaction,
|
||||
compaction_tail_start: None,
|
||||
reasoning: None,
|
||||
reasoning_complete: true,
|
||||
reasoning_open: false,
|
||||
content: format!("message {id}"),
|
||||
markdown: Vec::new(),
|
||||
};
|
||||
let mut history = vec![
|
||||
message(1, true, false, false, false),
|
||||
message(2, false, false, true, true),
|
||||
ChatMessage {
|
||||
content: format!(
|
||||
"{} running job",
|
||||
crate::agent::COMPACTION_OBSERVATION_PREFIX
|
||||
),
|
||||
..message(3, false, true, false, false)
|
||||
},
|
||||
message(4, false, false, true, false),
|
||||
];
|
||||
|
||||
assert!(!has_chat_after_last_compaction(&history));
|
||||
history.push(message(5, false, true, false, false));
|
||||
assert!(has_chat_after_last_compaction(&history));
|
||||
history.push(message(6, false, false, true, true));
|
||||
assert!(!has_chat_after_last_compaction(&history));
|
||||
history.push(message(7, true, false, false, false));
|
||||
assert!(has_chat_after_last_compaction(&history));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user