Add persistent local agent tools

This commit is contained in:
Georg Bauer
2026-07-25 14:57:17 +02:00
parent 668d8b787e
commit 4a45735072
20 changed files with 3139 additions and 37 deletions

View File

@@ -22,6 +22,7 @@ pub(super) struct TitleRequest {
pub(crate) struct ChatMessage {
pub(super) id: i32,
pub(super) user: bool,
pub(super) tool: bool,
pub(super) reasoning: Option<String>,
pub(super) reasoning_complete: bool,
pub(super) reasoning_open: bool,
@@ -40,11 +41,12 @@ impl ChatMessage {
}
pub(super) fn refresh_markdown(&mut self) {
if !self.user {
if !self.user && !self.tool {
let visible = crate::agent::visible_content(&self.content);
let content = if self.reasoning.is_some() {
self.content.trim_start()
visible.trim_start()
} else {
&self.content
visible
};
self.markdown = markdown::parse(content).collect();
}
@@ -56,6 +58,7 @@ impl From<StoredMessage> for ChatMessage {
let mut message = Self {
id: message.id,
user: message.user,
tool: message.tool,
reasoning: message.reasoning,
reasoning_complete: message.reasoning_complete,
reasoning_open: false,
@@ -88,13 +91,15 @@ impl App {
crate::settings::effective_settings(model, &generation, &runtime, &models_path())
})
});
let effective = match effective {
let mut effective = match effective {
Ok(settings) => settings,
Err(error) => {
self.error = Some(error);
return;
}
};
effective.turn.system_prompt =
crate::agent::system_prompt(model, &effective.turn.system_prompt);
let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct;
#[cfg(target_os = "macos")]
let mut messages = self
@@ -102,6 +107,7 @@ impl App {
.iter()
.map(|message| ChatTurn {
user: message.user,
tool: message.tool,
skip_previous_eos: false,
reasoning: message.reasoning.clone(),
reasoning_complete: message.reasoning_complete,
@@ -111,6 +117,7 @@ impl App {
#[cfg(target_os = "macos")]
messages.push(ChatTurn {
user: true,
tool: false,
skip_previous_eos: false,
reasoning: None,
reasoning_complete: true,
@@ -189,6 +196,31 @@ impl App {
}
pub(super) fn poll_generation(&mut self) -> bool {
#[cfg(target_os = "macos")]
if let Some(active) = &self.active_tools {
match crate::agent::try_tool_result(active) {
Ok(Some(result)) => {
let cancelled = active.cancel.load(Ordering::Relaxed);
self.active_tools = None;
if cancelled {
self.generating = false;
return false;
}
if let Err(error) = self.continue_after_tool_result(&result) {
self.generating = false;
self.error = Some(error);
}
return true;
}
Ok(None) => return false,
Err(error) => {
self.active_tools = None;
self.generating = false;
self.error = Some(error);
return false;
}
}
}
#[cfg(target_os = "macos")]
let Some(active) = &mut self.active_generation else {
self.generating = false;
@@ -221,9 +253,32 @@ impl App {
context_changed = true;
}
Ok(GenerationEvent::Finished(result)) => {
self.generating = false;
if let Err(error) = result {
self.error = Some(error);
match result {
Ok(_) => {
let model = ModelChoice::from_id(&self.preferences.selected_model)
.unwrap_or_default();
let content = self
.conversation
.last()
.map(|message| message.content.clone())
.unwrap_or_default();
match crate::agent::parse_tool_calls(model, &content) {
Ok((_, calls)) if !calls.is_empty() => {
if let Err(error) = self.start_agent_tools(calls) {
self.generating = false;
self.error = Some(error);
}
}
Ok(_) => self.generating = false,
Err(error) => {
self.active_tools = Some(crate::agent::error_async(error));
}
}
}
Err(error) => {
self.generating = false;
self.error = Some(error);
}
}
self.active_generation = None;
break;
@@ -286,6 +341,83 @@ impl App {
false
}
#[cfg(target_os = "macos")]
fn start_agent_tools(&mut self, calls: Vec<crate::agent::ToolCall>) -> Result<(), String> {
let session_id = self
.selected_session
.ok_or_else(|| "The active session is unavailable.".to_owned())?;
if self.agent_tools.as_ref().map(|(id, _)| *id) != Some(session_id) {
let project_id = self
.selected_project
.ok_or_else(|| "The active project is unavailable.".to_owned())?;
let root = self
.projects
.iter()
.find(|project| project.project.id == project_id)
.map(|project| PathBuf::from(&project.project.path))
.ok_or_else(|| "The active project is unavailable.".to_owned())?;
let tools = crate::agent::Tools::new(&root, self.preferences.context_tokens)?;
self.agent_tools = Some((session_id, Arc::new(Mutex::new(tools))));
}
let tools = Arc::clone(&self.agent_tools.as_ref().unwrap().1);
self.active_tools = Some(crate::agent::execute_async(tools, calls));
Ok(())
}
#[cfg(target_os = "macos")]
fn continue_after_tool_result(&mut self, result: &str) -> Result<(), String> {
let session_id = self
.selected_session
.ok_or_else(|| "The active session is unavailable.".to_owned())?;
let model = ModelChoice::from_id(&self.preferences.selected_model)
.ok_or_else(|| "The selected model is not supported.".to_owned())?;
let generation = self.preferences.generation()?;
let runtime = self.preferences.runtime()?;
let mut effective =
crate::settings::effective_settings(model, &generation, &runtime, &models_path())?;
effective.turn.system_prompt =
crate::agent::system_prompt(model, &effective.turn.system_prompt);
let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct;
let saved = self
.database
.as_mut()
.ok_or_else(|| "The project database is unavailable.".to_owned())?
.continue_tool_turn(session_id, result, assistant_reasoning)
.map_err(|error| format!("Could not save the tool turn: {error}"))?;
self.conversation.push(ChatMessage::from(saved.0));
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 mut assistant = ChatMessage::from(saved.1);
assistant.reasoning_open = assistant_reasoning;
self.conversation.push(assistant);
let idle_timeout =
Duration::from_secs(self.preferences.idle_timeout_minutes.max(1) as u64 * 60);
self.active_generation = Some(
self.generation_service
.as_ref()
.ok_or_else(|| "The model runtime is unavailable.".to_owned())?
.generate(
effective.engine,
effective.turn,
messages,
CheckpointTarget::Local(session_checkpoint_path(session_id)),
idle_timeout,
)?,
);
self.tokens_per_second = None;
Ok(())
}
/// 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.
///
@@ -318,6 +450,7 @@ impl App {
.filter(|message| !message.content.trim().is_empty())
.map(|message| ChatTurn {
user: message.user,
tool: message.tool,
skip_previous_eos: false,
reasoning: None,
reasoning_complete: true,
@@ -329,6 +462,7 @@ impl App {
}
messages.push(ChatTurn {
user: true,
tool: false,
skip_previous_eos: false,
reasoning: None,
reasoning_complete: true,

View File

@@ -57,7 +57,13 @@ impl App {
} else {
let markdown_style = markdown::Style::from_palette(app_theme().palette());
for (index, message) in self.conversation.iter().enumerate() {
let label = if message.user { "You" } else { "DS4" };
let label = if message.user {
"You"
} else if message.tool {
"Tool"
} else {
"DS4"
};
let active = self.generating && index + 1 == self.conversation.len();
let mut body = column![text(label).size(11)].spacing(5);
if let Some(reasoning) = &message.reasoning {
@@ -89,11 +95,11 @@ impl App {
}
}
if !message.content.is_empty() {
if message.user || message.markdown.is_empty() {
if message.user || message.tool || message.markdown.is_empty() {
let content = if message.reasoning.is_some() {
message.content.trim_start()
crate::agent::visible_content(&message.content).trim_start()
} else {
&message.content
crate::agent::visible_content(&message.content)
};
body = body.push(text(content).size(14));
} else {
@@ -109,6 +115,14 @@ impl App {
} else if active && message.reasoning.is_none() {
body = body.push(text("Loading model…").size(14));
}
if !message.user
&& !message.tool
&& let Some(model) = ModelChoice::from_id(&self.preferences.selected_model)
{
for summary in crate::agent::tool_summaries(model, &message.content) {
body = body.push(text(summary).size(13).color(muted_text()));
}
}
let user = message.user;
messages = messages.push(
container(body)