Skip to main content

recursive/tools/
send_message.rs

1//! `send_message` tool — coordinator ↔ worker (or task) messaging.
2//!
3//! # Resolution order
4//!
5//! Callers can address the target in two ways:
6//! 1. **`task_id`** — preferred, used when the worker was spawned via the
7//!    Phase D `agent` tool with `team_name+name` (or `run_in_background`).
8//!    The message is pushed into the task's output channel, which the
9//!    worker's prompt loop can poll between steps (or, more practically,
10//!    which the human can read on the next `task_output` call).
11//! 2. **`worker_id`** — legacy, addresses a worker registered in
12//!    `WorkerRegistry` (from pre-Phase-D `spawn_worker`).
13//!
14//! If both are present, `task_id` wins.  If neither resolves, the tool
15//! returns a helpful message listing active workers and tasks.
16
17use async_trait::async_trait;
18use serde_json::{json, Value};
19use std::collections::VecDeque;
20use std::sync::Arc;
21use tokio::sync::RwLock;
22
23use crate::error::{Error, Result};
24use crate::llm::ToolSpec;
25use crate::tasks::TaskRegistry;
26use crate::tools::{Tool, ToolSideEffect};
27
28// ---------------------------------------------------------------------------
29// WorkerMailbox
30// ---------------------------------------------------------------------------
31
32/// A FIFO queue of pending messages for a single worker agent.
33///
34/// The coordinator pushes messages here; the worker's kernel drains them
35/// between steps.
36#[derive(Clone, Default)]
37pub struct WorkerMailbox {
38    queue: Arc<tokio::sync::Mutex<VecDeque<String>>>,
39}
40
41impl WorkerMailbox {
42    pub fn new() -> Self {
43        Self::default()
44    }
45
46    /// Push a message into the mailbox.
47    pub async fn push(&self, msg: String) {
48        self.queue.lock().await.push_back(msg);
49    }
50
51    /// Pop the oldest pending message, or `None` if empty.
52    pub async fn pop(&self) -> Option<String> {
53        self.queue.lock().await.pop_front()
54    }
55
56    /// Drain all pending messages into a Vec.
57    pub async fn drain_all(&self) -> Vec<String> {
58        let mut q = self.queue.lock().await;
59        q.drain(..).collect()
60    }
61
62    /// Return the number of pending messages without consuming them.
63    pub async fn len(&self) -> usize {
64        self.queue.lock().await.len()
65    }
66
67    pub async fn is_empty(&self) -> bool {
68        self.queue.lock().await.is_empty()
69    }
70}
71
72// ---------------------------------------------------------------------------
73// WorkerRegistry
74// ---------------------------------------------------------------------------
75
76/// Global registry mapping `worker_id` → `WorkerMailbox`.
77#[derive(Clone, Default)]
78pub struct WorkerRegistry {
79    inner: Arc<RwLock<std::collections::HashMap<String, WorkerMailbox>>>,
80}
81
82impl WorkerRegistry {
83    pub fn new() -> Self {
84        Self::default()
85    }
86
87    /// Register a new worker and return its mailbox.
88    pub async fn register(&self, worker_id: &str) -> WorkerMailbox {
89        let mailbox = WorkerMailbox::new();
90        self.inner
91            .write()
92            .await
93            .insert(worker_id.to_string(), mailbox.clone());
94        mailbox
95    }
96
97    /// Deregister a worker (called when it finishes).
98    pub async fn deregister(&self, worker_id: &str) {
99        self.inner.write().await.remove(worker_id);
100    }
101
102    /// Get the mailbox for a worker, or `None` if not registered.
103    pub async fn get(&self, worker_id: &str) -> Option<WorkerMailbox> {
104        self.inner.read().await.get(worker_id).cloned()
105    }
106
107    /// List active worker IDs.
108    pub async fn active_workers(&self) -> Vec<String> {
109        self.inner.read().await.keys().cloned().collect()
110    }
111}
112
113// ---------------------------------------------------------------------------
114// SendMessageTool
115// ---------------------------------------------------------------------------
116
117/// The `send_message` tool — push a follow-up message to a running
118/// worker (task or legacy worker).
119pub struct SendMessageTool {
120    registry: WorkerRegistry,
121    task_registry: Arc<TaskRegistry>,
122    /// Background worker continuation table. When a message targets a
123    /// task_id whose worker is registered here, the message is pushed onto
124    /// the worker's continuation channel, driving a new turn on its
125    /// long-lived runtime (cross-turn continuation).
126    workers: crate::tools::agent::WorkerTable,
127}
128
129impl SendMessageTool {
130    pub fn new(
131        registry: WorkerRegistry,
132        task_registry: Arc<TaskRegistry>,
133        workers: crate::tools::agent::WorkerTable,
134    ) -> Self {
135        Self {
136            registry,
137            task_registry,
138            workers,
139        }
140    }
141}
142
143#[async_trait]
144impl Tool for SendMessageTool {
145    fn is_deferred(&self) -> bool {
146        true
147    }
148
149    fn spec(&self) -> ToolSpec {
150        ToolSpec {
151            name: "send_message".into(),
152            description: concat!(
153                "Send a follow-up message to a running worker. Two addressing ",
154                "modes: 'task_id' (preferred, returned by task_create / agent ",
155                "tool) or 'worker_id' (legacy, from spawn_worker).  Messages ",
156                "are queued and delivered to the worker on its next step."
157            )
158            .into(),
159            parameters: json!({
160                "type": "object",
161                "properties": {
162                    "task_id": {
163                        "type": "string",
164                        "description": "The task ID (preferred, returned by task_create or the agent tool when run_in_background=true or team_name+name are set)."
165                    },
166                    "worker_id": {
167                        "type": "string",
168                        "description": "Legacy: the worker ID returned by spawn_worker."
169                    },
170                    "message": {
171                        "type": "string",
172                        "description": "The message to send to the worker."
173                    }
174                },
175                "required": ["message"]
176            }),
177        }
178    }
179
180    fn side_effect_class(&self) -> ToolSideEffect {
181        // Pushing a message into a worker mailbox mutates shared external state.
182        ToolSideEffect::External
183    }
184
185    async fn execute(&self, arguments: Value) -> Result<String> {
186        let message = arguments["message"]
187            .as_str()
188            .ok_or_else(|| Error::BadToolArgs {
189                name: "send_message".into(),
190                message: "missing required parameter: message".to_string(),
191            })?
192            .to_string();
193
194        // Prefer task_id (Phase D).
195        if let Some(task_id_str) = arguments.get("task_id").and_then(|v| v.as_str()) {
196            let task_id = crate::tasks::TaskId(task_id_str.to_string());
197            let task = self
198                .task_registry
199                .get(&task_id)
200                .await
201                .ok_or_else(|| Error::NotFound(format!("task '{task_id_str}'")))?;
202            // Use the public output_tx so the worker (or a human reading
203            // task_output) can see the message.
204            let _ = task.output_tx.send(message.clone());
205
206            // If this task is a background worker, drive a new turn by
207            // pushing the message onto the worker's continuation channel.
208            // The worker runtime is retained across turns, so this continues
209            // the same conversation (transcript preserved).
210            let workers = self.workers.lock().unwrap_or_else(|e| e.into_inner());
211            for handle in workers.values() {
212                if handle.task_id == task_id {
213                    match handle.tx.send(message.clone()) {
214                        Ok(()) => {
215                            return Ok(format!(
216                                "Message delivered to task '{task_id_str}'; worker will run it as a new turn."
217                            ));
218                        }
219                        Err(_) => {
220                            return Ok(format!(
221                                "Message queued for task '{task_id_str}', but the worker has exited and cannot continue."
222                            ));
223                        }
224                    }
225                }
226            }
227            return Ok(format!("Message delivered to task '{task_id_str}'."));
228        }
229
230        // Fall back to worker_id. For a background worker registered in the
231        // worker table, drive a new turn on its runtime (cross-turn
232        // continuation). Otherwise push to the legacy WorkerMailbox.
233        if let Some(worker_id) = arguments.get("worker_id").and_then(|v| v.as_str()) {
234            // Background worker continuation: push to the mpsc channel so the
235            // long-lived worker runs this as a new turn on the same runtime.
236            // The std MutexGuard is !Send, so it is scoped to drop before the
237            // awaits below (goal-379 switched the table to a sync Mutex so a
238            // Drop guard can deregister on the abort path).
239            {
240                let workers = self.workers.lock().unwrap_or_else(|e| e.into_inner());
241                if let Some(handle) = workers.get(worker_id) {
242                    match handle.tx.send(message.clone()) {
243                        Ok(()) => {
244                            return Ok(format!(
245                                "Message delivered to worker '{worker_id}'; worker will run it as a new turn."
246                            ));
247                        }
248                        Err(_) => {
249                            return Ok(format!(
250                                "Message queued for worker '{worker_id}', but it has exited and cannot continue."
251                            ));
252                        }
253                    }
254                }
255            }
256
257            // Legacy in-memory mailbox path.
258            match self.registry.get(worker_id).await {
259                Some(mailbox) => {
260                    mailbox.push(message).await;
261                    return Ok(format!("Message delivered to worker '{worker_id}'."));
262                }
263                None => {
264                    let active = self.registry.active_workers().await;
265                    let tasks = self.task_registry.list().await;
266                    if active.is_empty() && tasks.is_empty() {
267                        return Ok(format!(
268                            "Worker '{worker_id}' not found. No active workers or tasks currently registered."
269                        ));
270                    }
271                    let mut sorted = active;
272                    sorted.sort_unstable();
273                    let task_ids: Vec<String> =
274                        tasks.into_iter().map(|t| t.id.to_string()).collect();
275                    return Ok(format!(
276                        "Worker '{worker_id}' not found. Active workers: [{}]. Active task IDs: [{}].",
277                        sorted.join(", "),
278                        task_ids.join(", ")
279                    ));
280                }
281            }
282        }
283
284        // Neither present.
285        Err(Error::BadToolArgs {
286            name: "send_message".into(),
287            message: "must provide one of: task_id, worker_id".to_string(),
288        })
289    }
290}
291
292// ---------------------------------------------------------------------------
293// ListWorkers
294// ---------------------------------------------------------------------------
295
296/// List all currently active workers and tasks registered.
297pub struct ListWorkersTool {
298    registry: WorkerRegistry,
299    task_registry: Arc<TaskRegistry>,
300}
301
302impl ListWorkersTool {
303    pub fn new(registry: WorkerRegistry, task_registry: Arc<TaskRegistry>) -> Self {
304        Self {
305            registry,
306            task_registry,
307        }
308    }
309}
310
311#[async_trait]
312impl Tool for ListWorkersTool {
313    fn spec(&self) -> ToolSpec {
314        ToolSpec {
315            name: "list_workers".into(),
316            description: concat!(
317                "List all currently active worker IDs and task IDs. Use this to ",
318                "discover peer workers and tasks you can communicate with via ",
319                "send_message."
320            )
321            .into(),
322            parameters: json!({
323                "type": "object",
324                "properties": {}
325            }),
326        }
327    }
328
329    fn side_effect_class(&self) -> ToolSideEffect {
330        ToolSideEffect::ReadOnly
331    }
332
333    async fn execute(&self, _arguments: Value) -> Result<String> {
334        let mut workers = self.registry.active_workers().await;
335        workers.sort_unstable();
336        let tasks = self.task_registry.list().await;
337        let mut out = String::new();
338        if workers.is_empty() {
339            out.push_str("Active workers: (none)\n");
340        } else {
341            out.push_str(&format!("Active workers ({}):\n", workers.len()));
342            for w in &workers {
343                out.push_str(&format!("  {w}\n"));
344            }
345        }
346        if tasks.is_empty() {
347            out.push_str("Active tasks: (none)\n");
348        } else {
349            out.push_str(&format!("Active tasks ({}):\n", tasks.len()));
350            for t in tasks {
351                let s = t.status().await;
352                out.push_str(&format!("  {} [{}] {}\n", t.id, s.as_str(), t.description));
353            }
354        }
355        Ok(out.trim_end().to_string())
356    }
357}
358
359// ---------------------------------------------------------------------------
360// Tests
361// ---------------------------------------------------------------------------
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use crate::tasks::TaskState;
367
368    #[test]
369    fn send_message_tool_is_not_readonly() {
370        use crate::tools::Tool;
371        let reg = WorkerRegistry::new();
372        let tr = Arc::new(TaskRegistry::new());
373        let tool = SendMessageTool::new(reg, tr, crate::tools::agent::WorkerTable::default());
374        assert!(
375            !tool.is_readonly(),
376            "SendMessageTool must not be ReadOnly: it pushes messages to worker mailboxes"
377        );
378        assert_eq!(
379            tool.side_effect_class(),
380            super::ToolSideEffect::External,
381            "SendMessageTool side_effect_class must be External"
382        );
383    }
384
385    #[tokio::test]
386    async fn mailbox_push_pop() {
387        let mb = WorkerMailbox::new();
388        mb.push("hello".into()).await;
389        mb.push("world".into()).await;
390        assert_eq!(mb.len().await, 2);
391        assert_eq!(mb.pop().await, Some("hello".into()));
392        assert_eq!(mb.pop().await, Some("world".into()));
393        assert_eq!(mb.pop().await, None);
394    }
395
396    #[tokio::test]
397    async fn mailbox_drain_all() {
398        let mb = WorkerMailbox::new();
399        mb.push("a".into()).await;
400        mb.push("b".into()).await;
401        let msgs = mb.drain_all().await;
402        assert_eq!(msgs, vec!["a", "b"]);
403        assert!(mb.is_empty().await);
404    }
405
406    #[tokio::test]
407    async fn registry_register_and_get() {
408        let reg = WorkerRegistry::new();
409        reg.register("w1").await;
410        assert!(reg.get("w1").await.is_some());
411        assert!(reg.get("nonexistent").await.is_none());
412    }
413
414    #[tokio::test]
415    async fn registry_deregister() {
416        let reg = WorkerRegistry::new();
417        reg.register("w1").await;
418        reg.deregister("w1").await;
419        assert!(reg.get("w1").await.is_none());
420    }
421
422    #[tokio::test]
423    async fn send_message_delivers_to_worker() {
424        let reg = WorkerRegistry::new();
425        let tr = Arc::new(TaskRegistry::new());
426        let mailbox = reg.register("w1").await;
427
428        let tool = SendMessageTool::new(reg, tr, crate::tools::agent::WorkerTable::default());
429        let result = tool
430            .execute(json!({
431                "worker_id": "w1",
432                "message": "please add error handling"
433            }))
434            .await
435            .unwrap();
436
437        assert!(result.contains("w1"));
438        assert!(result.contains("delivered"));
439        assert_eq!(
440            mailbox.pop().await.as_deref(),
441            Some("please add error handling")
442        );
443    }
444
445    #[tokio::test]
446    async fn send_message_to_task() {
447        let reg = WorkerRegistry::new();
448        let tr = Arc::new(TaskRegistry::new());
449        let (state, id) = TaskState::new("t", "alpha", "r");
450        tr.register(state).await;
451
452        let tool = SendMessageTool::new(reg, tr, crate::tools::agent::WorkerTable::default());
453        let result = tool
454            .execute(json!({
455                "task_id": id.to_string(),
456                "message": "do thing"
457            }))
458            .await
459            .unwrap();
460        assert!(result.contains(&id.to_string()));
461        assert!(result.contains("delivered"));
462    }
463
464    #[tokio::test]
465    async fn send_message_unknown_task_errors() {
466        let reg = WorkerRegistry::new();
467        let tr = Arc::new(TaskRegistry::new());
468        let tool = SendMessageTool::new(reg, tr, crate::tools::agent::WorkerTable::default());
469        let res = tool
470            .execute(json!({
471                "task_id": "task-bogus",
472                "message": "hi"
473            }))
474            .await;
475        assert!(matches!(res, Err(Error::NotFound(_))));
476    }
477
478    #[tokio::test]
479    async fn send_message_requires_one_of_ids() {
480        let reg = WorkerRegistry::new();
481        let tr = Arc::new(TaskRegistry::new());
482        let tool = SendMessageTool::new(reg, tr, crate::tools::agent::WorkerTable::default());
483        let res = tool.execute(json!({ "message": "hi" })).await;
484        assert!(matches!(res, Err(Error::BadToolArgs { .. })));
485    }
486
487    #[tokio::test]
488    async fn send_message_unknown_worker() {
489        let reg = WorkerRegistry::new();
490        let tr = Arc::new(TaskRegistry::new());
491        let tool = SendMessageTool::new(reg, tr, crate::tools::agent::WorkerTable::default());
492        let result = tool
493            .execute(json!({"worker_id": "ghost", "message": "hi"}))
494            .await
495            .unwrap();
496        assert!(result.contains("ghost"));
497        assert!(result.contains("not found"));
498    }
499
500    #[tokio::test]
501    async fn send_message_shows_active_workers() {
502        let reg = WorkerRegistry::new();
503        let tr = Arc::new(TaskRegistry::new());
504        reg.register("active-1").await;
505        let tool = SendMessageTool::new(reg, tr, crate::tools::agent::WorkerTable::default());
506        let result = tool
507            .execute(json!({"worker_id": "missing", "message": "hello"}))
508            .await
509            .unwrap();
510        assert!(result.contains("active-1"));
511    }
512
513    #[tokio::test]
514    async fn list_workers_includes_tasks() {
515        let reg = WorkerRegistry::new();
516        let tr = Arc::new(TaskRegistry::new());
517        reg.register("w1").await;
518        let (state, id) = TaskState::new("t", "alpha", "r");
519        tr.register(state).await;
520        let tool = ListWorkersTool::new(reg, tr);
521        let out = tool.execute(json!({})).await.unwrap();
522        assert!(out.contains("w1"));
523        assert!(out.contains(&id.to_string()));
524    }
525
526    // ── Additional targeted tests ─────────────────────────────────────────────
527
528    #[tokio::test]
529    async fn mailbox_is_empty_false_when_nonempty() {
530        // kills `replace WorkerMailbox::is_empty -> bool with true`
531        let mb = WorkerMailbox::new();
532        assert!(mb.is_empty().await, "fresh mailbox must be empty");
533        mb.push("msg".into()).await;
534        assert!(
535            !mb.is_empty().await,
536            "mailbox with a message must NOT be empty"
537        );
538    }
539
540    #[test]
541    fn send_message_tool_is_deferred() {
542        // kills `replace SendMessageTool::is_deferred -> bool with false`
543        let reg = WorkerRegistry::new();
544        let tr = Arc::new(TaskRegistry::new());
545        let tool = SendMessageTool::new(reg, tr, crate::tools::agent::WorkerTable::default());
546        assert!(
547            tool.is_deferred(),
548            "SendMessageTool must be deferred (depends on available workers)"
549        );
550    }
551
552    #[tokio::test]
553    async fn registry_active_workers_lists_all_registered() {
554        // kills `replace WorkerRegistry::active_workers -> Vec<String> with vec![]`
555        let reg = WorkerRegistry::new();
556        reg.register("w-alpha").await;
557        reg.register("w-beta").await;
558        let workers = reg.active_workers().await;
559        assert!(
560            workers.contains(&"w-alpha".to_string()),
561            "active_workers must include w-alpha"
562        );
563        assert!(
564            workers.contains(&"w-beta".to_string()),
565            "active_workers must include w-beta"
566        );
567        assert_eq!(workers.len(), 2, "expected exactly 2 active workers");
568    }
569}