Support long-running agent turns
This commit is contained in:
@@ -36,6 +36,7 @@ pub(crate) struct ChatMessage {
|
||||
pub(super) id: i32,
|
||||
pub(super) user: bool,
|
||||
pub(super) tool: bool,
|
||||
pub(super) system: bool,
|
||||
pub(super) reasoning: Option<String>,
|
||||
pub(super) reasoning_complete: bool,
|
||||
pub(super) reasoning_open: bool,
|
||||
@@ -72,6 +73,7 @@ impl From<StoredMessage> for ChatMessage {
|
||||
id: message.id,
|
||||
user: message.user,
|
||||
tool: message.tool,
|
||||
system: message.system,
|
||||
reasoning: message.reasoning,
|
||||
reasoning_complete: message.reasoning_complete,
|
||||
reasoning_open: false,
|
||||
@@ -85,13 +87,27 @@ impl From<StoredMessage> for ChatMessage {
|
||||
|
||||
impl App {
|
||||
pub(super) fn start_generation(&mut self) {
|
||||
if self.generating || self.selected_project.is_none() {
|
||||
if self.selected_project.is_none() {
|
||||
return;
|
||||
}
|
||||
let prompt = self.composer.trim().to_owned();
|
||||
if prompt.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.generating {
|
||||
self.queued_inputs.push_back(prompt);
|
||||
self.composer.clear();
|
||||
self.activity = Some(format!(
|
||||
"{} queued input{}",
|
||||
self.queued_inputs.len(),
|
||||
if self.queued_inputs.len() == 1 {
|
||||
""
|
||||
} else {
|
||||
"s"
|
||||
}
|
||||
));
|
||||
return;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if prompt == "/compact" {
|
||||
self.composer.clear();
|
||||
@@ -136,12 +152,30 @@ impl App {
|
||||
);
|
||||
let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct;
|
||||
#[cfg(target_os = "macos")]
|
||||
let opening_turn = self.selected_session.is_none();
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut injected_system = Vec::new();
|
||||
#[cfg(target_os = "macos")]
|
||||
if opening_turn {
|
||||
injected_system.push(crate::agent::datetime_context());
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
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,
|
||||
));
|
||||
}
|
||||
#[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,
|
||||
@@ -149,9 +183,20 @@ impl App {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
#[cfg(target_os = "macos")]
|
||||
messages.extend(injected_system.iter().map(|content| ChatTurn {
|
||||
user: false,
|
||||
tool: false,
|
||||
system: true,
|
||||
skip_previous_eos: false,
|
||||
reasoning: None,
|
||||
reasoning_complete: true,
|
||||
content: content.clone(),
|
||||
}));
|
||||
#[cfg(target_os = "macos")]
|
||||
messages.push(ChatTurn {
|
||||
user: true,
|
||||
tool: false,
|
||||
system: false,
|
||||
skip_previous_eos: false,
|
||||
reasoning: None,
|
||||
reasoning_complete: true,
|
||||
@@ -161,7 +206,6 @@ impl App {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// A draft session only reaches the database once there is a turn to store.
|
||||
let opening_turn = self.selected_session.is_none();
|
||||
let session_id = match self.selected_session {
|
||||
Some(session_id) => session_id,
|
||||
None => {
|
||||
@@ -184,13 +228,21 @@ impl App {
|
||||
let Some(database) = &mut self.database else {
|
||||
return;
|
||||
};
|
||||
let saved = match database.start_chat_turn(session_id, &prompt, assistant_reasoning) {
|
||||
let mut saved = match database.start_chat_turn(
|
||||
session_id,
|
||||
&prompt,
|
||||
&injected_system,
|
||||
assistant_reasoning,
|
||||
) {
|
||||
Ok(turn) => turn,
|
||||
Err(error) => {
|
||||
self.error = Some(format!("Could not save the chat turn: {error}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if reminder_injected {
|
||||
self.system_prompt_seen_at = self.context_used;
|
||||
}
|
||||
let idle_timeout =
|
||||
Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60);
|
||||
self.active_generation = match service.generate(
|
||||
@@ -207,13 +259,18 @@ impl App {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let user = ChatMessage::from(saved.0);
|
||||
let mut assistant = ChatMessage::from(saved.1);
|
||||
let mut assistant = ChatMessage::from(saved.pop().unwrap());
|
||||
let user = ChatMessage::from(saved.pop().unwrap());
|
||||
for message in saved {
|
||||
self.conversation.push(ChatMessage::from(message));
|
||||
}
|
||||
assistant.reasoning_open = assistant_reasoning;
|
||||
self.composer.clear();
|
||||
self.conversation.push(user);
|
||||
self.conversation.push(assistant);
|
||||
self.generating = true;
|
||||
self.stop_requested = false;
|
||||
self.activity = Some("Loading model…".into());
|
||||
self.tokens_per_second = None;
|
||||
self.error = None;
|
||||
if opening_turn {
|
||||
@@ -229,6 +286,10 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn system_prompt_reminder_due(&self) -> bool {
|
||||
crate::agent::prompt_reminder_due(self.context_used, self.system_prompt_seen_at)
|
||||
}
|
||||
|
||||
pub(super) fn poll_generation(&mut self) -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
if self.active_compaction.is_some() {
|
||||
@@ -242,6 +303,7 @@ impl App {
|
||||
self.active_tools = None;
|
||||
if cancelled {
|
||||
self.generating = false;
|
||||
self.activity = Some("Stopped".into());
|
||||
return false;
|
||||
}
|
||||
if let Err(error) = self.continue_after_tool_result(&result) {
|
||||
@@ -254,6 +316,7 @@ impl App {
|
||||
Err(error) => {
|
||||
self.active_tools = None;
|
||||
self.generating = false;
|
||||
self.activity = Some("Failed".into());
|
||||
self.error = Some(error);
|
||||
return false;
|
||||
}
|
||||
@@ -269,6 +332,8 @@ impl App {
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut context_changed = false;
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut start_queued = false;
|
||||
#[cfg(target_os = "macos")]
|
||||
loop {
|
||||
match active.events.try_recv() {
|
||||
Ok(GenerationEvent::Loading) => {}
|
||||
@@ -297,6 +362,10 @@ impl App {
|
||||
}
|
||||
Ok(GenerationEvent::Finished(result)) => {
|
||||
match result {
|
||||
Ok(_) if self.stop_requested => {
|
||||
self.generating = false;
|
||||
self.activity = Some("Stopped".into());
|
||||
}
|
||||
Ok(_) => {
|
||||
let model = self.config.model;
|
||||
let content = self
|
||||
@@ -311,14 +380,23 @@ impl App {
|
||||
self.error = Some(error);
|
||||
}
|
||||
}
|
||||
Ok(_) => self.generating = false,
|
||||
Ok(_) => {
|
||||
self.generating = false;
|
||||
self.activity = None;
|
||||
start_queued = !self.queued_inputs.is_empty();
|
||||
}
|
||||
Err(error) => {
|
||||
self.active_tools = Some(crate::agent::error_async(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) if self.stop_requested => {
|
||||
self.generating = false;
|
||||
self.activity = Some("Stopped".into());
|
||||
}
|
||||
Err(error) => {
|
||||
self.generating = false;
|
||||
self.activity = Some("Failed".into());
|
||||
self.error = Some(error);
|
||||
}
|
||||
}
|
||||
@@ -330,6 +408,7 @@ impl App {
|
||||
self.generating = false;
|
||||
self.active_generation = None;
|
||||
self.error = Some("The model runtime stopped unexpectedly.".into());
|
||||
self.activity = Some("Failed".into());
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -378,11 +457,22 @@ impl App {
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if start_queued {
|
||||
self.start_next_queued();
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
return transcript_changed;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
false
|
||||
}
|
||||
|
||||
fn start_next_queued(&mut self) {
|
||||
if let Some(prompt) = self.queued_inputs.pop_front() {
|
||||
self.composer = prompt;
|
||||
self.start_generation();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn start_agent_tools(&mut self, calls: Vec<crate::agent::ToolCall>) -> Result<(), String> {
|
||||
let session_id = self
|
||||
@@ -437,26 +527,43 @@ impl App {
|
||||
self.compaction_summary(),
|
||||
);
|
||||
let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct;
|
||||
let saved = self
|
||||
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 mut saved = self
|
||||
.database
|
||||
.as_mut()
|
||||
.ok_or_else(|| "The project database is unavailable.".to_owned())?
|
||||
.continue_tool_turn(session_id, result, assistant_reasoning)
|
||||
.continue_tool_turn(
|
||||
session_id,
|
||||
result,
|
||||
&queued,
|
||||
reminder.as_deref(),
|
||||
assistant_reasoning,
|
||||
)
|
||||
.map_err(|error| format!("Could not save the tool turn: {error}"))?;
|
||||
self.conversation.push(ChatMessage::from(saved.0));
|
||||
self.queued_inputs.clear();
|
||||
if reminder.is_some() {
|
||||
self.system_prompt_seen_at = self.context_used;
|
||||
}
|
||||
let mut assistant = ChatMessage::from(saved.pop().unwrap());
|
||||
for message in saved {
|
||||
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(),
|
||||
})
|
||||
.collect();
|
||||
let mut assistant = ChatMessage::from(saved.1);
|
||||
assistant.reasoning_open = assistant_reasoning;
|
||||
self.conversation.push(assistant);
|
||||
let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60);
|
||||
@@ -473,6 +580,8 @@ impl App {
|
||||
)?,
|
||||
);
|
||||
self.tokens_per_second = None;
|
||||
self.activity = Some("Continuing after tools…".into());
|
||||
self.stop_requested = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -501,6 +610,7 @@ impl App {
|
||||
.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,
|
||||
@@ -559,10 +669,11 @@ impl App {
|
||||
return false;
|
||||
}
|
||||
self.context_used = compacted.context_tokens;
|
||||
self.system_prompt_seen_at = compacted.context_tokens;
|
||||
self.generating = false;
|
||||
self.activity = None;
|
||||
match request.pending {
|
||||
PendingContinuation::None => {}
|
||||
PendingContinuation::None => self.start_next_queued(),
|
||||
PendingContinuation::User(prompt) => {
|
||||
self.composer = prompt;
|
||||
self.skip_compaction_once = true;
|
||||
@@ -585,8 +696,12 @@ impl App {
|
||||
}
|
||||
Err(error) => {
|
||||
self.generating = false;
|
||||
self.activity = None;
|
||||
self.error = Some(error);
|
||||
if self.stop_requested {
|
||||
self.activity = Some("Stopped".into());
|
||||
} else {
|
||||
self.activity = Some("Failed".into());
|
||||
self.error = Some(error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -618,6 +733,7 @@ impl App {
|
||||
.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(),
|
||||
@@ -633,6 +749,7 @@ impl App {
|
||||
tail.push(crate::database::MessageDraft {
|
||||
user: false,
|
||||
tool: true,
|
||||
system: false,
|
||||
reasoning: None,
|
||||
reasoning_complete: true,
|
||||
content: observation,
|
||||
@@ -713,6 +830,7 @@ impl App {
|
||||
.map(|message| ChatTurn {
|
||||
user: message.user,
|
||||
tool: message.tool,
|
||||
system: message.system,
|
||||
skip_previous_eos: false,
|
||||
reasoning: None,
|
||||
reasoning_complete: true,
|
||||
@@ -725,6 +843,7 @@ impl App {
|
||||
messages.push(ChatTurn {
|
||||
user: true,
|
||||
tool: false,
|
||||
system: false,
|
||||
skip_previous_eos: false,
|
||||
reasoning: None,
|
||||
reasoning_complete: true,
|
||||
|
||||
@@ -49,6 +49,7 @@ impl App {
|
||||
Ok(project) => {
|
||||
self.remember_project(project.id);
|
||||
self.selected_session = None;
|
||||
self.system_prompt_seen_at = 0;
|
||||
self.pending_project_path = None;
|
||||
self.project_name_input.clear();
|
||||
self.error = None;
|
||||
@@ -74,6 +75,8 @@ impl App {
|
||||
self.selected_session = None;
|
||||
self.conversation.clear();
|
||||
self.composer.clear();
|
||||
self.queued_inputs.clear();
|
||||
self.system_prompt_seen_at = 0;
|
||||
self.context_used = 0;
|
||||
self.context_limit = self.config.generation.context_tokens.max(0) as u32;
|
||||
self.tokens_per_second = None;
|
||||
@@ -84,6 +87,8 @@ impl App {
|
||||
if self.drafts.remove(&project_id).is_some() && self.draft_selected(project_id) {
|
||||
self.conversation.clear();
|
||||
self.composer.clear();
|
||||
self.queued_inputs.clear();
|
||||
self.system_prompt_seen_at = 0;
|
||||
self.context_used = 0;
|
||||
self.tokens_per_second = None;
|
||||
}
|
||||
|
||||
@@ -71,6 +71,9 @@ impl App {
|
||||
);
|
||||
}
|
||||
for (index, message) in self.conversation.iter().enumerate() {
|
||||
if message.system {
|
||||
continue;
|
||||
}
|
||||
let label = if message.user {
|
||||
"You"
|
||||
} else if message.tool {
|
||||
@@ -165,13 +168,20 @@ impl App {
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
} else {
|
||||
text_input("Ask DS4Server anything…", &self.composer)
|
||||
.id(composer_id())
|
||||
.on_input(Message::ComposerChanged)
|
||||
.on_submit(Message::SubmitPrompt)
|
||||
.padding(12)
|
||||
.size(14)
|
||||
.into()
|
||||
text_input(
|
||||
if self.generating {
|
||||
"Add guidance to the queue…"
|
||||
} else {
|
||||
"Ask DS4Server anything…"
|
||||
},
|
||||
&self.composer,
|
||||
)
|
||||
.id(composer_id())
|
||||
.on_input(Message::ComposerChanged)
|
||||
.on_submit(Message::SubmitPrompt)
|
||||
.padding(12)
|
||||
.size(14)
|
||||
.into()
|
||||
};
|
||||
let action = if self.generating {
|
||||
action_button(text("Stop").size(12)).on_press(Message::StopGeneration)
|
||||
@@ -187,50 +197,60 @@ impl App {
|
||||
} else {
|
||||
self.context_used.min(self.context_limit) as f32 / self.context_limit as f32
|
||||
};
|
||||
let mut composer_content = column![composer].spacing(6);
|
||||
for queued in &self.queued_inputs {
|
||||
let mut queued = queued.replace('\n', " ");
|
||||
if queued.chars().count() > 120 {
|
||||
queued = queued.chars().take(119).collect::<String>() + "…";
|
||||
}
|
||||
composer_content = composer_content.push(
|
||||
text(format!("Queued · {queued}"))
|
||||
.size(12)
|
||||
.color(muted_text()),
|
||||
);
|
||||
}
|
||||
composer_content =
|
||||
composer_content.push(
|
||||
row![
|
||||
icon(ICON_PAPERCLIP, 19),
|
||||
tooltip(
|
||||
context_pie(context_fraction, 19),
|
||||
container(
|
||||
text(format!(
|
||||
"{} / {} tokens ({:.0}%)",
|
||||
self.context_used,
|
||||
self.context_limit,
|
||||
context_fraction * 100.0
|
||||
))
|
||||
.size(12)
|
||||
)
|
||||
.padding(10)
|
||||
.style(preference_group_style),
|
||||
tooltip::Position::Top,
|
||||
)
|
||||
.gap(6),
|
||||
text(self.tokens_per_second.map_or_else(
|
||||
|| "— tok/s".to_owned(),
|
||||
|speed| format!("{speed:.1} tok/s")
|
||||
))
|
||||
.size(11)
|
||||
.color(muted_text()),
|
||||
Space::with_width(Length::Fill),
|
||||
icon(ICON_MODEL, 16),
|
||||
text(self.config.model.to_string()).size(12),
|
||||
action,
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(Alignment::Center),
|
||||
);
|
||||
let conversation = column![
|
||||
scrollable(messages)
|
||||
.id(chat_scroll_id())
|
||||
.height(Length::Fill),
|
||||
container(
|
||||
column![
|
||||
composer,
|
||||
row![
|
||||
icon(ICON_PAPERCLIP, 19),
|
||||
tooltip(
|
||||
context_pie(context_fraction, 19),
|
||||
container(
|
||||
text(format!(
|
||||
"{} / {} tokens ({:.0}%)",
|
||||
self.context_used,
|
||||
self.context_limit,
|
||||
context_fraction * 100.0
|
||||
))
|
||||
.size(12)
|
||||
)
|
||||
.padding(10)
|
||||
.style(preference_group_style),
|
||||
tooltip::Position::Top,
|
||||
)
|
||||
.gap(6),
|
||||
text(self.tokens_per_second.map_or_else(
|
||||
|| "— tok/s".to_owned(),
|
||||
|speed| format!("{speed:.1} tok/s")
|
||||
))
|
||||
.size(11)
|
||||
.color(muted_text()),
|
||||
Space::with_width(Length::Fill),
|
||||
icon(ICON_MODEL, 16),
|
||||
text(self.config.model.to_string()).size(12),
|
||||
action,
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(Alignment::Center),
|
||||
]
|
||||
.spacing(8),
|
||||
)
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
.style(overview_style),
|
||||
container(composer_content,)
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
.style(overview_style),
|
||||
]
|
||||
.height(Length::Fill)
|
||||
.spacing(8);
|
||||
|
||||
Reference in New Issue
Block a user