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}