Harden local tool execution
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user