1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
20pub enum TimeoutAction {
21 #[default]
23 Reject,
24 AutoApprove,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ConfirmationPolicy {
35 pub enabled: bool,
37
38 pub default_timeout_ms: u64,
40
41 pub timeout_action: TimeoutAction,
43
44 pub yolo_lanes: HashSet<SessionLane>,
48}
49
50impl Default for ConfirmationPolicy {
51 fn default() -> Self {
52 Self {
53 enabled: false, default_timeout_ms: 30_000, timeout_action: TimeoutAction::Reject,
56 yolo_lanes: HashSet::new(), }
58 }
59}
60
61impl ConfirmationPolicy {
62 pub fn enabled() -> Self {
64 Self {
65 enabled: true,
66 ..Default::default()
67 }
68 }
69
70 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 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 pub fn is_yolo(&self, tool_name: &str) -> bool {
88 if !self.enabled {
89 return true; }
91 let lane = SessionLane::from_tool_name(tool_name);
92 self.yolo_lanes.contains(&lane)
93 }
94
95 pub fn requires_confirmation(&self, tool_name: &str) -> bool {
100 !self.is_yolo(tool_name)
101 }
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct ConfirmationResponse {
107 pub approved: bool,
109 pub reason: Option<String>,
111}
112
113#[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#[async_trait::async_trait]
127pub trait ConfirmationProvider: Send + Sync {
128 fn snapshot_for_run(&self) -> Option<Arc<dyn ConfirmationProvider>> {
135 None
136 }
137
138 async fn requires_confirmation(&self, tool_name: &str) -> bool;
140
141 async fn requires_confirmation_for(&self, tool_name: &str, _args: &serde_json::Value) -> bool {
147 self.requires_confirmation(tool_name).await
148 }
149
150 async fn confirmation_available_for(
157 &self,
158 _tool_name: &str,
159 _args: &serde_json::Value,
160 ) -> bool {
161 true
162 }
163
164 async fn policy_for(&self, _tool_name: &str, _args: &serde_json::Value) -> ConfirmationPolicy {
170 self.policy().await
171 }
172
173 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 async fn confirm(
188 &self,
189 tool_id: &str,
190 approved: bool,
191 reason: Option<String>,
192 ) -> Result<bool, String>;
193
194 async fn policy(&self) -> ConfirmationPolicy;
196
197 async fn set_policy(&self, policy: ConfirmationPolicy);
199
200 async fn check_timeouts(&self) -> usize;
202
203 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 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 async fn cancel_all(&self) -> usize;
235
236 async fn pending_confirmations(&self) -> Vec<PendingConfirmationInfo> {
238 Vec::new()
239 }
240}
241
242pub struct PendingConfirmation {
244 pub tool_id: String,
246 pub tool_name: String,
248 pub args: serde_json::Value,
250 pub created_at: Instant,
252 pub timeout_ms: u64,
254 response_tx: oneshot::Sender<ConfirmationResponse>,
256}
257
258impl PendingConfirmation {
259 pub fn is_timed_out(&self) -> bool {
261 self.created_at.elapsed() > Duration::from_millis(self.timeout_ms)
262 }
263
264 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
271pub struct ConfirmationManager {
273 policy: RwLock<ConfirmationPolicy>,
275 pending: Arc<RwLock<HashMap<String, PendingConfirmation>>>,
277 event_tx: broadcast::Sender<AgentEvent>,
279}
280
281impl ConfirmationManager {
282 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 pub async fn policy(&self) -> ConfirmationPolicy {
293 self.policy.read().await.clone()
294 }
295
296 pub async fn set_policy(&self, policy: ConfirmationPolicy) {
298 *self.policy.write().await = policy;
299 }
300
301 pub async fn requires_confirmation(&self, tool_name: &str) -> bool {
303 self.policy.read().await.requires_confirmation(tool_name)
304 }
305
306 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 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 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 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 let _ = self.event_tx.send(AgentEvent::ConfirmationReceived {
394 tool_id: tool_id.to_string(),
395 approved,
396 reason: reason.clone(),
397 });
398
399 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 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 {
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 pub async fn pending_count(&self) -> usize {
441 self.pending.read().await.len()
442 }
443
444 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 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 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 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 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#[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
586pub 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;