feat: record agent approvals

This commit is contained in:
Georg Bauer
2026-08-02 09:47:28 +02:00
parent 94ed73e235
commit 118a9d41e0
11 changed files with 154 additions and 19 deletions

3
assets/icons/robot.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 2.5v2M7.5 2.5h5M4 7.5A2.5 2.5 0 0 1 6.5 5h7A2.5 2.5 0 0 1 16 7.5v6a2.5 2.5 0 0 1-2.5 2.5h-7A2.5 2.5 0 0 1 4 13.5zM4 10H2.5M17.5 10H16M7.5 9.5h.01M12.5 9.5h.01M7.5 13h5"/>
</svg>

After

Width:  |  Height:  |  Size: 346 B

View File

@@ -0,0 +1 @@
ALTER TABLE messages DROP COLUMN tool_approval_reasons;

View File

@@ -0,0 +1 @@
ALTER TABLE messages ADD COLUMN tool_approval_reasons TEXT;

View File

@@ -129,6 +129,7 @@ pub(crate) struct ToolCard {
pub(crate) call: ToolCall, pub(crate) call: ToolCall,
pub(crate) state: ToolLifecycle, pub(crate) state: ToolLifecycle,
pub(crate) result: Option<String>, pub(crate) result: Option<String>,
pub(crate) approval_reason: Option<String>,
} }
impl ToolCard { impl ToolCard {
@@ -137,6 +138,7 @@ impl ToolCard {
call, call,
state: ToolLifecycle::Parsing, state: ToolLifecycle::Parsing,
result: None, result: None,
approval_reason: None,
} }
} }
@@ -179,24 +181,38 @@ impl ShellApprovalMode {
call: &ToolCall, call: &ToolCall,
root: &Path, root: &Path,
cancel: &AtomicBool, cancel: &AtomicBool,
) -> Option<ApprovalPrompt> { ) -> (Option<ApprovalPrompt>, Option<String>) {
let command = (call.name == "bash").then(|| string(call, "command"))??; let Some(command) = (call.name == "bash")
let reason = match self { .then(|| string(call, "command"))
Self::Heuristic => risky_shell_reason(command, root).map(str::to_owned), .flatten()
else {
return (None, None);
};
let (reason, ai_reason) = match self {
Self::Heuristic => (risky_shell_reason(command, root).map(str::to_owned), None),
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
Self::Ai(classifier) => match classifier.assess(command, root, cancel) { Self::Ai(classifier) => match classifier.assess(command, root, cancel) {
Ok(assessment) if !assessment.risky => None, Ok(assessment) => {
Ok(assessment) => Some(assessment.reason), let reason = (!assessment.reason.is_empty()).then_some(assessment.reason);
Err(error) => Some(format!( let approval = assessment.risky.then(|| reason.clone()).flatten();
"The AI risk check could not complete ({error}); approval is required." (approval, reason)
)), }
Err(error) => (
Some(format!(
"The AI risk check could not complete ({error}); approval is required."
)),
None,
),
}, },
}?; };
Some(ApprovalPrompt { (
title: "Allow shell command?".into(), reason.map(|reason| ApprovalPrompt {
detail: format!("{reason}\n\n{command}"), title: "Allow shell command?".into(),
working_directory: root.to_owned(), detail: format!("{reason}\n\n{command}"),
}) working_directory: root.to_owned(),
}),
ai_reason,
)
} }
} }
@@ -315,6 +331,10 @@ pub(crate) enum ToolEvent {
prompt: ApprovalPrompt, prompt: ApprovalPrompt,
decision: Sender<bool>, decision: Sender<bool>,
}, },
ApprovalReason {
index: usize,
reason: String,
},
} }
struct BashJob { struct BashJob {
@@ -1225,9 +1245,16 @@ pub(crate) fn execute_async(
if approval_mode.uses_ai(call) { if approval_mode.uses_ai(call) {
send_state(&event_sender, index, ToolLifecycle::AssessingRisk, None); send_state(&event_sender, index, ToolLifecycle::AssessingRisk, None);
} }
let prompt = tools let browser_prompt = tools.browser_approval(call);
.browser_approval(call) let (shell_prompt, ai_reason) = if browser_prompt.is_none() {
.or_else(|| approval_mode.approval(call, &tools.root, &worker_cancel)); approval_mode.approval(call, &tools.root, &worker_cancel)
} else {
(None, None)
};
if let Some(reason) = ai_reason {
let _ = event_sender.send(ToolEvent::ApprovalReason { index, reason });
}
let prompt = browser_prompt.or(shell_prompt);
if let Some(prompt) = prompt { if let Some(prompt) = prompt {
match request_approval(&event_sender, index, prompt, &worker_cancel) { match request_approval(&event_sender, index, prompt, &worker_cancel) {
Ok(()) => {} Ok(()) => {}
@@ -1458,6 +1485,7 @@ pub(crate) fn stored_tool_cards(
model: ModelChoice, model: ModelChoice,
assistant: &str, assistant: &str,
result: Option<&str>, result: Option<&str>,
approval_reasons: &[Option<String>],
) -> Vec<ToolCard> { ) -> Vec<ToolCard> {
let calls = parse_tool_calls(model, assistant) let calls = parse_tool_calls(model, assistant)
.map(|(_, calls)| calls) .map(|(_, calls)| calls)
@@ -1479,6 +1507,7 @@ pub(crate) fn stored_tool_cards(
call, call,
state, state,
result, result,
approval_reason: approval_reasons.get(index).cloned().flatten(),
} }
}) })
.collect() .collect()
@@ -2054,6 +2083,26 @@ mod tests {
); );
} }
#[test]
fn stored_tool_cards_restore_ai_approval_reasons_by_call() {
let assistant = r#"<DSMLtool_calls>
<DSMLinvoke name="bash"><DSMLparameter name="command" string="true">pwd</DSMLparameter></DSMLinvoke>
<DSMLinvoke name="read"><DSMLparameter name="path" string="true">README.md</DSMLparameter></DSMLinvoke>
</DSMLtool_calls>"#;
let cards = stored_tool_cards(
ModelChoice::DeepSeekV4Flash,
assistant,
None,
&[Some("Only reads the working directory.".into()), None],
);
assert_eq!(
cards[0].approval_reason.as_deref(),
Some("Only reads the working directory.")
);
assert_eq!(cards[1].approval_reason, None);
}
#[test] #[test]
fn denial_and_stop_cancel_commands_awaiting_approval() { fn denial_and_stop_cancel_commands_awaiting_approval() {
let directory = std::env::temp_dir().join(format!( let directory = std::env::temp_dir().join(format!(
@@ -2082,6 +2131,7 @@ mod tests {
break decision; break decision;
} }
ToolEvent::State { .. } => {} ToolEvent::State { .. } => {}
ToolEvent::ApprovalReason { .. } => {}
} }
}; };
decision.send(false).unwrap(); decision.send(false).unwrap();
@@ -2108,6 +2158,7 @@ mod tests {
break decision; break decision;
} }
ToolEvent::State { .. } => {} ToolEvent::State { .. } => {}
ToolEvent::ApprovalReason { .. } => {}
} }
}; };
active.cancel.store(true, Ordering::Relaxed); active.cancel.store(true, Ordering::Relaxed);

View File

@@ -2729,6 +2729,7 @@ mod tests {
reasoning_open: true, reasoning_open: true,
content: String::new(), content: String::new(),
model_content: None, model_content: None,
tool_approval_reasons: Vec::new(),
markdown: markdown::Content::new(), markdown: markdown::Content::new(),
transcript: text_editor::Content::new(), transcript: text_editor::Content::new(),
a2ui_lines_processed: 0, a2ui_lines_processed: 0,
@@ -2760,6 +2761,7 @@ mod tests {
reasoning_open: false, reasoning_open: false,
content: content.into(), content: content.into(),
model_content: None, model_content: None,
tool_approval_reasons: Vec::new(),
markdown: markdown::Content::new(), markdown: markdown::Content::new(),
transcript: text_editor::Content::new(), transcript: text_editor::Content::new(),
a2ui_lines_processed: 0, a2ui_lines_processed: 0,

View File

@@ -62,6 +62,7 @@ pub(crate) struct ChatMessage {
pub(super) reasoning_open: bool, pub(super) reasoning_open: bool,
pub(super) content: String, pub(super) content: String,
pub(super) model_content: Option<String>, pub(super) model_content: Option<String>,
pub(super) tool_approval_reasons: Vec<Option<String>>,
pub(super) markdown: markdown::Content, pub(super) markdown: markdown::Content,
pub(super) transcript: text_editor::Content, pub(super) transcript: text_editor::Content,
pub(super) a2ui_lines_processed: usize, pub(super) a2ui_lines_processed: usize,
@@ -229,6 +230,11 @@ impl From<StoredMessage> for ChatMessage {
reasoning_open: false, reasoning_open: false,
content: message.content, content: message.content,
model_content: message.model_content, model_content: message.model_content,
tool_approval_reasons: message
.tool_approval_reasons
.as_deref()
.and_then(|reasons| serde_json::from_str(reasons).ok())
.unwrap_or_default(),
markdown: iced::widget::markdown::Content::new(), markdown: iced::widget::markdown::Content::new(),
transcript: iced::widget::text_editor::Content::new(), transcript: iced::widget::text_editor::Content::new(),
a2ui_lines_processed: 0, a2ui_lines_processed: 0,
@@ -789,6 +795,26 @@ impl App {
self.pending_tool_approval = Some((prompt, decision)); self.pending_tool_approval = Some((prompt, decision));
self.activity = Some(format!("Tool {} · Awaiting approval", index + 1)); self.activity = Some(format!("Tool {} · Awaiting approval", index + 1));
} }
crate::agent::ToolEvent::ApprovalReason { index, reason } => {
if let Some(card) = self.tool_cards.get_mut(index) {
card.approval_reason = Some(reason);
}
let reasons = self
.tool_cards
.iter()
.map(|card| card.approval_reason.clone())
.collect::<Vec<_>>();
if let Some(message) = self.conversation.last_mut() {
message.tool_approval_reasons = reasons.clone();
if let Some(database) = &mut self.database
&& let Err(error) = database
.update_message_tool_approval_reasons(message.id, &reasons)
{
self.error =
Some(format!("Could not save tool approval reasons: {error}"));
}
}
}
} }
} }
} }
@@ -1961,6 +1987,7 @@ mod tests {
reasoning_open: false, reasoning_open: false,
content: content.to_owned(), content: content.to_owned(),
model_content: None, model_content: None,
tool_approval_reasons: Vec::new(),
markdown: iced::widget::markdown::Content::new(), markdown: iced::widget::markdown::Content::new(),
transcript: iced::widget::text_editor::Content::new(), transcript: iced::widget::text_editor::Content::new(),
a2ui_lines_processed: 0, a2ui_lines_processed: 0,
@@ -2156,6 +2183,7 @@ mod tests {
content: "### Core / Setup\n\n| File | Lines |\n|---|---:|\n| `src/app.rs` | **1,750** |\n| `src/engine.rs` | 2,400 |\n\n### Summary\n\nDone." content: "### Core / Setup\n\n| File | Lines |\n|---|---:|\n| `src/app.rs` | **1,750** |\n| `src/engine.rs` | 2,400 |\n\n### Summary\n\nDone."
.to_owned(), .to_owned(),
model_content: None, model_content: None,
tool_approval_reasons: Vec::new(),
markdown: iced::widget::markdown::Content::new(), markdown: iced::widget::markdown::Content::new(),
transcript: iced::widget::text_editor::Content::new(), transcript: iced::widget::text_editor::Content::new(),
a2ui_lines_processed: 0, a2ui_lines_processed: 0,
@@ -2223,6 +2251,7 @@ mod tests {
reasoning_open: false, reasoning_open: false,
content: format!("message {id}"), content: format!("message {id}"),
model_content: None, model_content: None,
tool_approval_reasons: Vec::new(),
markdown: iced::widget::markdown::Content::new(), markdown: iced::widget::markdown::Content::new(),
transcript: iced::widget::text_editor::Content::new(), transcript: iced::widget::text_editor::Content::new(),
a2ui_lines_processed: 0, a2ui_lines_processed: 0,
@@ -2262,6 +2291,7 @@ mod tests {
reasoning_open: false, reasoning_open: false,
content: format!("message {id}"), content: format!("message {id}"),
model_content: None, model_content: None,
tool_approval_reasons: Vec::new(),
markdown: iced::widget::markdown::Content::new(), markdown: iced::widget::markdown::Content::new(),
transcript: iced::widget::text_editor::Content::new(), transcript: iced::widget::text_editor::Content::new(),
a2ui_lines_processed: 0, a2ui_lines_processed: 0,

View File

@@ -40,6 +40,7 @@ const ICON_MORE: &[u8] = include_bytes!("../../assets/icons/more.svg");
const ICON_PAPERCLIP: &[u8] = include_bytes!("../../assets/icons/paperclip.svg"); const ICON_PAPERCLIP: &[u8] = include_bytes!("../../assets/icons/paperclip.svg");
const ICON_SEND: &[u8] = include_bytes!("../../assets/icons/send.svg"); const ICON_SEND: &[u8] = include_bytes!("../../assets/icons/send.svg");
const ICON_MODEL: &[u8] = include_bytes!("../../assets/icons/model.svg"); const ICON_MODEL: &[u8] = include_bytes!("../../assets/icons/model.svg");
const ICON_ROBOT: &[u8] = include_bytes!("../../assets/icons/robot.svg");
const ICON_SPARK: &[u8] = include_bytes!("../../assets/icons/spark.svg"); const ICON_SPARK: &[u8] = include_bytes!("../../assets/icons/spark.svg");
const ICON_PIN: &[u8] = include_bytes!("../../assets/icons/pin.svg"); const ICON_PIN: &[u8] = include_bytes!("../../assets/icons/pin.svg");
const ICON_SIDEBAR: &[u8] = include_bytes!("../../assets/icons/sidebar.svg"); const ICON_SIDEBAR: &[u8] = include_bytes!("../../assets/icons/sidebar.svg");

View File

@@ -81,6 +81,7 @@ impl App {
self.config.model, self.config.model,
&self.conversation[index - 1].content, &self.conversation[index - 1].content,
None, None,
&self.conversation[index - 1].tool_approval_reasons,
) )
.is_empty() .is_empty()
{ {
@@ -191,6 +192,7 @@ impl App {
self.config.model, self.config.model,
&message.content, &message.content,
stored_result, stored_result,
&message.tool_approval_reasons,
) )
}; };
if !cards.is_empty() { if !cards.is_empty() {
@@ -540,9 +542,25 @@ fn tool_cards(cards: Vec<crate::agent::ToolCard>) -> Element<'static, Message> {
.gap(6), .gap(6),
); );
} }
let mut name = row![text(card.call.name).size(13)]
.spacing(5)
.align_y(Alignment::Center);
if let Some(reason) = &card.approval_reason {
name = name.push(
tooltip(
icon(ICON_ROBOT, 13),
container(text(reason.clone()).size(12))
.padding(8)
.max_width(320)
.style(preference_group_style),
tooltip::Position::Top,
)
.gap(5),
);
}
let mut content = column![ let mut content = column![
row![ row![
text(card.call.name).size(13), name,
Space::new().width(Length::Fill), Space::new().width(Length::Fill),
text(card.state.label()).size(11).color(muted_text()), text(card.state.label()).size(11).color(muted_text()),
actions, actions,

View File

@@ -825,6 +825,7 @@ mod tests {
reasoning_open: false, reasoning_open: false,
content: content.to_owned(), content: content.to_owned(),
model_content: None, model_content: None,
tool_approval_reasons: Vec::new(),
markdown: markdown::Content::new(), markdown: markdown::Content::new(),
transcript: text_editor::Content::new(), transcript: text_editor::Content::new(),
a2ui_lines_processed: 0, a2ui_lines_processed: 0,

View File

@@ -132,6 +132,7 @@ pub struct StoredMessage {
pub reasoning_complete: bool, pub reasoning_complete: bool,
pub content: String, pub content: String,
pub model_content: Option<String>, pub model_content: Option<String>,
pub tool_approval_reasons: Option<String>,
pub system: bool, pub system: bool,
pub compaction: bool, pub compaction: bool,
pub compaction_tail_start: Option<i32>, pub compaction_tail_start: Option<i32>,
@@ -151,6 +152,7 @@ struct NewMessage<'a> {
reasoning_complete: bool, reasoning_complete: bool,
content: &'a str, content: &'a str,
model_content: Option<&'a str>, model_content: Option<&'a str>,
tool_approval_reasons: Option<&'a str>,
system: bool, system: bool,
compaction: bool, compaction: bool,
compaction_tail_start: Option<i32>, compaction_tail_start: Option<i32>,
@@ -197,6 +199,7 @@ impl<'a> NewMessage<'a> {
reasoning_complete: true, reasoning_complete: true,
content, content,
model_content: None, model_content: None,
tool_approval_reasons: None,
system, system,
compaction: false, compaction: false,
compaction_tail_start: None, compaction_tail_start: None,
@@ -599,6 +602,19 @@ impl Database {
.map_err(|error| error.to_string()) .map_err(|error| error.to_string())
} }
pub fn update_message_tool_approval_reasons(
&mut self,
id: i32,
reasons: &[Option<String>],
) -> Result<(), String> {
let reasons = serde_json::to_string(reasons).map_err(|error| error.to_string())?;
diesel::update(messages::table.find(id))
.set(messages::tool_approval_reasons.eq(reasons))
.execute(&mut self.connection)
.map(|_| ())
.map_err(|error| error.to_string())
}
pub fn update_message_generation_stats( pub fn update_message_generation_stats(
&mut self, &mut self,
id: i32, id: i32,
@@ -973,6 +989,12 @@ mod tests {
database database
.update_message(assistant.id, Some("Reasoning"), true, "Answer") .update_message(assistant.id, Some("Reasoning"), true, "Answer")
.unwrap(); .unwrap();
database
.update_message_tool_approval_reasons(
assistant.id,
&[Some("Reads repository metadata only.".into())],
)
.unwrap();
database database
.update_message_generation_stats(assistant.id, 12_345, 1_024, 768, 256) .update_message_generation_stats(assistant.id, 12_345, 1_024, 768, 256)
.unwrap(); .unwrap();
@@ -1013,6 +1035,10 @@ mod tests {
assert_eq!(messages[2].reasoning.as_deref(), Some("Reasoning")); assert_eq!(messages[2].reasoning.as_deref(), Some("Reasoning"));
assert!(messages[2].reasoning_complete); assert!(messages[2].reasoning_complete);
assert_eq!(messages[2].content, "Answer"); assert_eq!(messages[2].content, "Answer");
assert_eq!(
messages[2].tool_approval_reasons.as_deref(),
Some(r#"["Reads repository metadata only."]"#)
);
assert_eq!(messages[2].generation_duration_ms, Some(12_345)); assert_eq!(messages[2].generation_duration_ms, Some(12_345));
assert_eq!(messages[2].input_tokens, Some(1_024)); assert_eq!(messages[2].input_tokens, Some(1_024));
assert_eq!(messages[2].cached_tokens, Some(768)); assert_eq!(messages[2].cached_tokens, Some(768));

View File

@@ -18,6 +18,7 @@ diesel::table! {
reasoning_complete -> Bool, reasoning_complete -> Bool,
content -> Text, content -> Text,
model_content -> Nullable<Text>, model_content -> Nullable<Text>,
tool_approval_reasons -> Nullable<Text>,
system -> Bool, system -> Bool,
compaction -> Bool, compaction -> Bool,
compaction_tail_start -> Nullable<Integer>, compaction_tail_start -> Nullable<Integer>,