feat: record agent approvals
This commit is contained in:
87
src/agent.rs
87
src/agent.rs
@@ -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#"<|DSML|tool_calls>
|
||||
<|DSML|invoke name="bash"><|DSML|parameter name="command" string="true">pwd</|DSML|parameter></|DSML|invoke>
|
||||
<|DSML|invoke name="read"><|DSML|parameter name="path" string="true">README.md</|DSML|parameter></|DSML|invoke>
|
||||
</|DSML|tool_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);
|
||||
|
||||
Reference in New Issue
Block a user