recursive/tools/
send_message.rs1use 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#[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 pub async fn push(&self, msg: String) {
52 self.queue.lock().await.push_back(msg);
53 }
54
55 pub async fn pop(&self) -> Option<String> {
57 self.queue.lock().await.pop_front()
58 }
59
60 pub async fn drain_all(&self) -> Vec<String> {
62 let mut q = self.queue.lock().await;
63 q.drain(..).collect()
64 }
65
66 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#[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 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 pub async fn deregister(&self, worker_id: &str) {
106 self.inner.write().await.remove(worker_id);
107 }
108
109 pub async fn get(&self, worker_id: &str) -> Option<WorkerMailbox> {
111 self.inner.read().await.get(worker_id).cloned()
112 }
113
114 pub async fn active_workers(&self) -> Vec<String> {
116 self.inner.read().await.keys().cloned().collect()
117 }
118}
119
120pub 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#[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}