Skip to main content

pulsehive_core/
approval.rs

1//! Human-in-the-loop approval primitives.
2//!
3//! Products implement [`ApprovalHandler`] to define their approval UX.
4//! The framework calls it before executing tools where
5//! [`Tool::requires_approval()`](crate::tool::Tool::requires_approval) returns `true`.
6//!
7//! [`AutoApprove`] is the default handler that approves everything — suitable for
8//! autonomous operation and MVP development.
9
10use async_trait::async_trait;
11use serde_json::Value;
12
13use crate::error::Result;
14
15/// Describes a tool invocation awaiting human approval.
16#[derive(Debug, Clone)]
17pub struct PendingAction {
18    /// ID of the agent requesting the action.
19    pub agent_id: String,
20    /// Name of the tool to be executed.
21    pub tool_name: String,
22    /// Parameters the tool would be called with.
23    pub params: Value,
24    /// Human-readable description of what the tool will do.
25    pub description: String,
26}
27
28/// Result of a human approval decision.
29#[derive(Debug, Clone)]
30pub enum ApprovalResult {
31    /// Action is approved as-is.
32    Approved,
33    /// Action is denied with a reason (communicated back to the LLM).
34    Denied { reason: String },
35    /// Action is approved with modified parameters.
36    Modified { new_params: Value },
37}
38
39/// Trait for handling human-in-the-loop approval requests.
40///
41/// Products implement this to define how approval is presented to users.
42/// The framework calls [`request_approval`](ApprovalHandler::request_approval)
43/// before executing any tool where `requires_approval()` returns `true`.
44///
45/// # Approval Flow
46///
47/// 1. Agent's agentic loop encounters a tool with `requires_approval() == true`
48/// 2. Framework emits `HiveEvent::ToolApprovalRequested` and calls your handler
49/// 3. Handler returns one of:
50///    - [`ApprovalResult::Approved`] — tool executes with original parameters
51///    - [`ApprovalResult::Denied`] — tool is blocked, LLM is informed of the reason
52///    - [`ApprovalResult::Modified`] — tool executes with modified parameters
53///
54/// # CLI Example
55///
56/// Interactive terminal approval with all three outcome paths:
57///
58/// ```rust,ignore
59/// use std::io::{self, Write};
60/// use async_trait::async_trait;
61/// use pulsehive_core::approval::*;
62/// use pulsehive_core::error::Result;
63///
64/// struct CLIApproval;
65///
66/// #[async_trait]
67/// impl ApprovalHandler for CLIApproval {
68///     async fn request_approval(&self, action: &PendingAction) -> Result<ApprovalResult> {
69///         println!("\n--- Approval Required ---");
70///         println!("Agent:  {}", action.agent_id);
71///         println!("Tool:   {}", action.tool_name);
72///         println!("Params: {}", action.params);
73///         println!("Desc:   {}", action.description);
74///         print!("[a]pprove / [d]eny / [m]odify: ");
75///         io::stdout().flush().unwrap();
76///
77///         let mut input = String::new();
78///         io::stdin().read_line(&mut input).unwrap();
79///
80///         match input.trim() {
81///             "a" | "approve" => Ok(ApprovalResult::Approved),
82///             "d" | "deny" => Ok(ApprovalResult::Denied {
83///                 reason: "Operator denied the action".into(),
84///             }),
85///             "m" | "modify" => {
86///                 // Example: force safe_mode on all approved actions
87///                 let mut params = action.params.clone();
88///                 if let Some(obj) = params.as_object_mut() {
89///                     obj.insert("safe_mode".into(), serde_json::Value::Bool(true));
90///                 }
91///                 Ok(ApprovalResult::Modified { new_params: params })
92///             }
93///             _ => Ok(ApprovalResult::Denied {
94///                 reason: "Unrecognized input — defaulting to deny".into(),
95///             }),
96///         }
97///     }
98/// }
99/// ```
100///
101/// # Slack / Webhook Example
102///
103/// ```rust,ignore
104/// struct SlackApproval { channel: String }
105///
106/// #[async_trait]
107/// impl ApprovalHandler for SlackApproval {
108///     async fn request_approval(&self, action: &PendingAction) -> Result<ApprovalResult> {
109///         // Post to Slack, wait for reaction, return result
110///         todo!()
111///     }
112/// }
113/// ```
114#[async_trait]
115pub trait ApprovalHandler: Send + Sync {
116    /// Request approval for a pending action.
117    ///
118    /// Called by the agentic loop before executing a tool with `requires_approval() == true`.
119    /// Implementations should present the action to a human and return their decision.
120    async fn request_approval(&self, action: &PendingAction) -> Result<ApprovalResult>;
121}
122
123/// Default approval handler that approves all actions automatically.
124///
125/// Used when no custom handler is provided to [`HiveMind`](crate) builder.
126/// Suitable for autonomous operation, testing, and MVP development.
127#[derive(Debug, Clone, Default)]
128pub struct AutoApprove;
129
130#[async_trait]
131impl ApprovalHandler for AutoApprove {
132    async fn request_approval(&self, _action: &PendingAction) -> Result<ApprovalResult> {
133        Ok(ApprovalResult::Approved)
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn test_approval_handler_is_object_safe() {
143        let _: Box<dyn ApprovalHandler> = Box::new(AutoApprove);
144    }
145
146    #[tokio::test]
147    async fn test_auto_approve_returns_approved() {
148        let handler = AutoApprove;
149        let action = PendingAction {
150            agent_id: "agent-1".into(),
151            tool_name: "delete_file".into(),
152            params: serde_json::json!({"path": "/tmp/test"}),
153            description: "Delete temporary file".into(),
154        };
155
156        let result = handler.request_approval(&action).await.unwrap();
157        assert!(matches!(result, ApprovalResult::Approved));
158    }
159
160    #[test]
161    fn test_approval_result_variants() {
162        let approved = ApprovalResult::Approved;
163        assert!(matches!(approved, ApprovalResult::Approved));
164
165        let denied = ApprovalResult::Denied {
166            reason: "Too dangerous".into(),
167        };
168        assert!(matches!(denied, ApprovalResult::Denied { .. }));
169
170        let modified = ApprovalResult::Modified {
171            new_params: serde_json::json!({"safe_mode": true}),
172        };
173        assert!(matches!(modified, ApprovalResult::Modified { .. }));
174    }
175
176    #[test]
177    fn test_pending_action_fields() {
178        let action = PendingAction {
179            agent_id: "test-agent".into(),
180            tool_name: "run_query".into(),
181            params: serde_json::json!({"sql": "SELECT 1"}),
182            description: "Execute a read-only query".into(),
183        };
184        assert_eq!(action.agent_id, "test-agent");
185        assert_eq!(action.tool_name, "run_query");
186        assert_eq!(action.description, "Execute a read-only query");
187    }
188}