Skip to main content

agent_works/multi_agent/
mailbox.rs

1//! Asynchronous message passing between agents.
2//!
3//! The [`MailboxHub`] manages per-agent mailboxes and a global sequence number
4//! (`tokio::sync::watch<u64>`) that notifies waiters when any result arrives.
5//!
6//! # Architecture
7//!
8//! ```text
9//! Parent (LLM tools)                      Child (AgentRuntime task)
10//!        │                                         │
11//!        │  send_task() / send_message()           │
12//!        ├────────────────────────────────────────►│ task_rx
13//!        │                                         │
14//!        │                      post_result()      │
15//!        │◄────────────────────────────────────────┤ (via result_tx clone)
16//!        │                                         │
17//!        │  wait_for_result() watches seq_rx       │
18//!        │  (blocks until seq changes)             │
19//! ```
20//!
21//! Every `post_result()` increments the global sequence number, waking all
22//! `wait_for_result()` callers.
23
24use std::collections::HashMap;
25use std::sync::Mutex;
26
27use tokio::sync::{mpsc, watch};
28
29use super::path::AgentPath;
30
31// ---------------------------------------------------------------------------
32// Types
33// ---------------------------------------------------------------------------
34
35/// A task sent from parent to child agent.
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct MailboxTask {
38    /// The task description / user input for the child agent.
39    pub task: String,
40    /// Whether to interrupt the child's current execution.
41    pub interrupt: bool,
42    /// Pending messages accumulated before this task (from `send_message`).
43    pub pending_messages: Vec<String>,
44}
45
46/// Status of a result posted from child to parent.
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub enum MailboxStatus {
49    /// Child agent completed its task successfully.
50    Ok,
51    /// Child agent encountered an error.
52    Error,
53    /// Child agent was closed.
54    Closed,
55}
56
57/// A result posted from child agent to parent.
58#[derive(Clone, Debug)]
59pub struct MailboxResult {
60    /// Which agent produced this result.
61    pub agent_path: AgentPath,
62    /// The status of the result.
63    pub status: MailboxStatus,
64    /// The result text (if any).
65    pub result: Option<String>,
66    /// Tools the child attempted to call but were denied (permission wall).
67    pub denied_tools: Vec<String>,
68}
69
70// ---------------------------------------------------------------------------
71// Per-agent mailbox handle (child side)
72// ---------------------------------------------------------------------------
73
74/// The child-side handle to a mailbox.
75///
76/// Given to the spawned child agent task. The child reads tasks from `task_rx`
77/// and posts results via `hub.post_result()` using the agent path.
78#[derive(Debug)]
79pub struct ChildMailbox {
80    /// Receive tasks from parent.
81    pub task_rx: mpsc::Receiver<MailboxTask>,
82}
83
84// ---------------------------------------------------------------------------
85// Per-agent mailbox (internal)
86// ---------------------------------------------------------------------------
87
88struct MailboxEntry {
89    /// Send tasks to child.
90    task_tx: mpsc::Sender<MailboxTask>,
91    /// Results received from child (or posted by runtime).
92    results: Vec<MailboxResult>,
93    /// Pending messages (from `send_message`, no execution trigger).
94    pending: Vec<String>,
95}
96
97// ---------------------------------------------------------------------------
98// MailboxHub
99// ---------------------------------------------------------------------------
100
101/// Central hub for inter-agent message passing.
102///
103/// Manages per-agent mailboxes and a global sequence number. The sequence
104/// number increments every time a result is posted, allowing `wait_for_result`
105/// to efficiently block until new data arrives.
106///
107/// All methods use internal `Mutex` — the hub is designed to be shared
108/// via `Arc<MailboxHub>`.
109pub struct MailboxHub {
110    entries: Mutex<HashMap<AgentPath, MailboxEntry>>,
111    seq_tx: watch::Sender<u64>,
112    seq_rx: watch::Receiver<u64>,
113}
114
115impl MailboxHub {
116    /// Create a new empty mailbox hub.
117    pub fn new() -> Self {
118        let (seq_tx, seq_rx) = watch::channel(0);
119        Self {
120            entries: Mutex::new(HashMap::new()),
121            seq_tx,
122            seq_rx,
123        }
124    }
125
126    /// Register a new agent mailbox.
127    ///
128    /// Returns the child-side handle to be given to the spawned agent task.
129    /// Returns `None` if the agent_path is already registered.
130    pub fn register(&self, agent_path: &AgentPath) -> Option<ChildMailbox> {
131        let mut entries = self.entries.lock().unwrap();
132        if entries.contains_key(agent_path) {
133            return None;
134        }
135        let (task_tx, task_rx) = mpsc::channel(32);
136        entries.insert(
137            agent_path.clone(),
138            MailboxEntry {
139                task_tx,
140                results: Vec::new(),
141                pending: Vec::new(),
142            },
143        );
144        Some(ChildMailbox { task_rx })
145    }
146
147    /// Unregister an agent mailbox.
148    ///
149    /// Posts a `Closed` result first (to wake any waiters), then removes the entry.
150    /// Returns `true` if the agent was registered.
151    pub fn unregister(&self, agent_path: &AgentPath) -> bool {
152        let mut entries = self.entries.lock().unwrap();
153        if entries.get(agent_path).is_some() {
154            // Wake waiters with sequence bump
155            let current = *self.seq_rx.borrow();
156            let _ = self.seq_tx.send(current.wrapping_add(1));
157            entries.remove(agent_path);
158            true
159        } else {
160            false
161        }
162    }
163
164    /// Send a message to a sub-agent (no execution trigger).
165    ///
166    /// The message is appended to the agent's pending message buffer.
167    /// Returns `true` if the message was queued, `false` if the agent is not registered.
168    pub fn send_message(&self, agent_path: &AgentPath, message: String) -> bool {
169        let mut entries = self.entries.lock().unwrap();
170        match entries.get_mut(agent_path) {
171            Some(entry) => {
172                entry.pending.push(message);
173                true
174            }
175            None => false,
176        }
177    }
178
179    /// Send a task to a sub-agent (triggers execution).
180    ///
181    /// Drains pending messages and packages them with the task.
182    /// Returns `true` if the task was sent, `false` if the agent is not registered
183    /// or the channel is full.
184    pub fn send_task(&self, agent_path: &AgentPath, task: String, interrupt: bool) -> bool {
185        let mut entries = self.entries.lock().unwrap();
186        match entries.get_mut(agent_path) {
187            Some(entry) => {
188                let pending = std::mem::take(&mut entry.pending);
189                let mailbox_task = MailboxTask {
190                    task,
191                    interrupt,
192                    pending_messages: pending,
193                };
194                entry.task_tx.try_send(mailbox_task).is_ok()
195            }
196            None => false,
197        }
198    }
199
200    /// Check if an agent has pending (unread) messages.
201    pub fn has_pending(&self, agent_path: &AgentPath) -> bool {
202        let entries = self.entries.lock().unwrap();
203        entries
204            .get(agent_path)
205            .map(|e| !e.pending.is_empty())
206            .unwrap_or(false)
207    }
208
209    /// Post a result from a child agent.
210    ///
211    /// Increments the global sequence number, waking all `wait_for_result` callers.
212    pub fn post_result(&self, result: MailboxResult) {
213        let mut entries = self.entries.lock().unwrap();
214        if let Some(entry) = entries.get_mut(&result.agent_path) {
215            entry.results.push(result);
216            // Notify waiters
217            let current = *self.seq_rx.borrow();
218            let _ = self.seq_tx.send(current.wrapping_add(1));
219        }
220    }
221
222    /// Get a clone of the global sequence number receiver.
223    ///
224    /// Used by `wait_agent` to watch for changes before polling.
225    pub fn subscribe_seq(&self) -> watch::Receiver<u64> {
226        self.seq_rx.clone()
227    }
228
229    /// Try to receive a result for a specific agent (non-blocking).
230    ///
231    /// Returns the oldest unread result for the agent, or `None`.
232    pub fn try_recv_result(&self, agent_path: &AgentPath) -> Option<MailboxResult> {
233        let mut entries = self.entries.lock().unwrap();
234        entries.get_mut(agent_path).and_then(|e| {
235            if e.results.is_empty() {
236                None
237            } else {
238                Some(e.results.remove(0))
239            }
240        })
241    }
242
243    /// Try to receive any result (non-blocking).
244    ///
245    /// Returns the first available result from any agent mailbox.
246    pub fn try_recv_any(&self) -> Option<MailboxResult> {
247        let mut entries = self.entries.lock().unwrap();
248        for entry in entries.values_mut() {
249            if !entry.results.is_empty() {
250                return Some(entry.results.remove(0));
251            }
252        }
253        None
254    }
255
256    /// Check if an agent has unread results.
257    pub fn has_results(&self, agent_path: &AgentPath) -> bool {
258        let entries = self.entries.lock().unwrap();
259        entries
260            .get(agent_path)
261            .map(|e| !e.results.is_empty())
262            .unwrap_or(false)
263    }
264
265    /// Return the total number of unread results across all agents.
266    pub fn total_pending_results(&self) -> usize {
267        let entries = self.entries.lock().unwrap();
268        entries.values().map(|e| e.results.len()).sum()
269    }
270
271    /// Check if an agent is registered.
272    pub fn contains(&self, agent_path: &AgentPath) -> bool {
273        let entries = self.entries.lock().unwrap();
274        entries.contains_key(agent_path)
275    }
276
277    /// Return the number of registered agents.
278    pub fn len(&self) -> usize {
279        let entries = self.entries.lock().unwrap();
280        entries.len()
281    }
282
283    /// Return whether there are no registered agents.
284    pub fn is_empty(&self) -> bool {
285        self.len() == 0
286    }
287
288    /// Return all registered agent paths.
289    pub fn agent_paths(&self) -> Vec<AgentPath> {
290        let entries = self.entries.lock().unwrap();
291        entries.keys().cloned().collect()
292    }
293}
294
295impl Default for MailboxHub {
296    fn default() -> Self {
297        Self::new()
298    }
299}
300
301// ---------------------------------------------------------------------------
302// Tests
303// ---------------------------------------------------------------------------
304
305#[cfg(test)]
306mod tests {
307    use std::sync::Arc;
308
309    use super::*;
310
311    fn test_path(name: &str) -> AgentPath {
312        AgentPath::root().join(name)
313    }
314
315    #[test]
316    fn register_and_unregister() {
317        let hub = MailboxHub::new();
318        let path = test_path("test-agent");
319
320        assert!(!hub.contains(&path));
321        assert_eq!(hub.len(), 0);
322
323        let child = hub.register(&path);
324        assert!(child.is_some());
325        assert!(hub.contains(&path));
326        assert_eq!(hub.len(), 1);
327
328        // Duplicate register fails
329        assert!(hub.register(&path).is_none());
330
331        assert!(hub.unregister(&path));
332        assert!(!hub.contains(&path));
333        assert_eq!(hub.len(), 0);
334
335        // Double unregister is a no-op
336        assert!(!hub.unregister(&path));
337    }
338
339    #[test]
340    fn send_message_and_task() {
341        let hub = MailboxHub::new();
342        let path = test_path("worker");
343
344        let mut child = hub.register(&path).unwrap();
345
346        // Messages accumulate without triggering
347        assert!(hub.send_message(&path, "hello".into()));
348        assert!(hub.send_message(&path, "world".into()));
349        assert!(hub.has_pending(&path));
350
351        // Send to non-existent agent
352        assert!(!hub.send_message(&test_path("ghost"), "nope".into()));
353
354        // Task drains pending
355        assert!(hub.send_task(&path, "do work".into(), true));
356        assert!(!hub.has_pending(&path));
357
358        // Child receives task with pending messages
359        let received = child.task_rx.try_recv().unwrap();
360        assert_eq!(received.task, "do work");
361        assert!(received.interrupt);
362        assert_eq!(received.pending_messages, vec!["hello", "world"]);
363    }
364
365    #[test]
366    fn post_and_receive_result() {
367        let hub = MailboxHub::new();
368        let path = test_path("worker");
369
370        hub.register(&path);
371
372        hub.post_result(MailboxResult {
373            agent_path: path.clone(),
374            status: MailboxStatus::Ok,
375            result: Some("done!".into()),
376            denied_tools: vec![],
377        });
378
379        assert!(hub.has_results(&path));
380
381        let received = hub.try_recv_result(&path);
382        assert!(received.is_some());
383        let r = received.unwrap();
384        assert_eq!(r.agent_path, path);
385        assert_eq!(r.status, MailboxStatus::Ok);
386        assert_eq!(r.result.unwrap(), "done!");
387
388        assert!(!hub.has_results(&path));
389    }
390
391    #[test]
392    fn try_recv_any_returns_all() {
393        let hub = MailboxHub::new();
394        let a = test_path("a");
395        let b = test_path("b");
396
397        hub.register(&a);
398        hub.register(&b);
399
400        hub.post_result(MailboxResult {
401            agent_path: a.clone(),
402            status: MailboxStatus::Ok,
403            result: Some("first".into()),
404            denied_tools: vec![],
405        });
406        hub.post_result(MailboxResult {
407            agent_path: b.clone(),
408            status: MailboxStatus::Error,
409            result: Some("second".into()),
410            denied_tools: vec![],
411        });
412
413        // HashMap iteration order is non-deterministic, so just check we get both
414        let r1 = hub.try_recv_any().unwrap();
415        let r2 = hub.try_recv_any().unwrap();
416        assert!(hub.try_recv_any().is_none());
417
418        let mut paths = vec![r1.agent_path.to_string(), r2.agent_path.to_string()];
419        paths.sort();
420        assert_eq!(paths, vec!["root/a", "root/b"]);
421    }
422
423    #[test]
424    fn sequence_number_changes_on_post() {
425        let hub = MailboxHub::new();
426        let path = test_path("worker");
427        hub.register(&path);
428
429        let seq = hub.subscribe_seq();
430        let initial = *seq.borrow();
431
432        hub.post_result(MailboxResult {
433            agent_path: path.clone(),
434            status: MailboxStatus::Ok,
435            result: None,
436            denied_tools: vec![],
437        });
438
439        assert!(seq.has_changed().unwrap());
440        assert_ne!(*seq.borrow(), initial);
441    }
442
443    #[test]
444    fn sequence_number_changes_on_unregister() {
445        let hub = MailboxHub::new();
446        let path = test_path("worker");
447        hub.register(&path);
448
449        let seq = hub.subscribe_seq();
450        let initial = *seq.borrow();
451
452        hub.unregister(&path);
453
454        assert!(seq.has_changed().unwrap());
455        assert_ne!(*seq.borrow(), initial);
456    }
457
458    #[test]
459    fn agent_paths() {
460        let hub = MailboxHub::new();
461        hub.register(&test_path("a"));
462        hub.register(&test_path("b"));
463
464        let mut paths = hub.agent_paths();
465        paths.sort();
466        assert_eq!(paths.len(), 2);
467    }
468
469    #[test]
470    fn total_pending_results() {
471        let hub = MailboxHub::new();
472        let a = test_path("a");
473        hub.register(&a);
474
475        assert_eq!(hub.total_pending_results(), 0);
476
477        hub.post_result(MailboxResult {
478            agent_path: a.clone(),
479            status: MailboxStatus::Ok,
480            result: None,
481            denied_tools: vec![],
482        });
483        assert_eq!(hub.total_pending_results(), 1);
484
485        hub.post_result(MailboxResult {
486            agent_path: a.clone(),
487            status: MailboxStatus::Ok,
488            result: None,
489            denied_tools: vec![],
490        });
491        assert_eq!(hub.total_pending_results(), 2);
492
493        hub.try_recv_any();
494        assert_eq!(hub.total_pending_results(), 1);
495    }
496
497    #[tokio::test]
498    async fn wait_for_result_pattern() {
499        let hub = Arc::new(MailboxHub::new());
500        let path = test_path("worker");
501        hub.register(&path);
502
503        let hub_clone = hub.clone();
504        let path_clone = path.clone();
505
506        // Spawn a task that posts a result after a short delay
507        tokio::spawn(async move {
508            tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
509            hub_clone.post_result(MailboxResult {
510                agent_path: path_clone,
511                status: MailboxStatus::Ok,
512                result: Some("async result".into()),
513                denied_tools: vec![],
514            });
515        });
516
517        // Wait for result using the seq pattern
518        let mut seq = hub.subscribe_seq();
519        loop {
520            match hub.try_recv_any() {
521                Some(r) => {
522                    assert_eq!(r.status, MailboxStatus::Ok);
523                    assert_eq!(r.result.unwrap(), "async result");
524                    break;
525                }
526                None => {
527                    let _ = seq.changed().await;
528                }
529            }
530        }
531    }
532}