Skip to main content

clawft_core/
agent_bus.rs

1//! Inter-agent communication bus.
2//!
3//! [`AgentBus`] provides per-agent inboxes for agent-to-agent messaging
4//! with TTL enforcement and agent-scoped delivery (agents can only
5//! read from their own inbox).
6//!
7//! # Security
8//!
9//! - Messages are tagged with agent IDs.
10//! - Agents cannot read other agents' messages (inbox scoping).
11//! - Bounded inbox size prevents memory exhaustion.
12
13use std::collections::HashMap;
14use std::sync::Arc;
15use std::time::Duration;
16
17use tokio::sync::{Mutex, mpsc};
18use tracing::{debug, warn};
19
20use clawft_types::agent_bus::{AgentBusError, InterAgentMessage};
21
22/// Default inbox capacity per agent.
23const DEFAULT_INBOX_CAPACITY: usize = 256;
24
25/// Handle for an agent's inbox receiver.
26///
27/// This is returned from [`AgentBus::register_agent`] and provides
28/// the only way to read messages from the agent's inbox.
29pub struct AgentInbox {
30    agent_id: String,
31    rx: mpsc::Receiver<InterAgentMessage>,
32}
33
34impl AgentInbox {
35    /// The agent ID that owns this inbox.
36    pub fn agent_id(&self) -> &str {
37        &self.agent_id
38    }
39
40    /// Receive the next message from this inbox.
41    ///
42    /// Returns `None` if the bus has been dropped or the agent
43    /// has been unregistered.
44    pub async fn recv(&mut self) -> Option<InterAgentMessage> {
45        loop {
46            match self.rx.recv().await {
47                Some(msg) => {
48                    if msg.is_expired() {
49                        warn!(
50                            msg_id = %msg.id,
51                            from = %msg.from_agent,
52                            to = %msg.to_agent,
53                            "dropping expired inter-agent message"
54                        );
55                        continue;
56                    }
57                    return Some(msg);
58                }
59                None => return None,
60            }
61        }
62    }
63
64    /// Try to receive a message without blocking.
65    ///
66    /// Returns `None` if no message is available.
67    pub fn try_recv(&mut self) -> Option<InterAgentMessage> {
68        loop {
69            match self.rx.try_recv() {
70                Ok(msg) => {
71                    if msg.is_expired() {
72                        warn!(
73                            msg_id = %msg.id,
74                            from = %msg.from_agent,
75                            to = %msg.to_agent,
76                            "dropping expired inter-agent message"
77                        );
78                        continue;
79                    }
80                    return Some(msg);
81                }
82                Err(_) => return None,
83            }
84        }
85    }
86}
87
88/// Inter-agent communication bus with per-agent inboxes.
89///
90/// Agents register on the bus to get an inbox. Messages are delivered
91/// to the recipient's inbox. Agents can only read their own inbox
92/// (enforced by the [`AgentInbox`] handle).
93///
94/// # Thread safety
95///
96/// The bus uses `Arc<Mutex<..>>` internally for concurrent access.
97/// Send operations use `try_send` to avoid blocking callers.
98pub struct AgentBus {
99    /// Per-agent inbox senders: agent_id -> sender.
100    inboxes: Arc<Mutex<HashMap<String, mpsc::Sender<InterAgentMessage>>>>,
101    /// Inbox capacity for new registrations.
102    inbox_capacity: usize,
103}
104
105impl AgentBus {
106    /// Create a new agent bus with default inbox capacity (256).
107    pub fn new() -> Self {
108        Self {
109            inboxes: Arc::new(Mutex::new(HashMap::new())),
110            inbox_capacity: DEFAULT_INBOX_CAPACITY,
111        }
112    }
113
114    /// Create a new agent bus with custom inbox capacity.
115    pub fn with_capacity(capacity: usize) -> Self {
116        Self {
117            inboxes: Arc::new(Mutex::new(HashMap::new())),
118            inbox_capacity: capacity,
119        }
120    }
121
122    /// Register an agent on the bus and return its inbox handle.
123    ///
124    /// If the agent is already registered, the old inbox is replaced
125    /// (messages in the old inbox are lost).
126    pub async fn register_agent(&self, agent_id: &str) -> AgentInbox {
127        let (tx, rx) = mpsc::channel(self.inbox_capacity);
128        let mut inboxes = self.inboxes.lock().await;
129        inboxes.insert(agent_id.to_string(), tx);
130
131        debug!(agent_id = %agent_id, "agent registered on bus");
132
133        AgentInbox {
134            agent_id: agent_id.to_string(),
135            rx,
136        }
137    }
138
139    /// Unregister an agent from the bus.
140    ///
141    /// The agent's inbox sender is dropped, causing the receiver to
142    /// return `None` on next recv.
143    pub async fn unregister_agent(&self, agent_id: &str) {
144        let mut inboxes = self.inboxes.lock().await;
145        if inboxes.remove(agent_id).is_some() {
146            debug!(agent_id = %agent_id, "agent unregistered from bus");
147        }
148    }
149
150    /// Send a message to the specified agent's inbox.
151    ///
152    /// # Errors
153    ///
154    /// Returns [`AgentBusError::AgentNotFound`] if the target agent
155    /// is not registered.
156    /// Returns [`AgentBusError::InboxFull`] if the inbox has reached
157    /// its capacity limit.
158    /// Returns [`AgentBusError::MessageExpired`] if the message has
159    /// already expired.
160    pub async fn send(&self, msg: InterAgentMessage) -> Result<(), AgentBusError> {
161        // Check TTL before delivery.
162        if msg.is_expired() {
163            warn!(
164                msg_id = %msg.id,
165                from = %msg.from_agent,
166                to = %msg.to_agent,
167                "dropping expired message before delivery"
168            );
169            return Err(AgentBusError::MessageExpired { ttl: msg.ttl });
170        }
171
172        let inboxes = self.inboxes.lock().await;
173        let tx = inboxes
174            .get(&msg.to_agent)
175            .ok_or_else(|| AgentBusError::AgentNotFound(msg.to_agent.clone()))?;
176
177        tx.try_send(msg).map_err(|e| match e {
178            mpsc::error::TrySendError::Full(msg) => {
179                AgentBusError::InboxFull(msg.to_agent.clone())
180            }
181            mpsc::error::TrySendError::Closed(msg) => {
182                AgentBusError::AgentNotFound(msg.to_agent.clone())
183            }
184        })
185    }
186
187    /// Check whether an agent is registered on the bus.
188    pub async fn is_registered(&self, agent_id: &str) -> bool {
189        let inboxes = self.inboxes.lock().await;
190        inboxes.contains_key(agent_id)
191    }
192
193    /// List all registered agent IDs.
194    pub async fn registered_agents(&self) -> Vec<String> {
195        let inboxes = self.inboxes.lock().await;
196        inboxes.keys().cloned().collect()
197    }
198}
199
200impl Default for AgentBus {
201    fn default() -> Self {
202        Self::new()
203    }
204}
205
206/// Coordinator for dispatching subtasks to worker agents and collecting
207/// results via the [`AgentBus`].
208///
209/// Implements the coordinator pattern: a lead agent dispatches work to
210/// worker agents and waits for their replies.
211pub struct SwarmCoordinator {
212    /// Shared agent bus for message delivery.
213    bus: Arc<AgentBus>,
214    /// Agent ID of the coordinator.
215    coordinator_id: String,
216    /// Registered worker agent IDs.
217    worker_agents: Vec<String>,
218}
219
220impl SwarmCoordinator {
221    /// Create a new coordinator.
222    pub fn new(
223        bus: Arc<AgentBus>,
224        coordinator_id: impl Into<String>,
225        worker_agents: Vec<String>,
226    ) -> Self {
227        Self {
228            bus,
229            coordinator_id: coordinator_id.into(),
230            worker_agents,
231        }
232    }
233
234    /// Dispatch a subtask to a specific worker agent.
235    ///
236    /// Sends an [`InterAgentMessage`] to the worker and returns the
237    /// message ID for later correlation.
238    pub async fn dispatch_subtask(
239        &self,
240        task: &str,
241        worker: &str,
242        payload: serde_json::Value,
243        ttl: Duration,
244    ) -> Result<uuid::Uuid, AgentBusError> {
245        let msg = InterAgentMessage::new(
246            &self.coordinator_id,
247            worker,
248            task,
249            payload,
250            ttl,
251        );
252        let msg_id = msg.id;
253        self.bus.send(msg).await?;
254        debug!(
255            coordinator = %self.coordinator_id,
256            worker = %worker,
257            task = %task,
258            msg_id = %msg_id,
259            "dispatched subtask"
260        );
261        Ok(msg_id)
262    }
263
264    /// Dispatch the same task to all registered workers.
265    ///
266    /// Returns a list of (worker_id, message_id) pairs.
267    pub async fn broadcast_task(
268        &self,
269        task: &str,
270        payload: serde_json::Value,
271        ttl: Duration,
272    ) -> Vec<(String, Result<uuid::Uuid, AgentBusError>)> {
273        let mut results = Vec::new();
274        for worker in &self.worker_agents {
275            let result = self
276                .dispatch_subtask(task, worker, payload.clone(), ttl)
277                .await;
278            results.push((worker.clone(), result));
279        }
280        results
281    }
282
283    /// Coordinator agent ID.
284    pub fn coordinator_id(&self) -> &str {
285        &self.coordinator_id
286    }
287
288    /// List of registered worker agent IDs.
289    pub fn workers(&self) -> &[String] {
290        &self.worker_agents
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use serde_json::json;
298
299    #[tokio::test]
300    async fn register_and_send() {
301        let bus = AgentBus::new();
302        let mut inbox = bus.register_agent("agent-a").await;
303
304        let msg = InterAgentMessage::new(
305            "agent-b",
306            "agent-a",
307            "do work",
308            json!({}),
309            Duration::from_secs(60),
310        );
311        bus.send(msg).await.unwrap();
312
313        let received = inbox.recv().await.unwrap();
314        assert_eq!(received.from_agent, "agent-b");
315        assert_eq!(received.to_agent, "agent-a");
316        assert_eq!(received.task, "do work");
317    }
318
319    #[tokio::test]
320    async fn send_to_unregistered_agent_fails() {
321        let bus = AgentBus::new();
322        let msg = InterAgentMessage::new(
323            "sender",
324            "nonexistent",
325            "task",
326            json!({}),
327            Duration::from_secs(60),
328        );
329        let result = bus.send(msg).await;
330        assert!(result.is_err());
331        assert!(matches!(
332            result.unwrap_err(),
333            AgentBusError::AgentNotFound(_)
334        ));
335    }
336
337    #[tokio::test]
338    async fn agent_isolation() {
339        let bus = AgentBus::new();
340        let mut inbox_a = bus.register_agent("agent-a").await;
341        let mut inbox_b = bus.register_agent("agent-b").await;
342
343        // Send to agent-a only.
344        let msg = InterAgentMessage::new(
345            "sender",
346            "agent-a",
347            "for a only",
348            json!({}),
349            Duration::from_secs(60),
350        );
351        bus.send(msg).await.unwrap();
352
353        // agent-a should receive it.
354        let received = inbox_a.try_recv();
355        assert!(received.is_some());
356        assert_eq!(received.unwrap().task, "for a only");
357
358        // agent-b should not.
359        let received_b = inbox_b.try_recv();
360        assert!(received_b.is_none());
361    }
362
363    #[tokio::test]
364    async fn unregister_agent() {
365        let bus = AgentBus::new();
366        let _inbox = bus.register_agent("agent-a").await;
367
368        assert!(bus.is_registered("agent-a").await);
369        bus.unregister_agent("agent-a").await;
370        assert!(!bus.is_registered("agent-a").await);
371    }
372
373    #[tokio::test]
374    async fn inbox_full_backpressure() {
375        let bus = AgentBus::with_capacity(2);
376        let _inbox = bus.register_agent("agent-a").await;
377
378        // Fill inbox.
379        for i in 0..2 {
380            let msg = InterAgentMessage::new(
381                "sender",
382                "agent-a",
383                &format!("task-{i}"),
384                json!({}),
385                Duration::from_secs(60),
386            );
387            bus.send(msg).await.unwrap();
388        }
389
390        // Third should fail.
391        let msg = InterAgentMessage::new(
392            "sender",
393            "agent-a",
394            "overflow",
395            json!({}),
396            Duration::from_secs(60),
397        );
398        let result = bus.send(msg).await;
399        assert!(result.is_err());
400        assert!(matches!(result.unwrap_err(), AgentBusError::InboxFull(_)));
401    }
402
403    #[tokio::test]
404    async fn registered_agents_list() {
405        let bus = AgentBus::new();
406        bus.register_agent("a").await;
407        bus.register_agent("b").await;
408
409        let agents = bus.registered_agents().await;
410        assert_eq!(agents.len(), 2);
411        assert!(agents.contains(&"a".to_string()));
412        assert!(agents.contains(&"b".to_string()));
413    }
414
415    #[tokio::test]
416    async fn coordinator_dispatch() {
417        let bus = Arc::new(AgentBus::new());
418        let _inbox = bus.register_agent("worker-1").await;
419
420        let coord = SwarmCoordinator::new(
421            bus.clone(),
422            "coordinator",
423            vec!["worker-1".into()],
424        );
425
426        let msg_id = coord
427            .dispatch_subtask("subtask-1", "worker-1", json!({"data": 42}), Duration::from_secs(60))
428            .await
429            .unwrap();
430
431        // Message ID should be valid.
432        assert!(!msg_id.is_nil());
433    }
434
435    #[tokio::test]
436    async fn coordinator_broadcast() {
437        let bus = Arc::new(AgentBus::new());
438        let _inbox1 = bus.register_agent("w1").await;
439        let _inbox2 = bus.register_agent("w2").await;
440
441        let coord = SwarmCoordinator::new(
442            bus.clone(),
443            "coord",
444            vec!["w1".into(), "w2".into()],
445        );
446
447        let results = coord
448            .broadcast_task("broadcast-task", json!({}), Duration::from_secs(60))
449            .await;
450
451        assert_eq!(results.len(), 2);
452        for (_, result) in &results {
453            assert!(result.is_ok());
454        }
455    }
456
457    #[test]
458    fn agent_bus_is_send_sync() {
459        fn assert_send_sync<T: Send + Sync>() {}
460        assert_send_sync::<AgentBus>();
461    }
462}