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

View File

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

View File

@@ -62,6 +62,7 @@ pub(crate) struct ChatMessage {
pub(super) reasoning_open: bool,
pub(super) content: String,
pub(super) model_content: Option<String>,
pub(super) tool_approval_reasons: Vec<Option<String>>,
pub(super) markdown: markdown::Content,
pub(super) transcript: text_editor::Content,
pub(super) a2ui_lines_processed: usize,
@@ -229,6 +230,11 @@ impl From<StoredMessage> for ChatMessage {
reasoning_open: false,
content: message.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(),
transcript: iced::widget::text_editor::Content::new(),
a2ui_lines_processed: 0,
@@ -789,6 +795,26 @@ impl App {
self.pending_tool_approval = Some((prompt, decision));
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,
content: content.to_owned(),
model_content: None,
tool_approval_reasons: Vec::new(),
markdown: iced::widget::markdown::Content::new(),
transcript: iced::widget::text_editor::Content::new(),
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."
.to_owned(),
model_content: None,
tool_approval_reasons: Vec::new(),
markdown: iced::widget::markdown::Content::new(),
transcript: iced::widget::text_editor::Content::new(),
a2ui_lines_processed: 0,
@@ -2223,6 +2251,7 @@ mod tests {
reasoning_open: false,
content: format!("message {id}"),
model_content: None,
tool_approval_reasons: Vec::new(),
markdown: iced::widget::markdown::Content::new(),
transcript: iced::widget::text_editor::Content::new(),
a2ui_lines_processed: 0,
@@ -2262,6 +2291,7 @@ mod tests {
reasoning_open: false,
content: format!("message {id}"),
model_content: None,
tool_approval_reasons: Vec::new(),
markdown: iced::widget::markdown::Content::new(),
transcript: iced::widget::text_editor::Content::new(),
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_SEND: &[u8] = include_bytes!("../../assets/icons/send.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_PIN: &[u8] = include_bytes!("../../assets/icons/pin.svg");
const ICON_SIDEBAR: &[u8] = include_bytes!("../../assets/icons/sidebar.svg");

View File

@@ -81,6 +81,7 @@ impl App {
self.config.model,
&self.conversation[index - 1].content,
None,
&self.conversation[index - 1].tool_approval_reasons,
)
.is_empty()
{
@@ -191,6 +192,7 @@ impl App {
self.config.model,
&message.content,
stored_result,
&message.tool_approval_reasons,
)
};
if !cards.is_empty() {
@@ -540,9 +542,25 @@ fn tool_cards(cards: Vec<crate::agent::ToolCard>) -> Element<'static, Message> {
.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![
row![
text(card.call.name).size(13),
name,
Space::new().width(Length::Fill),
text(card.state.label()).size(11).color(muted_text()),
actions,

View File

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

View File

@@ -132,6 +132,7 @@ pub struct StoredMessage {
pub reasoning_complete: bool,
pub content: String,
pub model_content: Option<String>,
pub tool_approval_reasons: Option<String>,
pub system: bool,
pub compaction: bool,
pub compaction_tail_start: Option<i32>,
@@ -151,6 +152,7 @@ struct NewMessage<'a> {
reasoning_complete: bool,
content: &'a str,
model_content: Option<&'a str>,
tool_approval_reasons: Option<&'a str>,
system: bool,
compaction: bool,
compaction_tail_start: Option<i32>,
@@ -197,6 +199,7 @@ impl<'a> NewMessage<'a> {
reasoning_complete: true,
content,
model_content: None,
tool_approval_reasons: None,
system,
compaction: false,
compaction_tail_start: None,
@@ -599,6 +602,19 @@ impl Database {
.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(
&mut self,
id: i32,
@@ -973,6 +989,12 @@ mod tests {
database
.update_message(assistant.id, Some("Reasoning"), true, "Answer")
.unwrap();
database
.update_message_tool_approval_reasons(
assistant.id,
&[Some("Reads repository metadata only.".into())],
)
.unwrap();
database
.update_message_generation_stats(assistant.id, 12_345, 1_024, 768, 256)
.unwrap();
@@ -1013,6 +1035,10 @@ mod tests {
assert_eq!(messages[2].reasoning.as_deref(), Some("Reasoning"));
assert!(messages[2].reasoning_complete);
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].input_tokens, Some(1_024));
assert_eq!(messages[2].cached_tokens, Some(768));

View File

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