Skip to main content

a3s_code_core/
hitl.rs

1//! Human-in-the-Loop (HITL) confirmation mechanism
2//!
3//! Provides the runtime confirmation flow for tool execution. Works with
4//! `PermissionPolicy` (permissions.rs) which decides Allow/Deny/Ask.
5//! When the permission decision is `Ask`, this module handles:
6//! - Interactive confirmation request/response flow
7//! - Timeout handling with configurable actions
8//! - Lane names a host records as Allow on `PermissionPolicy`
9
10use crate::agent::AgentEvent;
11use crate::queue::SessionLane;
12use serde::{Deserialize, Serialize};
13use std::collections::{HashMap, HashSet};
14use std::sync::Arc;
15use std::time::{Duration, Instant};
16use tokio::sync::{broadcast, oneshot, RwLock};
17
18/// Action to take when confirmation times out
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
20pub enum TimeoutAction {
21    /// Reject the tool execution on timeout
22    #[default]
23    Reject,
24    /// Auto-approve the tool execution on timeout
25    AutoApprove,
26}
27
28/// Confirmation policy configuration
29///
30/// Controls the runtime behavior of HITL confirmation flow.
31/// The *decision* of whether to ask is made by `PermissionPolicy` (permissions.rs).
32/// This policy controls *how* the confirmation works: timeouts, YOLO lanes.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ConfirmationPolicy {
35    /// Whether HITL is enabled (default: false, all tools auto-approved)
36    pub enabled: bool,
37
38    /// Default timeout in milliseconds (default: 30000 = 30s)
39    pub default_timeout_ms: u64,
40
41    /// Action to take on timeout (default: Reject)
42    pub timeout_action: TimeoutAction,
43
44    /// Lanes the host records as Allow on `PermissionPolicy`.
45    /// This set does not itself skip an Ask decision.
46    pub yolo_lanes: HashSet<SessionLane>,
47}
48
49impl Default for ConfirmationPolicy {
50    fn default() -> Self {
51        Self {
52            enabled: false,             // HITL disabled by default
53            default_timeout_ms: 30_000, // 30 seconds
54            timeout_action: TimeoutAction::Reject,
55            yolo_lanes: HashSet::new(), // No YOLO lanes by default
56        }
57    }
58}
59
60impl ConfirmationPolicy {
61    /// Create a new policy with HITL enabled
62    pub fn enabled() -> Self {
63        Self {
64            enabled: true,
65            ..Default::default()
66        }
67    }
68
69    /// Enable YOLO mode for specific lanes
70    pub fn with_yolo_lanes(mut self, lanes: impl IntoIterator<Item = SessionLane>) -> Self {
71        self.yolo_lanes = lanes.into_iter().collect();
72        self
73    }
74
75    /// Set timeout
76    pub fn with_timeout(mut self, timeout_ms: u64, action: TimeoutAction) -> Self {
77        self.default_timeout_ms = timeout_ms;
78        self.timeout_action = action;
79        self
80    }
81
82    /// YOLO lanes are Allow rules on `PermissionPolicy`. This does not skip Ask.
83    pub fn is_yolo(&self, _tool_name: &str) -> bool {
84        false
85    }
86
87    /// Whether the confirmation manager is enabled.
88    ///
89    /// Lane auto-approval is not decided here. `PermissionPolicy::check` is the
90    /// only Allow, Deny, or Ask decision.
91    pub fn requires_confirmation(&self, _tool_name: &str) -> bool {
92        self.enabled
93    }
94}
95
96/// Confirmation response from user
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct ConfirmationResponse {
99    /// Whether the tool execution was approved
100    pub approved: bool,
101    /// Optional reason for rejection
102    pub reason: Option<String>,
103}
104
105/// Snapshot of a pending confirmation request.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct PendingConfirmationInfo {
108    pub tool_id: String,
109    pub tool_name: String,
110    pub args: serde_json::Value,
111    pub remaining_ms: u64,
112}
113
114/// Trait for confirmation providers (HITL runtime behavior)
115///
116/// This trait abstracts the confirmation flow, allowing different implementations
117/// (e.g., interactive, auto-approve, test mocks) while keeping the agent logic clean.
118#[async_trait::async_trait]
119pub trait ConfirmationProvider: Send + Sync {
120    /// Freeze host confirmation semantics for one agent run.
121    ///
122    /// The returned provider may share its pending-request store with the
123    /// session provider, but mode-dependent routing must no longer change
124    /// after this snapshot is created. Stateless providers can keep the
125    /// default and will be shared as-is.
126    fn snapshot_for_run(&self) -> Option<Arc<dyn ConfirmationProvider>> {
127        None
128    }
129
130    /// Check if a tool requires confirmation
131    async fn requires_confirmation(&self, tool_name: &str) -> bool;
132
133    /// Check whether this exact invocation requires confirmation.
134    ///
135    /// Providers that do not inspect arguments inherit the tool-level behavior.
136    /// Composed child-run providers override this method so an Ask introduced by
137    /// the parent boundary cannot be auto-approved by a child-local policy.
138    async fn requires_confirmation_for(&self, tool_name: &str, _args: &serde_json::Value) -> bool {
139        self.requires_confirmation(tool_name).await
140    }
141
142    /// Whether this provider can resolve confirmation for this invocation.
143    ///
144    /// Most providers are always available. A composed child-run provider uses
145    /// this hook to fail closed before emitting a HITL request when the policy
146    /// scope that produced `Ask` deliberately has no provider (`deny_on_ask`),
147    /// or when a parent escalation boundary has no confirmation channel.
148    async fn confirmation_available_for(
149        &self,
150        _tool_name: &str,
151        _args: &serde_json::Value,
152    ) -> bool {
153        true
154    }
155
156    /// Return the effective confirmation policy for this invocation.
157    ///
158    /// Argument-insensitive providers inherit the session-wide policy. A
159    /// composed provider overrides this so timeout behavior comes only from the
160    /// child and/or parent scopes that actually requested confirmation.
161    async fn policy_for(&self, _tool_name: &str, _args: &serde_json::Value) -> ConfirmationPolicy {
162        self.policy().await
163    }
164
165    /// Request confirmation for a tool execution
166    ///
167    /// Returns a receiver that will receive the confirmation response.
168    async fn request_confirmation(
169        &self,
170        tool_id: &str,
171        tool_name: &str,
172        args: &serde_json::Value,
173    ) -> oneshot::Receiver<ConfirmationResponse>;
174
175    /// Handle a confirmation response from the user
176    ///
177    /// Returns Ok(true) if the confirmation was found and processed,
178    /// Ok(false) if no pending confirmation was found.
179    async fn confirm(
180        &self,
181        tool_id: &str,
182        approved: bool,
183        reason: Option<String>,
184    ) -> Result<bool, String>;
185
186    /// Get the current policy
187    async fn policy(&self) -> ConfirmationPolicy;
188
189    /// Update the confirmation policy
190    async fn set_policy(&self, policy: ConfirmationPolicy);
191
192    /// Check for and handle timed out confirmations
193    async fn check_timeouts(&self) -> usize;
194
195    /// Cancel one exact pending confirmation.
196    ///
197    /// The default uses the provider's targeted `confirm` operation so existing
198    /// provider implementations remain source-compatible. Session shutdown
199    /// should use [`Self::cancel_all`]; invocation cancellation must use this
200    /// method so concurrent, unrelated confirmations remain pending.
201    async fn cancel(&self, tool_id: &str) -> bool {
202        self.confirm(tool_id, false, Some("Confirmation cancelled".to_string()))
203            .await
204            .unwrap_or(false)
205    }
206
207    /// A timer must not approve or deny a confirmation.
208    async fn expire(&self, _tool_id: &str, _action: TimeoutAction) -> bool {
209        false
210    }
211
212    /// Cancel all pending confirmations
213    async fn cancel_all(&self) -> usize;
214
215    /// Snapshot pending confirmations for status inspection.
216    async fn pending_confirmations(&self) -> Vec<PendingConfirmationInfo> {
217        Vec::new()
218    }
219}
220
221/// A pending confirmation request
222pub struct PendingConfirmation {
223    /// Tool call ID
224    pub tool_id: String,
225    /// Tool name
226    pub tool_name: String,
227    /// Tool arguments
228    pub args: serde_json::Value,
229    /// When the confirmation was requested
230    pub created_at: Instant,
231    /// Timeout in milliseconds
232    pub timeout_ms: u64,
233    /// Channel to send the response
234    response_tx: oneshot::Sender<ConfirmationResponse>,
235}
236
237impl PendingConfirmation {
238    /// Check if this confirmation has timed out
239    pub fn is_timed_out(&self) -> bool {
240        self.created_at.elapsed() > Duration::from_millis(self.timeout_ms)
241    }
242
243    /// Get remaining time until timeout in milliseconds
244    pub fn remaining_ms(&self) -> u64 {
245        let elapsed = self.created_at.elapsed().as_millis() as u64;
246        self.timeout_ms.saturating_sub(elapsed)
247    }
248}
249
250/// Manages confirmation requests for a session
251pub struct ConfirmationManager {
252    /// Confirmation policy
253    policy: RwLock<ConfirmationPolicy>,
254    /// Pending confirmations by tool_id
255    pending: Arc<RwLock<HashMap<String, PendingConfirmation>>>,
256    /// Event broadcaster
257    event_tx: broadcast::Sender<AgentEvent>,
258}
259
260impl ConfirmationManager {
261    /// Create a new confirmation manager
262    pub fn new(policy: ConfirmationPolicy, event_tx: broadcast::Sender<AgentEvent>) -> Self {
263        Self {
264            policy: RwLock::new(policy),
265            pending: Arc::new(RwLock::new(HashMap::new())),
266            event_tx,
267        }
268    }
269
270    /// Get the current policy
271    pub async fn policy(&self) -> ConfirmationPolicy {
272        self.policy.read().await.clone()
273    }
274
275    /// Update the confirmation policy
276    pub async fn set_policy(&self, policy: ConfirmationPolicy) {
277        *self.policy.write().await = policy;
278    }
279
280    /// Check if a tool requires confirmation
281    pub async fn requires_confirmation(&self, tool_name: &str) -> bool {
282        self.policy.read().await.requires_confirmation(tool_name)
283    }
284
285    /// Request confirmation for a tool execution
286    ///
287    /// Returns a receiver that will receive the confirmation response.
288    /// Emits a ConfirmationRequired event.
289    pub async fn request_confirmation(
290        &self,
291        tool_id: &str,
292        tool_name: &str,
293        args: &serde_json::Value,
294    ) -> oneshot::Receiver<ConfirmationResponse> {
295        let (tx, rx) = oneshot::channel();
296
297        let policy = self.policy.read().await;
298        let timeout_ms = policy.default_timeout_ms;
299        drop(policy);
300
301        let pending = PendingConfirmation {
302            tool_id: tool_id.to_string(),
303            tool_name: tool_name.to_string(),
304            args: args.clone(),
305            created_at: Instant::now(),
306            timeout_ms,
307            response_tx: tx,
308        };
309
310        // A tool id is the authority boundary used by the host to settle a
311        // request. Two live requests with the same id cannot be distinguished
312        // safely, so reject both instead of replacing one receiver and letting
313        // a later approval apply to ambiguous arguments.
314        let collision = {
315            let mut pending_map = self.pending.write().await;
316            match pending_map.entry(tool_id.to_string()) {
317                std::collections::hash_map::Entry::Vacant(entry) => {
318                    entry.insert(pending);
319                    None
320                }
321                std::collections::hash_map::Entry::Occupied(entry) => {
322                    Some((entry.remove(), pending))
323                }
324            }
325        };
326        if let Some((existing, duplicate)) = collision {
327            let reason = Some(format!(
328                "Duplicate confirmation tool id '{tool_id}'; both requests were rejected"
329            ));
330            let response = ConfirmationResponse {
331                approved: false,
332                reason: reason.clone(),
333            };
334            let _ = existing.response_tx.send(response.clone());
335            let _ = duplicate.response_tx.send(response);
336            let _ = self.event_tx.send(AgentEvent::ConfirmationReceived {
337                tool_id: tool_id.to_string(),
338                approved: false,
339                reason,
340            });
341            return rx;
342        }
343
344        // Emit confirmation required event
345        let _ = self.event_tx.send(AgentEvent::ConfirmationRequired {
346            tool_id: tool_id.to_string(),
347            tool_name: tool_name.to_string(),
348            args: args.clone(),
349            timeout_ms,
350        });
351
352        rx
353    }
354
355    /// Handle a confirmation response from the user
356    ///
357    /// Returns Ok(true) if the confirmation was found and processed,
358    /// Ok(false) if no pending confirmation was found.
359    pub async fn confirm(
360        &self,
361        tool_id: &str,
362        approved: bool,
363        reason: Option<String>,
364    ) -> Result<bool, String> {
365        let pending = {
366            let mut pending_map = self.pending.write().await;
367            pending_map.remove(tool_id)
368        };
369
370        if let Some(confirmation) = pending {
371            // Emit confirmation received event
372            let _ = self.event_tx.send(AgentEvent::ConfirmationReceived {
373                tool_id: tool_id.to_string(),
374                approved,
375                reason: reason.clone(),
376            });
377
378            // Send the response
379            let response = ConfirmationResponse { approved, reason };
380            let _ = confirmation.response_tx.send(response);
381
382            Ok(true)
383        } else {
384            Ok(false)
385        }
386    }
387
388    /// Confirmations settle only when a `confirmation.answered` fact is appended.
389    ///
390    /// This returns zero and does not approve or deny on a timer.
391    pub async fn check_timeouts(&self) -> usize {
392        0
393    }
394
395    /// Get the number of pending confirmations
396    pub async fn pending_count(&self) -> usize {
397        self.pending.read().await.len()
398    }
399
400    /// Get pending confirmation details (for debugging/status)
401    pub async fn pending_confirmations(&self) -> Vec<(String, String, u64)> {
402        let pending_map = self.pending.read().await;
403        pending_map
404            .values()
405            .map(|p| (p.tool_id.clone(), p.tool_name.clone(), p.remaining_ms()))
406            .collect()
407    }
408
409    /// Get detailed pending confirmation snapshots.
410    pub async fn pending_confirmation_details(&self) -> Vec<PendingConfirmationInfo> {
411        let pending_map = self.pending.read().await;
412        pending_map
413            .values()
414            .map(|p| PendingConfirmationInfo {
415                tool_id: p.tool_id.clone(),
416                tool_name: p.tool_name.clone(),
417                args: p.args.clone(),
418                remaining_ms: p.remaining_ms(),
419            })
420            .collect()
421    }
422
423    /// Cancel a pending confirmation
424    pub async fn cancel(&self, tool_id: &str) -> bool {
425        let pending = {
426            let mut pending_map = self.pending.write().await;
427            pending_map.remove(tool_id)
428        };
429
430        if let Some(confirmation) = pending {
431            let response = ConfirmationResponse {
432                approved: false,
433                reason: Some("Confirmation cancelled".to_string()),
434            };
435            let _ = confirmation.response_tx.send(response);
436            true
437        } else {
438            false
439        }
440    }
441
442    /// A timer must not approve or deny. The pending confirmation stays parked.
443    pub async fn expire(&self, _tool_id: &str, _action: TimeoutAction) -> bool {
444        false
445    }
446
447    /// Cancel all pending confirmations
448    pub async fn cancel_all(&self) -> usize {
449        let pending_list: Vec<_> = {
450            let mut pending_map = self.pending.write().await;
451            pending_map.drain().collect()
452        };
453
454        let count = pending_list.len();
455
456        for (_, confirmation) in pending_list {
457            let response = ConfirmationResponse {
458                approved: false,
459                reason: Some("Confirmation cancelled".to_string()),
460            };
461            let _ = confirmation.response_tx.send(response);
462        }
463
464        count
465    }
466}
467
468// Implement ConfirmationProvider trait for ConfirmationManager
469#[async_trait::async_trait]
470impl ConfirmationProvider for ConfirmationManager {
471    async fn requires_confirmation(&self, tool_name: &str) -> bool {
472        self.requires_confirmation(tool_name).await
473    }
474
475    async fn request_confirmation(
476        &self,
477        tool_id: &str,
478        tool_name: &str,
479        args: &serde_json::Value,
480    ) -> oneshot::Receiver<ConfirmationResponse> {
481        self.request_confirmation(tool_id, tool_name, args).await
482    }
483
484    async fn confirm(
485        &self,
486        tool_id: &str,
487        approved: bool,
488        reason: Option<String>,
489    ) -> Result<bool, String> {
490        self.confirm(tool_id, approved, reason).await
491    }
492
493    async fn policy(&self) -> ConfirmationPolicy {
494        self.policy().await
495    }
496
497    async fn set_policy(&self, policy: ConfirmationPolicy) {
498        self.set_policy(policy).await
499    }
500
501    async fn check_timeouts(&self) -> usize {
502        self.check_timeouts().await
503    }
504
505    async fn cancel(&self, tool_id: &str) -> bool {
506        self.cancel(tool_id).await
507    }
508
509    async fn expire(&self, tool_id: &str, action: TimeoutAction) -> bool {
510        self.expire(tool_id, action).await
511    }
512
513    async fn cancel_all(&self) -> usize {
514        self.cancel_all().await
515    }
516
517    async fn pending_confirmations(&self) -> Vec<PendingConfirmationInfo> {
518        self.pending_confirmation_details().await
519    }
520}
521
522/// A confirmation provider that never requires confirmation.
523///
524/// Used for child runs where the agent's permission policy already provides
525/// the access control boundary. When permissions return `Ask` for a tool not
526/// explicitly covered, this provider auto-approves instead of blocking.
527pub struct AutoApproveConfirmation;
528
529#[async_trait::async_trait]
530impl ConfirmationProvider for AutoApproveConfirmation {
531    async fn requires_confirmation(&self, _tool_name: &str) -> bool {
532        false
533    }
534
535    async fn request_confirmation(
536        &self,
537        _tool_id: &str,
538        _tool_name: &str,
539        _args: &serde_json::Value,
540    ) -> oneshot::Receiver<ConfirmationResponse> {
541        let (tx, rx) = oneshot::channel();
542        let _ = tx.send(ConfirmationResponse {
543            approved: true,
544            reason: None,
545        });
546        rx
547    }
548
549    async fn confirm(
550        &self,
551        _tool_id: &str,
552        _approved: bool,
553        _reason: Option<String>,
554    ) -> Result<bool, String> {
555        Ok(false)
556    }
557
558    async fn policy(&self) -> ConfirmationPolicy {
559        ConfirmationPolicy {
560            enabled: false,
561            ..ConfirmationPolicy::default()
562        }
563    }
564
565    async fn set_policy(&self, _policy: ConfirmationPolicy) {}
566
567    async fn check_timeouts(&self) -> usize {
568        0
569    }
570
571    async fn cancel(&self, _tool_id: &str) -> bool {
572        false
573    }
574
575    async fn expire(&self, _tool_id: &str, _action: TimeoutAction) -> bool {
576        false
577    }
578
579    async fn cancel_all(&self) -> usize {
580        0
581    }
582}
583
584#[cfg(test)]
585#[path = "hitl/tests.rs"]
586mod tests;