molo/message_channel/mod.rs
1//! MessageChannel: a message transport channel between an Agent and the outside world (a human or another Agent).
2//!
3//! The channel supports only two operations, defined by the [`MessageChannel`] trait:
4//!
5//! - [`MessageChannel::ask`] — send a question and wait for a reply (request-response), for scenarios that need the
6//! counterparty's confirmation (e.g. asking the user for consent before a tool runs);
7//! - [`MessageChannel::notify`] — a one-way notification, sent without waiting for a reply.
8//!
9//! Interrupt semantics: an interrupt happens when a channel consumer (such as a tool) calls `ask`, and the call
10//! returning is the resume. The channel itself is unaware of interrupts and takes no part in the resume flow.
11//!
12//! # Choosing a channel: which one when
13//!
14//! Pick a channel along two dimensions: "does it need a reply" and "how many receivers are there".
15//!
16//! | Need | Choice |
17//! |------|------|
18//! | Interacting with a human: terminal questions, confirmation, approval | [`CliMessageChannel`] |
19//! | One-on-one dialogue between two Agents, with request-response in both directions | [`MpscChannel`] |
20//! | One-to-many broadcast notifications, no reply expected | [`BroadcastChannel`] |
21//! | Observing changes of the latest state (status, heartbeat, progress) | [`WatchChannel`] |
22//!
23//! Selection notes:
24//!
25//! - **Need request-response** → [`CliMessageChannel`] (human)
26//! or [`MpscChannel`] (Agent); the other two don't support `ask`
27//! and return [`ChannelError::NotSupported`].
28//! - **One-way notifications only** → [`BroadcastChannel`] or
29//! [`WatchChannel`]; neither waits for the counterparty's confirmation.
30//! The difference is semantic: broadcast is a message queue (bounded, drops old
31//! messages when consumers are slow); watch holds the latest value (unbounded, keeps only the newest).
32//! - **Concurrent two-way questioning** → replies in `MpscChannel` are bound to their requests one-to-one, so both
33//! sides can ask each other concurrently without replies going astray; `CliMessageChannel` suits slow-paced
34//! "human confirms one at a time" interactions — while one request is unanswered, later requests queue up.
35//! - **Concurrent access** → all implementations are `Send + Sync`, so they can be placed in an [`Arc`](std::sync::Arc)
36//! and shared across tasks; `ask` presents only one request at a time.
37//!
38//! Event observation (UI / environment subscription) does not go through this module, but through the
39//! publish-subscribe of [`EventChannel`](crate::event_channel::EventChannel): conversation channels handle
40//! "request-response / notifications", while event channels handle "observing the reasoning process".
41
42/// Sends a question to the outside world and waits for a reply, or sends a one-way notification.
43///
44/// The receiver is not limited to humans: the same trait supports both
45/// Agent-to-Agent dialogue (one-to-one `ask`) and broadcast notifications
46/// (`notify`). **Serializing concurrent calls is the implementation's job**:
47/// `ask` presents only one request at a time, and the next one gets its turn
48/// only after the previous reply — e.g. a human can only approve one by one.
49///
50/// # Example
51///
52/// Implementing a custom channel: just answer "how to send a question" and "how to send a notification".
53///
54/// ```rust
55/// use molo::{ChannelError, MessageChannel};
56///
57/// // A custom channel that echoes the question back as the reply and drops notifications.
58/// struct EchoChannel;
59///
60/// #[async_trait::async_trait]
61/// impl MessageChannel for EchoChannel {
62/// async fn ask(&self, message: &str) -> Result<String, ChannelError> {
63/// Ok(format!("echo: {message}"))
64/// }
65///
66/// async fn notify(&self, message: &str) -> Result<(), ChannelError> {
67/// Ok(())
68/// }
69/// }
70///
71/// # #[tokio::main]
72/// # async fn main() -> Result<(), molo::ChannelError> {
73/// let channel = EchoChannel;
74/// assert_eq!(channel.ask("hello").await?, "echo: hello");
75/// # Ok(())
76/// # }
77/// ```
78#[async_trait::async_trait]
79pub trait MessageChannel: Send + Sync {
80 /// Sends a question and waits for a reply (request-response).
81 ///
82 /// Subsequent `ask` calls queue up until the previous reply is returned (serialization is the implementation's
83 /// responsibility).
84 async fn ask(&self, message: &str) -> Result<String, ChannelError>;
85
86 /// A one-way notification that does not wait for a reply (broadcast to all receivers).
87 async fn notify(&self, message: &str) -> Result<(), ChannelError>;
88}
89
90/// The reason a message channel failed.
91///
92/// When each variant is triggered:
93///
94/// - [`ChannelError::Io`] — underlying read/write failure (e.g. a terminal IO error);
95/// - [`ChannelError::Closed`] — the channel is closed: the peer has been dropped, or input ended
96/// (e.g. Ctrl-D in a terminal); after it closes, every subsequent call on the channel returns the same error;
97/// - [`ChannelError::NoReply`] — [`IncomingMessage::reply`] was called on a notification message
98/// that does not expect a reply;
99/// - [`ChannelError::NotSupported`] — the current implementation does not support this operation (broadcast / watch
100/// don't support `ask`).
101///
102/// The enum is `#[non_exhaustive]` (reserved for extension): matches must include a wildcard arm.
103#[derive(Debug, Clone, PartialEq, thiserror::Error)]
104#[non_exhaustive]
105pub enum ChannelError {
106 /// Underlying IO failure (reading or writing the channel failed).
107 #[error("channel io error: {0}")]
108 Io(String),
109 /// Input has been closed (e.g. user pressed Ctrl-D); no reply is available.
110 #[error("channel closed")]
111 Closed,
112 /// There are currently no receivers on the channel (e.g. `notify` on a watch channel with no subscribers).
113 /// The channel itself is not closed: subscribing again restores operation.
114 #[error("channel has no receivers")]
115 NoReceiver,
116 /// `reply` was called on a message (a notification) that has no reply channel.
117 #[error("message does not expect a reply")]
118 NoReply,
119 /// This channel implementation does not support the operation (e.g. broadcast / watch don't support `ask`).
120 #[error("operation not supported by this channel: {0}")]
121 NotSupported(String),
122}
123
124impl From<std::io::Error> for ChannelError {
125 fn from(err: std::io::Error) -> Self {
126 // Carry only the error text; the prefix is added uniformly by the Io variant's Display, so implementers don't
127 // prepend the prefix again and produce a doubled "channel io error: channel io error: ..." message.
128 ChannelError::Io(err.to_string())
129 }
130}
131
132/// A message in the queue: questions carry a reply channel, notifications don't.
133#[derive(Debug)]
134struct Envelope {
135 message: String,
136 reply: Option<tokio::sync::oneshot::Sender<String>>,
137}
138
139/// A message received from the channel: the text content, plus a reply slot that only questions have.
140///
141/// Returned by each channel's receive method. Check [`IncomingMessage::wants_reply`] first —
142/// for question messages, send the reply back with [`IncomingMessage::reply`]; notification messages have no reply slot.
143///
144/// See [`MpscChannel`] for a complete usage example.
145#[derive(Debug)]
146pub struct IncomingMessage {
147 text: String,
148 reply_tx: Option<tokio::sync::oneshot::Sender<String>>,
149}
150
151impl IncomingMessage {
152 /// The message text.
153 pub fn text(&self) -> &str {
154 &self.text
155 }
156
157 /// Whether this is a question (expects a reply); notifications return `false`.
158 ///
159 /// Only messages that return `true` may call [`IncomingMessage::reply`].
160 pub fn wants_reply(&self) -> bool {
161 self.reply_tx.is_some()
162 }
163
164 /// Sends back a reply; the message is consumed in the process, so it can only be replied to once.
165 ///
166 /// # Errors
167 ///
168 /// - [`ChannelError::NoReply`] — this is a notification message and does not expect a reply;
169 /// - [`ChannelError::Closed`] — the asker has left (e.g. `ask` was cancelled or timed out),
170 /// so nobody will receive the reply.
171 pub fn reply(self, answer: String) -> Result<(), ChannelError> {
172 match self.reply_tx {
173 Some(tx) => tx.send(answer).map_err(|_| ChannelError::Closed),
174 None => Err(ChannelError::NoReply),
175 }
176 }
177}
178
179mod broadcast;
180mod cli;
181mod mpsc;
182mod watch;
183
184pub use broadcast::{BroadcastChannel, BroadcastReceiver};
185pub use cli::CliMessageChannel;
186pub use mpsc::MpscChannel;
187pub use watch::{WatchChannel, WatchReceiver};