Skip to main content

clawft_core/
bus.rs

1//! Message bus for async channel-agent communication.
2//!
3//! Provides a thread-safe [`MessageBus`] using bounded MPSC channels
4//! for routing inbound messages (from channels) and outbound messages
5//! (from the agent pipeline) with configurable backpressure.
6//!
7//! On native, uses tokio bounded MPSC channels.
8//! On browser/WASM, uses futures-channel unbounded MPSC channels.
9//!
10//! Ported from Python `nanobot/bus/queue.py`.
11
12#[cfg(feature = "native")]
13use tokio::sync::Mutex;
14
15use tracing::debug;
16
17use clawft_types::error::ClawftError;
18use clawft_types::event::{InboundMessage, OutboundMessage};
19
20/// Default channel capacity for bounded MPSC channels (native only).
21#[cfg(feature = "native")]
22const DEFAULT_CHANNEL_CAPACITY: usize = 1024;
23
24// ---------------------------------------------------------------------------
25// Native implementation (tokio channels)
26// ---------------------------------------------------------------------------
27
28#[cfg(feature = "native")]
29pub struct MessageBus {
30    inbound_tx: tokio::sync::mpsc::Sender<InboundMessage>,
31    inbound_rx: Mutex<tokio::sync::mpsc::Receiver<InboundMessage>>,
32    outbound_tx: tokio::sync::mpsc::Sender<OutboundMessage>,
33    outbound_rx: Mutex<tokio::sync::mpsc::Receiver<OutboundMessage>>,
34}
35
36#[cfg(feature = "native")]
37impl MessageBus {
38    /// Create a new message bus with the default channel capacity (1024).
39    pub fn new() -> Self {
40        Self::with_capacity(DEFAULT_CHANNEL_CAPACITY)
41    }
42
43    /// Create a new message bus with a custom channel capacity.
44    ///
45    /// Higher capacity allows more burst buffering at the cost of memory.
46    /// Lower capacity provides tighter backpressure.
47    pub fn with_capacity(capacity: usize) -> Self {
48        let (inbound_tx, inbound_rx) = tokio::sync::mpsc::channel(capacity);
49        let (outbound_tx, outbound_rx) = tokio::sync::mpsc::channel(capacity);
50
51        debug!(capacity, "MessageBus created with bounded channels");
52
53        Self {
54            inbound_tx,
55            inbound_rx: Mutex::new(inbound_rx),
56            outbound_tx,
57            outbound_rx: Mutex::new(outbound_rx),
58        }
59    }
60
61    /// Publish an inbound message (from a channel adapter) to the bus.
62    ///
63    /// Uses `try_send` to avoid requiring the caller to be async.
64    /// Returns an error if the channel is closed or the buffer is full.
65    pub fn publish_inbound(&self, msg: InboundMessage) -> Result<(), ClawftError> {
66        debug!(
67            channel = %msg.channel,
68            chat_id = %msg.chat_id,
69            "publishing inbound message"
70        );
71        self.inbound_tx.try_send(msg).map_err(|e| match e {
72            tokio::sync::mpsc::error::TrySendError::Full(_) => {
73                ClawftError::Channel("inbound channel full (backpressure)".into())
74            }
75            tokio::sync::mpsc::error::TrySendError::Closed(_) => {
76                ClawftError::Channel("inbound channel closed".into())
77            }
78        })
79    }
80
81    /// Publish an inbound message, waiting asynchronously if the buffer is full.
82    ///
83    /// Prefer this over [`publish_inbound`](Self::publish_inbound) in async
84    /// contexts to avoid dropping messages under backpressure.
85    pub async fn publish_inbound_async(
86        &self,
87        msg: InboundMessage,
88    ) -> Result<(), ClawftError> {
89        debug!(
90            channel = %msg.channel,
91            chat_id = %msg.chat_id,
92            "publishing inbound message (async)"
93        );
94        self.inbound_tx
95            .send(msg)
96            .await
97            .map_err(|e| ClawftError::Channel(format!("inbound channel closed: {e}")))
98    }
99
100    /// Consume the next inbound message from the bus.
101    ///
102    /// Returns `None` if the channel is closed and all buffered messages
103    /// have been consumed.
104    pub async fn consume_inbound(&self) -> Option<InboundMessage> {
105        let mut rx = self.inbound_rx.lock().await;
106        rx.recv().await
107    }
108
109    /// Dispatch an outbound message (from the agent pipeline) to the bus.
110    ///
111    /// Uses `try_send` to avoid requiring the caller to be async.
112    /// Returns an error if the channel is closed or the buffer is full.
113    pub fn dispatch_outbound(&self, msg: OutboundMessage) -> Result<(), ClawftError> {
114        debug!(
115            channel = %msg.channel,
116            chat_id = %msg.chat_id,
117            "dispatching outbound message"
118        );
119        self.outbound_tx.try_send(msg).map_err(|e| match e {
120            tokio::sync::mpsc::error::TrySendError::Full(_) => {
121                ClawftError::Channel("outbound channel full (backpressure)".into())
122            }
123            tokio::sync::mpsc::error::TrySendError::Closed(_) => {
124                ClawftError::Channel("outbound channel closed".into())
125            }
126        })
127    }
128
129    /// Dispatch an outbound message, waiting asynchronously if the buffer is full.
130    pub async fn dispatch_outbound_async(
131        &self,
132        msg: OutboundMessage,
133    ) -> Result<(), ClawftError> {
134        debug!(
135            channel = %msg.channel,
136            chat_id = %msg.chat_id,
137            "dispatching outbound message (async)"
138        );
139        self.outbound_tx
140            .send(msg)
141            .await
142            .map_err(|e| ClawftError::Channel(format!("outbound channel closed: {e}")))
143    }
144
145    /// Consume the next outbound message from the bus.
146    ///
147    /// Returns `None` if the channel is closed and all buffered messages
148    /// have been consumed.
149    pub async fn consume_outbound(&self) -> Option<OutboundMessage> {
150        let mut rx = self.outbound_rx.lock().await;
151        rx.recv().await
152    }
153
154    /// Get a cloneable sender handle for publishing inbound messages.
155    ///
156    /// Channel adapters should clone this sender so that multiple producers
157    /// (e.g. Telegram, Slack, Discord) can publish concurrently.
158    pub fn inbound_sender(&self) -> tokio::sync::mpsc::Sender<InboundMessage> {
159        self.inbound_tx.clone()
160    }
161
162    /// Get a cloneable sender handle for dispatching outbound messages.
163    ///
164    /// Pipeline stages or agent tasks can clone this sender for concurrent
165    /// outbound dispatch.
166    pub fn outbound_sender(&self) -> tokio::sync::mpsc::Sender<OutboundMessage> {
167        self.outbound_tx.clone()
168    }
169}
170
171// ---------------------------------------------------------------------------
172// Browser implementation (futures-channel)
173// ---------------------------------------------------------------------------
174
175#[cfg(not(feature = "native"))]
176pub struct MessageBus {
177    inbound_tx: futures_util::lock::Mutex<futures_channel::mpsc::UnboundedSender<InboundMessage>>,
178    inbound_rx: futures_util::lock::Mutex<futures_channel::mpsc::UnboundedReceiver<InboundMessage>>,
179    outbound_tx: futures_util::lock::Mutex<futures_channel::mpsc::UnboundedSender<OutboundMessage>>,
180    outbound_rx: futures_util::lock::Mutex<futures_channel::mpsc::UnboundedReceiver<OutboundMessage>>,
181}
182
183#[cfg(not(feature = "native"))]
184impl MessageBus {
185    /// Create a new message bus (browser: unbounded channels).
186    pub fn new() -> Self {
187        let (inbound_tx, inbound_rx) = futures_channel::mpsc::unbounded();
188        let (outbound_tx, outbound_rx) = futures_channel::mpsc::unbounded();
189
190        debug!("MessageBus created with unbounded channels (browser)");
191
192        Self {
193            inbound_tx: futures_util::lock::Mutex::new(inbound_tx),
194            inbound_rx: futures_util::lock::Mutex::new(inbound_rx),
195            outbound_tx: futures_util::lock::Mutex::new(outbound_tx),
196            outbound_rx: futures_util::lock::Mutex::new(outbound_rx),
197        }
198    }
199
200    /// Create a new message bus (capacity parameter ignored on browser).
201    pub fn with_capacity(_capacity: usize) -> Self {
202        Self::new()
203    }
204
205    /// Publish an inbound message to the bus.
206    pub fn publish_inbound(&self, msg: InboundMessage) -> Result<(), ClawftError> {
207        debug!(
208            channel = %msg.channel,
209            chat_id = %msg.chat_id,
210            "publishing inbound message"
211        );
212        // For browser single-threaded context, try_lock should always succeed.
213        if let Some(tx) = self.inbound_tx.try_lock() {
214            tx.unbounded_send(msg).map_err(|_| {
215                ClawftError::Channel("inbound channel closed".into())
216            })
217        } else {
218            Err(ClawftError::Channel("inbound channel busy".into()))
219        }
220    }
221
222    /// Publish an inbound message (async version).
223    pub async fn publish_inbound_async(
224        &self,
225        msg: InboundMessage,
226    ) -> Result<(), ClawftError> {
227        debug!(
228            channel = %msg.channel,
229            chat_id = %msg.chat_id,
230            "publishing inbound message (async)"
231        );
232        let tx = self.inbound_tx.lock().await;
233        tx.unbounded_send(msg).map_err(|_| {
234            ClawftError::Channel("inbound channel closed".into())
235        })
236    }
237
238    /// Consume the next inbound message from the bus.
239    pub async fn consume_inbound(&self) -> Option<InboundMessage> {
240        use futures_util::StreamExt;
241        let mut rx = self.inbound_rx.lock().await;
242        rx.next().await
243    }
244
245    /// Dispatch an outbound message to the bus.
246    pub fn dispatch_outbound(&self, msg: OutboundMessage) -> Result<(), ClawftError> {
247        debug!(
248            channel = %msg.channel,
249            chat_id = %msg.chat_id,
250            "dispatching outbound message"
251        );
252        if let Some(tx) = self.outbound_tx.try_lock() {
253            tx.unbounded_send(msg).map_err(|_| {
254                ClawftError::Channel("outbound channel closed".into())
255            })
256        } else {
257            Err(ClawftError::Channel("outbound channel busy".into()))
258        }
259    }
260
261    /// Dispatch an outbound message (async version).
262    pub async fn dispatch_outbound_async(
263        &self,
264        msg: OutboundMessage,
265    ) -> Result<(), ClawftError> {
266        debug!(
267            channel = %msg.channel,
268            chat_id = %msg.chat_id,
269            "dispatching outbound message (async)"
270        );
271        let tx = self.outbound_tx.lock().await;
272        tx.unbounded_send(msg).map_err(|_| {
273            ClawftError::Channel("outbound channel closed".into())
274        })
275    }
276
277    /// Consume the next outbound message from the bus.
278    pub async fn consume_outbound(&self) -> Option<OutboundMessage> {
279        use futures_util::StreamExt;
280        let mut rx = self.outbound_rx.lock().await;
281        rx.next().await
282    }
283}
284
285impl Default for MessageBus {
286    fn default() -> Self {
287        Self::new()
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use chrono::Utc;
295    use std::collections::HashMap;
296
297    fn make_inbound(content: &str) -> InboundMessage {
298        InboundMessage {
299            channel: "test".into(),
300            sender_id: "user1".into(),
301            chat_id: "chat1".into(),
302            content: content.into(),
303            timestamp: Utc::now(),
304            media: vec![],
305            metadata: HashMap::new(),
306        }
307    }
308
309    fn make_outbound(content: &str) -> OutboundMessage {
310        OutboundMessage {
311            channel: "test".into(),
312            chat_id: "chat1".into(),
313            content: content.into(),
314            reply_to: None,
315            media: vec![],
316            metadata: HashMap::new(),
317        }
318    }
319
320    #[tokio::test]
321    async fn publish_and_consume_inbound() {
322        let bus = MessageBus::new();
323        let msg = make_inbound("hello");
324        bus.publish_inbound(msg).unwrap();
325
326        let received = bus.consume_inbound().await.unwrap();
327        assert_eq!(received.content, "hello");
328        assert_eq!(received.channel, "test");
329    }
330
331    #[tokio::test]
332    async fn dispatch_and_consume_outbound() {
333        let bus = MessageBus::new();
334        let msg = make_outbound("reply");
335        bus.dispatch_outbound(msg).unwrap();
336
337        let received = bus.consume_outbound().await.unwrap();
338        assert_eq!(received.content, "reply");
339    }
340
341    #[tokio::test]
342    async fn multiple_inbound_messages_in_order() {
343        let bus = MessageBus::new();
344        for i in 0..5 {
345            bus.publish_inbound(make_inbound(&format!("msg-{i}")))
346                .unwrap();
347        }
348
349        for i in 0..5 {
350            let msg = bus.consume_inbound().await.unwrap();
351            assert_eq!(msg.content, format!("msg-{i}"));
352        }
353    }
354
355    #[tokio::test]
356    async fn multiple_outbound_messages_in_order() {
357        let bus = MessageBus::new();
358        for i in 0..5 {
359            bus.dispatch_outbound(make_outbound(&format!("out-{i}")))
360                .unwrap();
361        }
362
363        for i in 0..5 {
364            let msg = bus.consume_outbound().await.unwrap();
365            assert_eq!(msg.content, format!("out-{i}"));
366        }
367    }
368
369    #[tokio::test]
370    async fn default_creates_valid_bus() {
371        let bus = MessageBus::default();
372        bus.publish_inbound(make_inbound("default-test")).unwrap();
373        bus.dispatch_outbound(make_outbound("default-test"))
374            .unwrap();
375    }
376
377    #[tokio::test]
378    async fn inbound_and_outbound_are_independent() {
379        let bus = MessageBus::new();
380
381        bus.publish_inbound(make_inbound("in")).unwrap();
382        bus.dispatch_outbound(make_outbound("out")).unwrap();
383
384        let inbound = bus.consume_inbound().await.unwrap();
385        let outbound = bus.consume_outbound().await.unwrap();
386
387        assert_eq!(inbound.content, "in");
388        assert_eq!(outbound.content, "out");
389    }
390
391    // The following tests use tokio-specific APIs (spawn, try_send on tokio
392    // Sender, etc.) and only compile under the native feature.
393
394    #[cfg(feature = "native")]
395    #[tokio::test]
396    async fn inbound_sender_allows_multi_producer() {
397        let bus = MessageBus::new();
398        let tx1 = bus.inbound_sender();
399        let tx2 = bus.inbound_sender();
400
401        tx1.try_send(make_inbound("from-tx1")).unwrap();
402        tx2.try_send(make_inbound("from-tx2")).unwrap();
403
404        let msg1 = bus.consume_inbound().await.unwrap();
405        let msg2 = bus.consume_inbound().await.unwrap();
406        assert_eq!(msg1.content, "from-tx1");
407        assert_eq!(msg2.content, "from-tx2");
408    }
409
410    #[cfg(feature = "native")]
411    #[tokio::test]
412    async fn outbound_sender_allows_multi_producer() {
413        let bus = MessageBus::new();
414        let tx1 = bus.outbound_sender();
415        let tx2 = bus.outbound_sender();
416
417        tx1.try_send(make_outbound("from-tx1")).unwrap();
418        tx2.try_send(make_outbound("from-tx2")).unwrap();
419
420        let msg1 = bus.consume_outbound().await.unwrap();
421        let msg2 = bus.consume_outbound().await.unwrap();
422        assert_eq!(msg1.content, "from-tx1");
423        assert_eq!(msg2.content, "from-tx2");
424    }
425
426    #[cfg(feature = "native")]
427    #[tokio::test]
428    async fn consume_returns_none_when_all_senders_dropped() {
429        let (tx, rx) = tokio::sync::mpsc::channel::<InboundMessage>(16);
430        let rx = tokio::sync::Mutex::new(rx);
431        tx.try_send(make_inbound("msg")).unwrap();
432        drop(tx);
433
434        let mut guard = rx.lock().await;
435        assert!(guard.recv().await.is_some());
436        assert!(guard.recv().await.is_none());
437    }
438
439    #[cfg(feature = "native")]
440    #[tokio::test]
441    async fn publish_inbound_error_on_closed_channel() {
442        let (tx, rx) = tokio::sync::mpsc::channel::<InboundMessage>(16);
443        drop(rx);
444
445        let result = tx.try_send(make_inbound("orphan"));
446        assert!(result.is_err());
447    }
448
449    #[cfg(feature = "native")]
450    #[tokio::test]
451    async fn dispatch_outbound_error_on_closed_channel() {
452        let (tx, rx) = tokio::sync::mpsc::channel::<OutboundMessage>(16);
453        drop(rx);
454
455        let result = tx.try_send(make_outbound("orphan"));
456        assert!(result.is_err());
457    }
458
459    #[cfg(feature = "native")]
460    #[tokio::test]
461    async fn concurrent_publish_and_consume() {
462        let bus = std::sync::Arc::new(MessageBus::new());
463        let bus_clone = bus.clone();
464
465        let producer = tokio::spawn(async move {
466            for i in 0..100 {
467                bus_clone
468                    .publish_inbound_async(make_inbound(&format!("concurrent-{i}")))
469                    .await
470                    .unwrap();
471            }
472        });
473
474        let consumer = tokio::spawn(async move {
475            let mut received = Vec::new();
476            for _ in 0..100 {
477                if let Some(msg) = bus.consume_inbound().await {
478                    received.push(msg.content);
479                }
480            }
481            received
482        });
483
484        producer.await.unwrap();
485        let results = consumer.await.unwrap();
486        assert_eq!(results.len(), 100);
487    }
488
489    #[cfg(feature = "native")]
490    #[test]
491    fn message_bus_is_send_sync() {
492        fn assert_send_sync<T: Send + Sync>() {}
493        assert_send_sync::<MessageBus>();
494    }
495
496    #[cfg(feature = "native")]
497    #[tokio::test]
498    async fn custom_capacity_bus() {
499        let bus = MessageBus::with_capacity(4);
500        for i in 0..4 {
501            bus.publish_inbound(make_inbound(&format!("msg-{i}")))
502                .unwrap();
503        }
504        let result = bus.publish_inbound(make_inbound("overflow"));
505        assert!(result.is_err());
506        let err_msg = result.unwrap_err().to_string();
507        assert!(
508            err_msg.contains("backpressure"),
509            "expected backpressure error, got: {err_msg}"
510        );
511    }
512
513    #[cfg(feature = "native")]
514    #[tokio::test]
515    async fn async_publish_waits_when_full() {
516        let bus = std::sync::Arc::new(MessageBus::with_capacity(2));
517        let bus_producer = bus.clone();
518        let bus_consumer = bus.clone();
519
520        bus.publish_inbound(make_inbound("a")).unwrap();
521        bus.publish_inbound(make_inbound("b")).unwrap();
522
523        let producer = tokio::spawn(async move {
524            bus_producer
525                .publish_inbound_async(make_inbound("c"))
526                .await
527                .unwrap();
528        });
529
530        tokio::task::yield_now().await;
531        let _ = bus_consumer.consume_inbound().await;
532
533        producer.await.unwrap();
534        let msg_b = bus_consumer.consume_inbound().await.unwrap();
535        let msg_c = bus_consumer.consume_inbound().await.unwrap();
536        assert_eq!(msg_b.content, "b");
537        assert_eq!(msg_c.content, "c");
538    }
539}