agentd/subagent/
replies.rs1use serde_json::Value;
10use std::collections::HashMap;
11use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
12use std::sync::{Condvar, Mutex};
13use std::time::{Duration, Instant};
14
15#[derive(Debug, Clone, PartialEq)]
17pub enum Reply {
18 Tool {
19 result: Value,
20 is_error: bool,
21 },
22 Budget {
23 ok: bool,
24 wait_ms: u64,
25 model: Option<String>,
26 reason: Option<String>,
27 },
28}
29
30#[derive(Default)]
31pub struct Replies {
32 slots: Mutex<HashMap<u64, Reply>>,
33 cv: Condvar,
34 closed: AtomicBool,
35 next_id: AtomicU64,
36}
37
38impl Replies {
39 pub fn new() -> Replies {
40 Replies {
41 next_id: AtomicU64::new(1),
42 ..Default::default()
43 }
44 }
45
46 pub fn next_id(&self) -> u64 {
48 self.next_id.fetch_add(1, Ordering::Relaxed)
49 }
50
51 pub fn deliver(&self, id: u64, reply: Reply) {
53 self.slots
54 .lock()
55 .unwrap_or_else(|e| e.into_inner())
56 .insert(id, reply);
57 self.cv.notify_all();
58 }
59
60 pub fn close(&self) {
62 self.closed.store(true, Ordering::Relaxed);
63 self.cv.notify_all();
64 }
65
66 pub fn is_closed(&self) -> bool {
67 self.closed.load(Ordering::Relaxed)
68 }
69
70 pub fn wait(&self, id: u64, deadline: Instant, cancel: &AtomicBool) -> Option<Reply> {
77 let mut slots = self.slots.lock().unwrap_or_else(|e| e.into_inner());
78 loop {
79 if let Some(r) = slots.remove(&id) {
80 return Some(r);
81 }
82 if self.is_closed() || cancel.load(Ordering::Relaxed) {
83 return None;
84 }
85 let now = Instant::now();
86 if now >= deadline {
87 return None;
88 }
89 let wait = (deadline - now).min(Duration::from_millis(100));
90 let (guard, _) = self
91 .cv
92 .wait_timeout(slots, wait)
93 .unwrap_or_else(|e| e.into_inner());
94 slots = guard;
95 }
96 }
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102 use serde_json::json;
103 use std::sync::Arc;
104
105 #[test]
106 fn replies_are_delivered_by_id_and_waiters_wake_on_close_or_cancel() {
107 let r = Arc::new(Replies::new());
108 let id = r.next_id();
109 let r2 = r.clone();
110 let t = std::thread::spawn(move || {
111 std::thread::sleep(Duration::from_millis(30));
112 r2.deliver(
113 99,
114 Reply::Tool {
115 result: json!("other"),
116 is_error: false,
117 },
118 );
119 r2.deliver(
120 id,
121 Reply::Tool {
122 result: json!({"ok": true}),
123 is_error: false,
124 },
125 );
126 });
127 let cancel = AtomicBool::new(false);
128 let got = r
129 .wait(id, Instant::now() + Duration::from_secs(2), &cancel)
130 .unwrap();
131 assert_eq!(
132 got,
133 Reply::Tool {
134 result: json!({"ok": true}),
135 is_error: false
136 }
137 );
138 t.join().unwrap();
139 assert!(
141 r.wait(12345, Instant::now() + Duration::from_millis(20), &cancel)
142 .is_none()
143 );
144 cancel.store(true, Ordering::Relaxed);
146 assert!(
147 r.wait(12345, Instant::now() + Duration::from_secs(5), &cancel)
148 .is_none()
149 );
150 let cancel2 = AtomicBool::new(false);
152 let r3 = r.clone();
153 let t = std::thread::spawn(move || {
154 std::thread::sleep(Duration::from_millis(20));
155 r3.close();
156 });
157 assert!(
158 r.wait(777, Instant::now() + Duration::from_secs(5), &cancel2)
159 .is_none()
160 );
161 t.join().unwrap();
162 r.deliver(
164 5,
165 Reply::Budget {
166 ok: true,
167 wait_ms: 0,
168 model: None,
169 reason: None,
170 },
171 );
172 assert!(matches!(
173 r.wait(5, Instant::now(), &cancel2),
174 Some(Reply::Budget { ok: true, .. })
175 ));
176 }
177}