feat(agent): Add approval callback system for tool execution

- Add ApprovalCallback trait with ApprovalDecision (Approved, Rejected, ModifyInput)
- Implement AutoApproveAll, RejectHighRisk, and ChannelApprover callbacks
- Integrate approval callback into Agent execution loop
- Support interactive approval via channel-based communication
- Include comprehensive tests for all approval scenarios

All 167 tests passing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Shunsuke Hayashi 2025-11-23 00:16:55 +09:00
parent 57820166f9
commit 5935dae7dd
3 changed files with 295 additions and 5 deletions

View file

@ -0,0 +1,231 @@
//! Approval callback system for tool execution
use async_trait::async_trait;
use serde_json::Value;
use super::executor::RiskLevel;
/// Decision from approval callback
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApprovalDecision {
/// Tool execution approved
Approved,
/// Tool execution rejected with optional reason
Rejected(Option<String>),
/// Request to modify the input before execution
ModifyInput(Value),
}
/// Request sent to approval callback
#[derive(Debug, Clone)]
pub struct ApprovalRequest {
/// Tool use ID from Claude
pub id: String,
/// Tool name
pub name: String,
/// Tool input parameters
pub input: Value,
/// Risk level of the tool
pub risk_level: RiskLevel,
/// Description of what the tool does
pub description: String,
}
/// Trait for implementing custom approval handlers
///
/// Implement this trait to create custom approval UIs or automated
/// approval logic based on tool risk levels.
///
/// # Example
///
/// ```rust
/// use miyabi_core::agent::{ApprovalCallback, ApprovalRequest, ApprovalDecision};
/// use async_trait::async_trait;
///
/// struct AutoApprover;
///
/// #[async_trait]
/// impl ApprovalCallback for AutoApprover {
/// async fn request_approval(&self, request: &ApprovalRequest) -> ApprovalDecision {
/// // Auto-approve low risk tools
/// match request.risk_level {
/// miyabi_core::agent::RiskLevel::Low => ApprovalDecision::Approved,
/// _ => ApprovalDecision::Rejected(Some("Manual approval required".to_string())),
/// }
/// }
/// }
/// ```
#[async_trait]
pub trait ApprovalCallback: Send + Sync {
/// Request approval for tool execution
///
/// This method is called before executing a tool that requires approval.
/// The implementation should decide whether to approve, reject, or modify
/// the tool execution.
async fn request_approval(&self, request: &ApprovalRequest) -> ApprovalDecision;
/// Called when tool execution is completed (optional)
async fn on_tool_completed(&self, _request: &ApprovalRequest, _output: &str) {}
/// Called when tool execution fails (optional)
async fn on_tool_failed(&self, _request: &ApprovalRequest, _error: &str) {}
}
/// Default approval callback that auto-approves everything
///
/// This is used when no custom approval callback is provided.
/// Use with caution in production environments.
pub struct AutoApproveAll;
#[async_trait]
impl ApprovalCallback for AutoApproveAll {
async fn request_approval(&self, _request: &ApprovalRequest) -> ApprovalDecision {
ApprovalDecision::Approved
}
}
/// Approval callback that requires all high-risk tools to be rejected
pub struct RejectHighRisk;
#[async_trait]
impl ApprovalCallback for RejectHighRisk {
async fn request_approval(&self, request: &ApprovalRequest) -> ApprovalDecision {
match request.risk_level {
RiskLevel::Low | RiskLevel::Medium => ApprovalDecision::Approved,
RiskLevel::High | RiskLevel::Critical => {
ApprovalDecision::Rejected(Some(format!(
"Tool {} requires manual approval (risk level: {})",
request.name,
request.risk_level.as_str()
)))
}
}
}
}
/// Approval callback that uses an async channel for interactive approval
///
/// This is useful for TUI/GUI applications that need to present
/// approval requests to the user.
pub struct ChannelApprover {
/// Channel to send approval requests
request_tx: tokio::sync::mpsc::Sender<ApprovalRequest>,
/// Channel to receive approval decisions
decision_rx: std::sync::Arc<tokio::sync::Mutex<tokio::sync::mpsc::Receiver<ApprovalDecision>>>,
}
impl ChannelApprover {
/// Create a new channel-based approver
pub fn new() -> (
Self,
tokio::sync::mpsc::Receiver<ApprovalRequest>,
tokio::sync::mpsc::Sender<ApprovalDecision>,
) {
let (request_tx, request_rx) = tokio::sync::mpsc::channel(10);
let (decision_tx, decision_rx) = tokio::sync::mpsc::channel(10);
let approver = Self {
request_tx,
decision_rx: std::sync::Arc::new(tokio::sync::Mutex::new(decision_rx)),
};
(approver, request_rx, decision_tx)
}
}
#[async_trait]
impl ApprovalCallback for ChannelApprover {
async fn request_approval(&self, request: &ApprovalRequest) -> ApprovalDecision {
// Send request through channel
if self.request_tx.send(request.clone()).await.is_err() {
return ApprovalDecision::Rejected(Some("Approval channel closed".to_string()));
}
// Wait for decision
let mut rx = self.decision_rx.lock().await;
match rx.recv().await {
Some(decision) => decision,
None => ApprovalDecision::Rejected(Some("No approval decision received".to_string())),
}
}
}
impl Default for ChannelApprover {
fn default() -> Self {
Self::new().0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_auto_approve_all() {
let approver = AutoApproveAll;
let request = ApprovalRequest {
id: "test-1".to_string(),
name: "bash".to_string(),
input: serde_json::json!({"command": "ls"}),
risk_level: RiskLevel::High,
description: "Execute bash command".to_string(),
};
let decision = approver.request_approval(&request).await;
assert_eq!(decision, ApprovalDecision::Approved);
}
#[tokio::test]
async fn test_reject_high_risk() {
let approver = RejectHighRisk;
// Low risk should be approved
let low_risk = ApprovalRequest {
id: "test-1".to_string(),
name: "read".to_string(),
input: serde_json::json!({"path": "/test"}),
risk_level: RiskLevel::Low,
description: "Read file".to_string(),
};
assert_eq!(approver.request_approval(&low_risk).await, ApprovalDecision::Approved);
// High risk should be rejected
let high_risk = ApprovalRequest {
id: "test-2".to_string(),
name: "bash".to_string(),
input: serde_json::json!({"command": "rm -rf /"}),
risk_level: RiskLevel::High,
description: "Execute bash command".to_string(),
};
match approver.request_approval(&high_risk).await {
ApprovalDecision::Rejected(_) => {}
_ => panic!("Expected rejection"),
}
}
#[tokio::test]
async fn test_channel_approver() {
let (approver, mut request_rx, decision_tx) = ChannelApprover::new();
let request = ApprovalRequest {
id: "test-1".to_string(),
name: "write".to_string(),
input: serde_json::json!({"path": "/test", "content": "data"}),
risk_level: RiskLevel::Medium,
description: "Write file".to_string(),
};
// Spawn task to handle approval
let handle = tokio::spawn(async move {
// Receive request
let _req = request_rx.recv().await.unwrap();
// Send approval
decision_tx.send(ApprovalDecision::Approved).await.unwrap();
});
let decision = approver.request_approval(&request).await;
assert_eq!(decision, ApprovalDecision::Approved);
handle.await.unwrap();
}
}

View file

@ -1,13 +1,15 @@
//! Agent struct and execution loop
use std::sync::Arc;
use std::time::Duration;
use serde_json::Value;
use tokio::sync::mpsc;
use crate::{AnthropicClient, ContentBlock, Message, Role, StopReason};
use crate::hooks::{HookContext, HookEvent, HookManager, HooksConfig};
use crate::{AnthropicClient, ContentBlock, Message, Role, StopReason};
use super::approval::{ApprovalCallback, ApprovalDecision, ApprovalRequest, AutoApproveAll};
use super::{AgentError, AgentEvent, AgentResult, ExecutorRegistry};
/// Configuration for agent execution
@ -45,6 +47,7 @@ pub struct Agent {
system_prompt: Option<String>,
event_tx: Option<mpsc::Sender<AgentEvent>>,
hook_manager: HookManager,
approval_callback: Arc<dyn ApprovalCallback>,
}
impl Agent {
@ -62,6 +65,7 @@ impl Agent {
system_prompt: None,
event_tx: None,
hook_manager,
approval_callback: Arc::new(AutoApproveAll),
}
}
@ -71,6 +75,12 @@ impl Agent {
self
}
/// Set custom approval callback
pub fn with_approval_callback<C: ApprovalCallback + 'static>(mut self, callback: C) -> Self {
self.approval_callback = Arc::new(callback);
self
}
/// Set agent configuration
pub fn with_config(mut self, config: AgentConfig) -> Self {
self.config = config;
@ -233,6 +243,8 @@ impl Agent {
let needs_approval = !self.is_auto_approved(&tool_use.name)
&& self.executor_registry.requires_approval(&tool_use.name);
let mut final_input = tool_use.input.clone();
if needs_approval {
self.emit_event(AgentEvent::AwaitingApproval {
id: tool_use.id.clone(),
@ -240,8 +252,50 @@ impl Agent {
input: tool_use.input.clone(),
})
.await;
// In non-interactive mode, auto-approve for now
// TODO: Add approval callback mechanism
// Request approval via callback
let risk_level = self
.executor_registry
.risk_level(&tool_use.name)
.unwrap_or(super::executor::RiskLevel::Medium);
let approval_request = ApprovalRequest {
id: tool_use.id.clone(),
name: tool_use.name.clone(),
input: tool_use.input.clone(),
risk_level,
description: self
.executor_registry
.get(&tool_use.name)
.map(|e| e.description().to_string())
.unwrap_or_default(),
};
let decision = self
.approval_callback
.request_approval(&approval_request)
.await;
match decision {
ApprovalDecision::Approved => {
// Continue with execution
}
ApprovalDecision::Rejected(reason) => {
// Add rejection as tool result
let error_msg = reason.unwrap_or_else(|| {
format!("Tool {} was rejected by approval", tool_use.name)
});
results.push(ContentBlock::ToolResult {
tool_use_id: tool_use.id,
content: format!("Rejected: {}", error_msg),
});
continue;
}
ApprovalDecision::ModifyInput(new_input) => {
// Use modified input
final_input = new_input;
}
}
}
// Execute PreTool hooks
@ -253,13 +307,13 @@ impl Agent {
// Execute tool
self.emit_event(AgentEvent::ToolExecuting {
name: tool_use.name.clone(),
input: tool_use.input.clone(),
input: final_input.clone(),
})
.await;
match self
.executor_registry
.execute(&tool_use.name, tool_use.input.clone())
.execute(&tool_use.name, final_input.clone())
.await
{
Ok(output) => {

View file

@ -3,10 +3,15 @@
//! This module provides the core Agent loop that enables autonomous
//! tool execution with Claude API, similar to Claude Agent SDK.
mod approval;
mod core;
mod events;
mod executor;
pub use approval::{
ApprovalCallback, ApprovalDecision, ApprovalRequest, AutoApproveAll, ChannelApprover,
RejectHighRisk,
};
pub use core::{Agent, AgentConfig};
pub use events::{AgentError, AgentEvent, AgentResult};
pub use executor::{ExecutorRegistry, RiskLevel, ToolExecutor};