Harden local tool execution

This commit is contained in:
Georg Bauer
2026-07-26 14:36:38 +02:00
parent 171b041ba6
commit c9f0c3661c
7 changed files with 1018 additions and 141 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -103,6 +103,11 @@ pub(crate) struct App {
#[cfg(target_os = "macos")]
active_tools: Option<crate::agent::ActiveTools>,
#[cfg(target_os = "macos")]
pub(super) tool_cards: Vec<crate::agent::ToolCard>,
#[cfg(target_os = "macos")]
pub(super) pending_tool_approval:
Option<(crate::agent::ApprovalPrompt, std::sync::mpsc::Sender<bool>)>,
#[cfg(target_os = "macos")]
active_titling: Option<generation::TitleRequest>,
#[cfg(target_os = "macos")]
runtime_config: Arc<RwLock<Config>>,
@@ -199,6 +204,10 @@ pub(crate) enum Message {
ComposerChanged(String),
ToggleReasoning(usize),
OpenLink(markdown::Url),
CopyToolText(String),
OpenToolOutput(PathBuf),
AllowToolOnce,
DenyTool,
SubmitPrompt,
StopGeneration,
GenerationTick,
@@ -316,6 +325,10 @@ impl App {
#[cfg(target_os = "macos")]
active_tools: None,
#[cfg(target_os = "macos")]
tool_cards: Vec::new(),
#[cfg(target_os = "macos")]
pending_tool_approval: None,
#[cfg(target_os = "macos")]
active_titling: None,
#[cfg(target_os = "macos")]
runtime_config,
@@ -415,6 +428,10 @@ impl App {
#[cfg(target_os = "macos")]
active_tools: None,
#[cfg(target_os = "macos")]
tool_cards: Vec::new(),
#[cfg(target_os = "macos")]
pending_tool_approval: None,
#[cfg(target_os = "macos")]
active_titling: None,
#[cfg(target_os = "macos")]
runtime_config,
@@ -778,6 +795,25 @@ impl App {
self.error = Some(format!("Could not open the link: {error}"));
}
}
Message::CopyToolText(value) => return iced::clipboard::write(value),
Message::OpenToolOutput(path) => {
if let Err(error) = std::process::Command::new("open").arg(path).spawn() {
self.error = Some(format!("Could not open the tool output: {error}"));
}
}
Message::AllowToolOnce => {
#[cfg(target_os = "macos")]
if let Some((_, decision)) = self.pending_tool_approval.take() {
let _ = decision.send(true);
}
}
Message::DenyTool =>
{
#[cfg(target_os = "macos")]
if let Some((_, decision)) = self.pending_tool_approval.take() {
let _ = decision.send(false);
}
}
Message::SubmitPrompt => {
self.start_generation();
return scroll_chat_to_end();
@@ -794,6 +830,12 @@ impl App {
active.cancel.store(true, Ordering::Relaxed);
}
#[cfg(target_os = "macos")]
if let Some((_, decision)) = self.pending_tool_approval.take() {
let _ = decision.send(false);
}
#[cfg(target_os = "macos")]
self.stop_agent_jobs();
#[cfg(target_os = "macos")]
if let Some(compaction) = &self.active_compaction {
compaction.active.cancel.store(true, Ordering::Relaxed);
}
@@ -1019,6 +1061,10 @@ impl App {
if self.selected_session == Some(session_id) {
return Task::none();
}
#[cfg(target_os = "macos")]
self.stop_agent_jobs();
#[cfg(target_os = "macos")]
self.tool_cards.clear();
let saved_context = self
.projects
.iter()
@@ -1298,6 +1344,10 @@ impl Drop for App {
if let Some(active) = &self.active_tools {
active.cancel.store(true, Ordering::Relaxed);
}
#[cfg(target_os = "macos")]
if let Some((_, decision)) = self.pending_tool_approval.take() {
let _ = decision.send(false);
}
}
}

View File

@@ -210,6 +210,8 @@ impl App {
return;
}
#[cfg(target_os = "macos")]
self.tool_cards.clear();
#[cfg(target_os = "macos")]
if !std::mem::take(&mut self.skip_compaction_once)
&& crate::compaction::should_compact(self.context_used, self.context_limit)
{
@@ -388,11 +390,47 @@ impl App {
return self.poll_tool_result_check();
}
#[cfg(target_os = "macos")]
if self.active_tools.is_some() {
while let Some(event) = self
.active_tools
.as_ref()
.and_then(crate::agent::try_tool_event)
{
match event {
crate::agent::ToolEvent::State {
index,
state,
result,
} => {
if let Some(card) = self.tool_cards.get_mut(index) {
card.state = state;
if result.is_some() {
card.result = result;
}
}
self.activity = Some(format!("Tool {} · {}", index + 1, state.label()));
}
crate::agent::ToolEvent::Approval {
index,
prompt,
decision,
} => {
if let Some(card) = self.tool_cards.get_mut(index) {
card.state = crate::agent::ToolLifecycle::AwaitingApproval;
}
self.pending_tool_approval = Some((prompt, decision));
self.activity = Some(format!("Tool {} · Awaiting approval", index + 1));
}
}
}
}
#[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;
self.pending_tool_approval = None;
if cancelled {
self.generating = false;
self.activity = Some("Stopped".into());
@@ -453,6 +491,13 @@ impl App {
&& !message.user
{
message.append(reasoning, &content);
if !reasoning
&& self.tool_cards.is_empty()
&& crate::agent::has_tool_markup(&message.content)
{
self.tool_cards.push(crate::agent::ToolCard::streaming());
self.activity = Some("Parsing tool call…".into());
}
transcript_changed = true;
}
}
@@ -501,6 +546,7 @@ impl App {
Ok(_) => {
self.generating = false;
self.activity = None;
self.tool_cards.clear();
start_queued = !self.queued_inputs.is_empty()
|| self.manual_compaction_queued;
}
@@ -628,8 +674,13 @@ impl App {
self.agent_tools = Some((session_id, Arc::new(Mutex::new(tools))));
}
let tools = Arc::clone(&self.agent_tools.as_ref().unwrap().1);
self.tool_cards = calls
.iter()
.cloned()
.map(crate::agent::ToolCard::parsing)
.collect();
self.active_tools = Some(crate::agent::execute_async(tools, calls));
self.activity = Some("Running tools…".into());
self.activity = Some("Parsing tool calls…".into());
Ok(())
}
@@ -685,6 +736,7 @@ impl App {
.collect();
assistant.reasoning_open = assistant_reasoning;
self.conversation.push(assistant);
self.tool_cards.clear();
let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60);
self.active_generation = Some(
self.generation_service
@@ -704,6 +756,20 @@ impl App {
Ok(())
}
#[cfg(target_os = "macos")]
pub(super) fn stop_agent_jobs(&mut self) {
let Some((_, tools)) = &self.agent_tools else {
return;
};
let Ok(mut tools) = tools.try_lock() else {
return;
};
let failures = tools.stop_all_jobs();
if !failures.is_empty() {
self.error = Some(failures.join("\n"));
}
}
#[cfg(target_os = "macos")]
fn start_tool_result_check(
&mut self,

View File

@@ -69,6 +69,10 @@ impl App {
if self.database.is_none() {
return;
}
#[cfg(target_os = "macos")]
self.stop_agent_jobs();
#[cfg(target_os = "macos")]
self.tool_cards.clear();
let title = draft_title(&self.projects, project_id);
self.drafts.entry(project_id).or_insert(title);
self.remember_project(project_id);

View File

@@ -69,6 +69,16 @@ impl App {
|| self.pending_project_path.is_some()
|| self.session_rename.is_some()
|| self.menu_session().is_some()
|| {
#[cfg(target_os = "macos")]
{
self.pending_tool_approval.is_some()
}
#[cfg(not(target_os = "macos"))]
{
false
}
}
}
fn main_view(&self) -> Element<'_, Message> {
@@ -112,6 +122,19 @@ impl App {
let content: Element<'_, Message> = shell.into();
let mut layers = vec![content];
#[cfg(target_os = "macos")]
if let Some((prompt, _)) = &self.pending_tool_approval {
layers.push(self.tool_approval_panel(prompt));
} else if self.preferences_open {
layers.push(self.preferences_panel());
} else if let Some(path) = &self.pending_project_path {
layers.push(self.project_dialog(path));
} else if let Some((_, title)) = &self.session_rename {
layers.push(self.rename_dialog(title));
} else if let Some(session) = self.menu_session() {
layers.push(self.session_menu_panel(session));
}
#[cfg(not(target_os = "macos"))]
if self.preferences_open {
layers.push(self.preferences_panel());
} else if let Some(path) = &self.pending_project_path {
@@ -128,6 +151,39 @@ impl App {
.into()
}
#[cfg(target_os = "macos")]
fn tool_approval_panel<'a>(
&'a self,
prompt: &'a crate::agent::ApprovalPrompt,
) -> Element<'a, Message> {
let dialog = container(
column![
text(&prompt.title).size(22),
text(&prompt.detail).size(13),
text("Working directory").size(11).color(muted_text()),
text(prompt.working_directory.display().to_string()).size(13),
row![
Space::with_width(Length::Fill),
action_button("Deny").on_press(Message::DenyTool),
action_button("Allow once").on_press(Message::AllowToolOnce),
]
.spacing(8),
]
.spacing(12),
)
.padding(22)
.width(560)
.style(overview_style);
opaque(
container(dialog)
.center_x(Length::Fill)
.center_y(Length::Fill)
.style(|_| {
container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68))
}),
)
}
fn sidebar(&self) -> Element<'_, Message> {
let preference_content = row![
icon(ICON_SETTINGS, 17),

View File

@@ -75,6 +75,17 @@ impl App {
if message.system {
continue;
}
if message.tool
&& index > 0
&& !crate::agent::stored_tool_cards(
self.config.model,
&self.conversation[index - 1].content,
None,
)
.is_empty()
{
continue;
}
let label = if message.user {
"You"
} else if message.tool {
@@ -135,11 +146,39 @@ impl App {
text(self.activity.as_deref().unwrap_or("Loading model…")).size(14),
);
}
if !message.user && !message.tool {
for summary in
crate::agent::tool_summaries(self.config.model, &message.content)
{
body = body.push(text(summary).size(13).color(muted_text()));
if !message.user {
let stored_result = self
.conversation
.get(index + 1)
.filter(|message| message.tool)
.map(|message| message.content.as_str());
let cards = if stored_result.is_none() && {
#[cfg(target_os = "macos")]
{
!self.tool_cards.is_empty()
}
#[cfg(not(target_os = "macos"))]
{
false
}
} {
#[cfg(target_os = "macos")]
{
self.tool_cards.clone()
}
#[cfg(not(target_os = "macos"))]
{
Vec::new()
}
} else {
crate::agent::stored_tool_cards(
self.config.model,
&message.content,
stored_result,
)
};
if !cards.is_empty() {
body = body.push(tool_cards(cards));
}
}
let user = message.user;
@@ -295,3 +334,80 @@ impl App {
.into()
}
}
fn tool_cards(cards: Vec<crate::agent::ToolCard>) -> Element<'static, Message> {
let mut rows = column![].spacing(0);
for (index, card) in cards.into_iter().enumerate() {
if index > 0 {
rows = rows.push(horizontal_rule(1));
}
let parameters = crate::agent::tool_parameters(&card.call);
let call = crate::agent::tool_call_text(&card.call);
let copy_call = tooltip(
action_button(text("Copy call").size(11))
.padding([5, 9])
.on_press(Message::CopyToolText(call)),
container(text("Copy tool name and all arguments").size(11))
.padding(8)
.style(preference_group_style),
tooltip::Position::Top,
)
.gap(6);
let copy_result = action_button(text("Copy result").size(11)).padding([5, 9]);
let copy_result = if let Some(result) = &card.result {
copy_result.on_press(Message::CopyToolText(result.clone()))
} else {
copy_result
};
let copy_result = tooltip(
copy_result,
container(text("Copy the complete tool result").size(11))
.padding(8)
.style(preference_group_style),
tooltip::Position::Top,
)
.gap(6);
let mut actions = row![copy_call, copy_result]
.spacing(6)
.align_y(Alignment::Center);
if let Some(path) = card
.result
.as_deref()
.and_then(crate::agent::tool_output_path)
{
actions = actions.push(
tooltip(
action_button(text("Open output").size(11))
.padding([5, 9])
.on_press(Message::OpenToolOutput(path)),
container(text("Open the complete output file").size(11))
.padding(8)
.style(preference_group_style),
tooltip::Position::Top,
)
.gap(6),
);
}
let mut content = column![
row![
text(card.call.name).size(13),
Space::with_width(Length::Fill),
text(card.state.label()).size(11).color(muted_text()),
actions,
]
.spacing(8)
.align_y(Alignment::Center),
text(parameters).size(12).color(muted_text()),
]
.spacing(5);
if let Some(result) = card.result {
let bounded = crate::agent::bounded_tool_text(&result, 1_200);
content = content.push(text(bounded).size(12));
}
rows = rows.push(container(content).padding(10).width(Length::Fill));
}
container(rows)
.width(Length::Fill)
.style(preference_group_style)
.into()
}