Skip to main content

recursive/tools/
send_message.rs

1//! `send_message` tool — bidirectional coordinator ↔ worker messaging.
2//!
3//! # Design
4//!
5//! Each worker spawned via `spawn_worker` (when operating in coordinator mode)
6//! registers a `WorkerMailbox` in the global `WorkerRegistry`.  The coordinator
7//! can then call `send_message` to push follow-up instructions into a running
8//! worker's mailbox.  The worker's agent loop drains the mailbox between steps
9//! and appends any pending messages as new user turns, allowing mid-run guidance
10//! without restarting the worker.
11//!
12//! # Current status
13//!
14//! This file provides:
15//! - `WorkerMailbox` — a thread-safe FIFO queue of pending messages.
16//! - `WorkerRegistry` — a shared map from `worker_id` → `WorkerMailbox`.
17//! - `SendMessageTool` — the LLM-callable tool that pushes messages.
18//!
19//! The kernel integration (polling the mailbox between steps) is the next
20//! phase; see `.dev/goals/180-send-message-tool.md`.
21
22use async_trait::async_trait;
23use serde_json::{json, Value};
24use std::collections::VecDeque;
25use std::sync::Arc;
26use tokio::sync::RwLock;
27
28use crate::error::{Error, Result};
29use crate::llm::ToolSpec;
30use crate::tools::{Tool, ToolSideEffect};
31
32// ---------------------------------------------------------------------------
33// WorkerMailbox
34// ---------------------------------------------------------------------------
35
36/// A FIFO queue of pending messages for a single worker agent.
37///
38/// The coordinator pushes messages here; the worker's kernel drains them
39/// between steps.
40#[derive(Clone, Default)]
41pub struct WorkerMailbox {
42    queue: Arc<tokio::sync::Mutex<VecDeque<String>>>,
43}
44
45impl WorkerMailbox {
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    /// Push a message into the mailbox.
51    pub async fn push(&self, msg: String) {
52        self.queue.lock().await.push_back(msg);
53    }
54
55    /// Pop the oldest pending message, or `None` if empty.
56    pub async fn pop(&self) -> Option<String> {
57        self.queue.lock().await.pop_front()
58    }
59
60    /// Drain all pending messages into a Vec.
61    pub async fn drain_all(&self) -> Vec<String> {
62        let mut q = self.queue.lock().await;
63        q.drain(..).collect()
64    }
65
66    /// Return the number of pending messages without consuming them.
67    pub async fn len(&self) -> usize {
68        self.queue.lock().await.len()
69    }
70
71    pub async fn is_empty(&self) -> bool {
72        self.queue.lock().await.is_empty()
73    }
74}
75
76// ---------------------------------------------------------------------------
77// WorkerRegistry
78// ---------------------------------------------------------------------------
79
80/// Global registry mapping `worker_id` → `WorkerMailbox`.
81///
82/// Shared between `spawn_worker` (registers on spawn, deregisters on finish)
83/// and `send_message` (looks up the mailbox to push messages).
84#[derive(Clone, Default)]
85pub struct WorkerRegistry {
86    inner: Arc<RwLock<std::collections::HashMap<String, WorkerMailbox>>>,
87}
88
89impl WorkerRegistry {
90    pub fn new() -> Self {
91        Self::default()
92    }
93
94    /// Register a new worker and return its mailbox.
95    pub async fn register(&self, worker_id: &str) -> WorkerMailbox {
96        let mailbox = WorkerMailbox::new();
97        self.inner
98            .write()
99            .await
100            .insert(worker_id.to_string(), mailbox.clone());
101        mailbox
102    }
103
104    /// Deregister a worker (called when it finishes).
105    pub async fn deregister(&self, worker_id: &str) {
106        self.inner.write().await.remove(worker_id);
107    }
108
109    /// Get the mailbox for a worker, or `None` if not registered.
110    pub async fn get(&self, worker_id: &str) -> Option<WorkerMailbox> {
111        self.inner.read().await.get(worker_id).cloned()
112    }
113
114    /// List active worker IDs.
115    pub async fn active_workers(&self) -> Vec<String> {
116        self.inner.read().await.keys().cloned().collect()
117    }
118}
119
120// ---------------------------------------------------------------------------
121// SendMessageTool
122// ---------------------------------------------------------------------------
123
124/// The `send_message` tool — push a follow-up message to a running worker.
125pub struct SendMessageTool {
126    registry: WorkerRegistry,
127}
128
129impl SendMessageTool {
130    pub fn new(registry: WorkerRegistry) -> Self {
131        Self { registry }
132    }
133}
134
135#[async_trait]
136impl Tool for SendMessageTool {
137    fn spec(&self) -> ToolSpec {
138        ToolSpec {
139            name: "send_message".into(),
140            description: concat!(
141                "Send a follow-up message to a running worker agent. ",
142                "The worker will receive the message between steps and incorporate it ",
143                "into its ongoing task. Use this to provide additional guidance, ",
144                "corrections, or new information to a worker without restarting it."
145            )
146            .into(),
147            parameters: json!({
148                "type": "object",
149                "properties": {
150                    "worker_id": {
151                        "type": "string",
152                        "description": "The worker ID returned by spawn_worker."
153                    },
154                    "message": {
155                        "type": "string",
156                        "description": "The message to send to the worker."
157                    }
158                },
159                "required": ["worker_id", "message"]
160            }),
161        }
162    }
163
164    fn side_effect_class(&self) -> ToolSideEffect {
165        ToolSideEffect::ReadOnly
166    }
167
168    async fn execute(&self, arguments: Value) -> Result<String> {
169        let worker_id = arguments["worker_id"]
170            .as_str()
171            .ok_or_else(|| Error::BadToolArgs {
172                name: "send_message".into(),
173                message: "missing required parameter: worker_id".to_string(),
174            })?;
175
176        let message = arguments["message"]
177            .as_str()
178            .ok_or_else(|| Error::BadToolArgs {
179                name: "send_message".into(),
180                message: "missing required parameter: message".to_string(),
181            })?
182            .to_string();
183
184        match self.registry.get(worker_id).await {
185            Some(mailbox) => {
186                mailbox.push(message).await;
187                Ok(format!("Message delivered to worker '{worker_id}'."))
188            }
189            None => {
190                let active = self.registry.active_workers().await;
191                if active.is_empty() {
192                    Ok(format!(
193                        "Worker '{worker_id}' not found. No active workers currently registered."
194                    ))
195                } else {
196                    let mut sorted = active;
197                    sorted.sort_unstable();
198                    Ok(format!(
199                        "Worker '{worker_id}' not found. Active workers: {}",
200                        sorted.join(", ")
201                    ))
202                }
203            }
204        }
205    }
206}
207
208// ---------------------------------------------------------------------------
209// Tests
210// ---------------------------------------------------------------------------
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[tokio::test]
217    async fn mailbox_push_pop() {
218        let mb = WorkerMailbox::new();
219        mb.push("hello".into()).await;
220        mb.push("world".into()).await;
221        assert_eq!(mb.len().await, 2);
222        assert_eq!(mb.pop().await, Some("hello".into()));
223        assert_eq!(mb.pop().await, Some("world".into()));
224        assert_eq!(mb.pop().await, None);
225    }
226
227    #[tokio::test]
228    async fn mailbox_drain_all() {
229        let mb = WorkerMailbox::new();
230        mb.push("a".into()).await;
231        mb.push("b".into()).await;
232        let msgs = mb.drain_all().await;
233        assert_eq!(msgs, vec!["a", "b"]);
234        assert!(mb.is_empty().await);
235    }
236
237    #[tokio::test]
238    async fn registry_register_and_get() {
239        let reg = WorkerRegistry::new();
240        reg.register("w1").await;
241        assert!(reg.get("w1").await.is_some());
242        assert!(reg.get("nonexistent").await.is_none());
243    }
244
245    #[tokio::test]
246    async fn registry_deregister() {
247        let reg = WorkerRegistry::new();
248        reg.register("w1").await;
249        reg.deregister("w1").await;
250        assert!(reg.get("w1").await.is_none());
251    }
252
253    #[tokio::test]
254    async fn send_message_delivers_to_worker() {
255        let reg = WorkerRegistry::new();
256        let mailbox = reg.register("w1").await;
257
258        let tool = SendMessageTool::new(reg);
259        let result = tool
260            .execute(json!({
261                "worker_id": "w1",
262                "message": "please add error handling"
263            }))
264            .await
265            .unwrap();
266
267        assert!(result.contains("w1"));
268        assert!(result.contains("delivered"));
269        assert_eq!(
270            mailbox.pop().await.as_deref(),
271            Some("please add error handling")
272        );
273    }
274
275    #[tokio::test]
276    async fn send_message_unknown_worker() {
277        let reg = WorkerRegistry::new();
278        let tool = SendMessageTool::new(reg);
279        let result = tool
280            .execute(json!({"worker_id": "ghost", "message": "hi"}))
281            .await
282            .unwrap();
283        assert!(result.contains("ghost"));
284        assert!(result.contains("not found"));
285    }
286
287    #[tokio::test]
288    async fn send_message_shows_active_workers() {
289        let reg = WorkerRegistry::new();
290        reg.register("active-1").await;
291        let tool = SendMessageTool::new(reg);
292        let result = tool
293            .execute(json!({"worker_id": "missing", "message": "hello"}))
294            .await
295            .unwrap();
296        assert!(result.contains("active-1"));
297    }
298}