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