返回 DeepSeek-TUI-2026
mailbox.rs
根目录 / crates / tui / src / tools / subagent / mailbox.rs
1 //! Mailbox abstraction for sub-agent runtime coordination.
2 //!
3 //! Monotonic sequence numbers give every consumer a consistent ordering even
4 //! when multiple subscribers (e.g. UI card + parent agent) drain
5 //! independently; close-as-cancel lets a single signal both stop new mail and
6 //! propagate cancellation through nested children.
7
8 // Some surface here is producer-only inside this crate today and consumed by
9 // #128's UI cards in a follow-up; suppress the dead-code warnings until then
10 // rather than deleting capabilities the design depends on.
11 #![allow(dead_code)]
12
13 use std::collections::VecDeque;
14 use std::sync::Arc;
15 use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
16 use std::time::Duration;
17
18 use serde::{Deserialize, Serialize};
19 use tokio::sync::{Mutex, mpsc, watch};
20 use tokio_util::sync::CancellationToken;
21
22 use crate::models::Usage;
23
24 use super::SubAgentType;
25
26 /// Stable, structured progress envelope shared across the sub-agent surface.
27 ///
28 /// Tracks the lifecycle of a single agent (identified by `agent_id`) end to
29 /// end: spawn, per-step progress, tool execution, completion / failure /
30 /// cancellation, and parent → child topology so consumers can render trees.
31 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32 #[serde(tag = "kind", rename_all = "snake_case")]
33 pub enum MailboxMessage {
34 /// Agent has been started (background task is running).
35 Started {
36 agent_id: String,
37 agent_type: String,
38 },
39 /// Free-form human-readable progress (mirrors `Event::AgentProgress`).
40 Progress { agent_id: String, status: String },
41 /// A tool call inside the agent has started.
42 ToolCallStarted {
43 agent_id: String,
44 tool_name: String,
45 step: u32,
46 },
47 /// A tool call inside the agent has finished.
48 ToolCallCompleted {
49 agent_id: String,
50 tool_name: String,
51 step: u32,
52 ok: bool,
53 },
54 /// A child agent was spawned by this agent.
55 ChildSpawned { parent_id: String, child_id: String },
56 /// Agent completed successfully (carries the summary line shown in the
57 /// transcript; full result is still available via `agent_result`).
58 Completed { agent_id: String, summary: String },
59 /// Agent failed with the carried error message.
60 Failed { agent_id: String, error: String },
61 /// Cancellation propagated to this agent.
62 Cancelled { agent_id: String },
63 /// Incremental token usage from a sub-agent's API call.
64 /// Published after each turn so the parent's cost counter updates live.
65 TokenUsage {
66 agent_id: String,
67 /// Model that produced this usage, used for pricing.
68 model: String,
69 /// Provider usage payload, including cache-hit/cache-miss fields.
70 usage: Usage,
71 },
72 }
73
74 impl MailboxMessage {
75 /// `agent_id` of the message subject (for `ChildSpawned` this is the
76 /// child, since that's the new lifecycle being announced).
77 #[must_use]
78 pub fn agent_id(&self) -> &str {
79 match self {
80 Self::Started { agent_id, .. }
81 | Self::Progress { agent_id, .. }
82 | Self::ToolCallStarted { agent_id, .. }
83 | Self::ToolCallCompleted { agent_id, .. }
84 | Self::Completed { agent_id, .. }
85 | Self::Failed { agent_id, .. }
86 | Self::Cancelled { agent_id }
87 | Self::TokenUsage { agent_id, .. } => agent_id,
88 Self::ChildSpawned { child_id, .. } => child_id,
89 }
90 }
91
92 pub(crate) fn started(agent_id: impl Into<String>, agent_type: SubAgentType) -> Self {
93 Self::Started {
94 agent_id: agent_id.into(),
95 agent_type: agent_type.as_str().to_string(),
96 }
97 }
98
99 pub(crate) fn progress(agent_id: impl Into<String>, status: impl Into<String>) -> Self {
100 Self::Progress {
101 agent_id: agent_id.into(),
102 status: status.into(),
103 }
104 }
105
106 pub(crate) fn token_usage(
107 agent_id: impl Into<String>,
108 model: impl Into<String>,
109 usage: Usage,
110 ) -> Self {
111 Self::TokenUsage {
112 agent_id: agent_id.into(),
113 model: model.into(),
114 usage,
115 }
116 }
117 }
118
119 /// One delivery: a sequence number plus the message. The sequence is
120 /// monotonic across the entire mailbox (not per-agent) so a single ordering
121 /// is well-defined even when multiple sub-agents share one mailbox.
122 #[derive(Debug, Clone, PartialEq, Eq)]
123 pub struct MailboxEnvelope {
124 pub seq: u64,
125 pub message: MailboxMessage,
126 }
127
128 /// Sender side of the mailbox.
129 ///
130 /// Cheaply cloneable (everything inside is `Arc`/atomic). Cloning a
131 /// `Mailbox` shares the same delivery channel, sequence counter, watch
132 /// notifier, and close/cancel state — so a child runtime that clones its
133 /// parent's `Mailbox` participates in the same stream.
134 #[derive(Clone)]
135 pub struct Mailbox {
136 inner: Arc<MailboxInner>,
137 }
138
139 struct MailboxInner {
140 tx: mpsc::UnboundedSender<MailboxEnvelope>,
141 next_seq: AtomicU64,
142 seq_tx: watch::Sender<u64>,
143 closed: AtomicBool,
144 cancel_token: CancellationToken,
145 }
146
147 /// Receiver side of the mailbox. Not `Clone` — only the original creator
148 /// can drain. Use `Mailbox::subscribe()` for fanout (UI cards + parent both
149 /// observing the same stream).
150 pub struct MailboxReceiver {
151 rx: mpsc::UnboundedReceiver<MailboxEnvelope>,
152 pending: VecDeque<MailboxEnvelope>,
153 }
154
155 impl Mailbox {
156 /// Create a new mailbox bound to the given cancellation token. Closing
157 /// the mailbox (or dropping the last sender) cancels this token, which
158 /// propagates to children via `child_token()` per `SubAgentRuntime`.
159 #[must_use]
160 pub fn new(cancel_token: CancellationToken) -> (Self, MailboxReceiver) {
161 let (tx, rx) = mpsc::unbounded_channel();
162 let (seq_tx, _) = watch::channel(0);
163 let inner = MailboxInner {
164 tx,
165 next_seq: AtomicU64::new(0),
166 seq_tx,
167 closed: AtomicBool::new(false),
168 cancel_token,
169 };
170 (
171 Self {
172 inner: Arc::new(inner),
173 },
174 MailboxReceiver {
175 rx,
176 pending: VecDeque::new(),
177 },
178 )
179 }
180
181 /// Subscribe to seq-bump notifications. Each `recv()` returns when the
182 /// sequence counter advances, signaling new mail without copying it —
183 /// the consumer then calls `drain` (or `recv_one` on its own receiver).
184 /// Multiple subscribers may exist; this is the fanout primitive.
185 #[must_use]
186 pub fn subscribe(&self) -> watch::Receiver<u64> {
187 self.inner.seq_tx.subscribe()
188 }
189
190 /// Send a message; returns `Some(seq)` on success, `None` if the
191 /// mailbox is already closed (callers should treat this as "the
192 /// receiver is gone, stop publishing").
193 pub fn send(&self, message: MailboxMessage) -> Option<u64> {
194 if self.inner.closed.load(Ordering::Acquire) {
195 return None;
196 }
197 let seq = self.inner.next_seq.fetch_add(1, Ordering::Relaxed) + 1;
198 let envelope = MailboxEnvelope { seq, message };
199 if self.inner.tx.send(envelope).is_err() {
200 return None;
201 }
202 let _ = self.inner.seq_tx.send_replace(seq);
203 Some(seq)
204 }
205
206 /// Whether the mailbox has been closed.
207 #[must_use]
208 pub fn is_closed(&self) -> bool {
209 self.inner.closed.load(Ordering::Acquire)
210 }
211
212 /// Close the mailbox AND cancel the bound cancellation token.
213 ///
214 /// "Close-as-cancel": there's no useful state where the consumer is
215 /// gone but children should keep producing. Closing the parent's
216 /// mailbox cascades to every nested child because each child runtime
217 /// derived its `cancel_token` via `child_token()` from the parent's.
218 pub fn close(&self) {
219 if !self.inner.closed.swap(true, Ordering::AcqRel) {
220 self.inner.cancel_token.cancel();
221 }
222 }
223 }
224
225 impl MailboxReceiver {
226 fn sync_pending(&mut self) {
227 while let Ok(env) = self.rx.try_recv() {
228 self.pending.push_back(env);
229 }
230 }
231
232 /// Whether any envelopes are buffered (or arrived since last check).
233 pub fn has_pending(&mut self) -> bool {
234 self.sync_pending();
235 !self.pending.is_empty()
236 }
237
238 /// Drain all currently available envelopes, in delivery order.
239 pub fn drain(&mut self) -> Vec<MailboxEnvelope> {
240 self.sync_pending();
241 self.pending.drain(..).collect()
242 }
243
244 /// Await the next envelope, with backpressure-aware blocking. Returns
245 /// `None` when every sender has been dropped and the buffer is drained.
246 pub async fn recv(&mut self) -> Option<MailboxEnvelope> {
247 if let Some(env) = self.pending.pop_front() {
248 return Some(env);
249 }
250 self.rx.recv().await
251 }
252
253 /// Awaits the next envelope with a timeout. Useful in tests.
254 #[allow(dead_code)]
255 pub async fn recv_timeout(&mut self, timeout: Duration) -> Option<MailboxEnvelope> {
256 tokio::time::timeout(timeout, self.recv())
257 .await
258 .ok()
259 .flatten()
260 }
261 }
262
263 /// Convenience handle: a mailbox + the matching cancellation token, ready to
264 /// hand to a runtime. The receiver lives on the spawning side.
265 pub type SharedMailbox = Arc<Mutex<Option<MailboxReceiver>>>;
266
267 #[cfg(test)]
268 mod tests {
269 use super::*;
270 use tokio::time::Duration;
271
272 fn open() -> (Mailbox, MailboxReceiver, CancellationToken) {
273 let token = CancellationToken::new();
274 let (mb, rx) = Mailbox::new(token.clone());
275 (mb, rx, token)
276 }
277
278 #[tokio::test]
279 async fn mailbox_assigns_monotonic_sequence_numbers() {
280 let (mb, _rx, _tok) = open();
281 let s1 = mb
282 .send(MailboxMessage::progress("a", "one"))
283 .expect("seq 1");
284 let s2 = mb
285 .send(MailboxMessage::progress("a", "two"))
286 .expect("seq 2");
287 let s3 = mb
288 .send(MailboxMessage::progress("b", "three"))
289 .expect("seq 3");
290 assert_eq!(s1, 1);
291 assert_eq!(s2, 2);
292 assert_eq!(s3, 3);
293 assert!(s2 > s1 && s3 > s2);
294 }
295
296 #[tokio::test]
297 async fn mailbox_drains_in_delivery_order() {
298 let (mb, mut rx, _tok) = open();
299 mb.send(MailboxMessage::progress("a", "first"));
300 mb.send(MailboxMessage::progress("a", "second"));
301 mb.send(MailboxMessage::Completed {
302 agent_id: "a".into(),
303 summary: "done".into(),
304 });
305 let drained = rx.drain();
306 assert_eq!(drained.len(), 3);
307 assert_eq!(drained[0].seq, 1);
308 assert_eq!(drained[1].seq, 2);
309 assert_eq!(drained[2].seq, 3);
310 assert!(matches!(
311 drained[0].message,
312 MailboxMessage::Progress { .. }
313 ));
314 assert!(matches!(
315 drained[2].message,
316 MailboxMessage::Completed { .. }
317 ));
318 assert!(!rx.has_pending());
319 }
320
321 #[tokio::test]
322 async fn subscribers_receive_seq_bumps_for_backpressure() {
323 let (mb, _rx, _tok) = open();
324 let mut sub_a = mb.subscribe();
325 let mut sub_b = mb.subscribe();
326 // Initial state: both at 0.
327 assert_eq!(*sub_a.borrow(), 0);
328 assert_eq!(*sub_b.borrow(), 0);
329
330 mb.send(MailboxMessage::progress("x", "tick"));
331 sub_a.changed().await.expect("subscriber a sees bump");
332 sub_b.changed().await.expect("subscriber b sees bump");
333 assert_eq!(*sub_a.borrow(), 1);
334 assert_eq!(*sub_b.borrow(), 1);
335
336 // A second send updates both subscribers' watch values too — even
337 // though they share a single watch channel, fanout is N-to-many.
338 mb.send(MailboxMessage::progress("x", "tick2"));
339 sub_a.changed().await.expect("a sees second bump");
340 assert_eq!(*sub_a.borrow(), 2);
341 }
342
343 #[tokio::test]
344 async fn close_cancels_bound_token_and_blocks_further_sends() {
345 let (mb, _rx, token) = open();
346 assert!(!token.is_cancelled());
347 mb.send(MailboxMessage::progress("a", "before close"));
348 mb.close();
349 assert!(token.is_cancelled(), "close-as-cancel: token must fire");
350 assert!(mb.is_closed());
351 // Further sends are no-ops, returning None instead of poisoning seq.
352 assert!(
353 mb.send(MailboxMessage::progress("a", "after close"))
354 .is_none()
355 );
356 }
357
358 #[tokio::test]
359 async fn close_propagates_to_child_tokens_across_max_spawn_depth() {
360 // Mirror the runtime: root → child → grandchild (default depth 3).
361 let root = CancellationToken::new();
362 let child = root.child_token();
363 let grandchild = child.child_token();
364 let (mb, _rx) = Mailbox::new(root.clone());
365
366 assert!(!child.is_cancelled());
367 assert!(!grandchild.is_cancelled());
368 mb.close();
369 assert!(child.is_cancelled(), "child inherits root close");
370 assert!(
371 grandchild.is_cancelled(),
372 "grandchild inherits too — covers default max_spawn_depth = 3"
373 );
374 }
375
376 #[tokio::test]
377 async fn recv_returns_envelope_then_none_after_close_and_drop() {
378 let (mb, mut rx, _tok) = open();
379 mb.send(MailboxMessage::progress("a", "queued"));
380 let env = rx.recv().await.expect("buffered envelope");
381 assert_eq!(env.seq, 1);
382
383 // After closing AND dropping the sender, recv must yield None.
384 mb.close();
385 drop(mb);
386 let next = rx.recv_timeout(Duration::from_millis(100)).await;
387 assert!(next.is_none(), "drained + dropped → recv yields None");
388 }
389
390 #[tokio::test]
391 async fn cloned_mailbox_shares_sequence_and_close_state() {
392 let (mb, mut rx, token) = open();
393 let mb_clone = mb.clone();
394 let s1 = mb
395 .send(MailboxMessage::progress("a", "from original"))
396 .unwrap();
397 let s2 = mb_clone
398 .send(MailboxMessage::progress("a", "from clone"))
399 .unwrap();
400 assert_eq!(s1, 1);
401 assert_eq!(s2, 2, "clones share the seq counter");
402
403 let drained = rx.drain();
404 assert_eq!(drained.len(), 2);
405
406 // Closing through one clone closes them all (the AtomicBool is shared).
407 mb_clone.close();
408 assert!(mb.is_closed());
409 assert!(token.is_cancelled());
410 }
411
412 #[tokio::test]
413 async fn agent_id_is_extractable_from_every_variant() {
414 let cases: Vec<(MailboxMessage, &str)> = vec![
415 (MailboxMessage::started("a1", SubAgentType::General), "a1"),
416 (MailboxMessage::progress("a2", "x"), "a2"),
417 (
418 MailboxMessage::ToolCallStarted {
419 agent_id: "a3".into(),
420 tool_name: "read_file".into(),
421 step: 1,
422 },
423 "a3",
424 ),
425 (
426 MailboxMessage::ToolCallCompleted {
427 agent_id: "a4".into(),
428 tool_name: "read_file".into(),
429 step: 1,
430 ok: true,
431 },
432 "a4",
433 ),
434 (
435 MailboxMessage::ChildSpawned {
436 parent_id: "parent".into(),
437 child_id: "a5".into(),
438 },
439 "a5",
440 ),
441 (
442 MailboxMessage::Completed {
443 agent_id: "a6".into(),
444 summary: "done".into(),
445 },
446 "a6",
447 ),
448 (
449 MailboxMessage::Failed {
450 agent_id: "a7".into(),
451 error: "boom".into(),
452 },
453 "a7",
454 ),
455 (
456 MailboxMessage::Cancelled {
457 agent_id: "a8".into(),
458 },
459 "a8",
460 ),
461 (
462 MailboxMessage::TokenUsage {
463 agent_id: "a9".into(),
464 model: "deepseek-v4-flash".into(),
465 usage: Usage {
466 input_tokens: 100,
467 output_tokens: 50,
468 ..Default::default()
469 },
470 },
471 "a9",
472 ),
473 ];
474 for (msg, expected) in cases {
475 assert_eq!(msg.agent_id(), expected, "extract failed for {msg:?}");
476 }
477 }
478 }
479
479 lines RUST