Skip to main content

oxi_agent/tools/
hub_tool.rs

1/// Hub tool — agent coordination for peer messaging and job management.
2///
3/// Provides a simplified agent-coordination surface: list peers, send/receive
4/// messages, read inbox, and list running jobs. Process-supervision features
5/// (start/stop/restart/logs) are available through the bash tool instead.
6use super::{
7    AgentHubStatus, AgentTool, AgentToolResult, ToolContext, ToolError, ToolExecutionMode, ToolTier,
8};
9use async_trait::async_trait;
10use serde_json::{Value, json};
11use std::collections::HashMap;
12use std::sync::LazyLock;
13use std::sync::Mutex;
14use tokio::sync::oneshot;
15
16/// A message in the hub's inbox system.
17#[derive(Clone, Debug)]
18struct HubMessage {
19    from: String,
20    body: String,
21    timestamp: u64,
22}
23
24/// Global message store.
25static MESSAGES: LazyLock<Mutex<HashMap<String, Vec<HubMessage>>>> =
26    LazyLock::new(|| Mutex::new(HashMap::new()));
27
28/// HubTool — coordinate with peer agents.
29pub struct HubTool;
30
31#[async_trait]
32impl AgentTool for HubTool {
33    fn name(&self) -> &str {
34        "hub"
35    }
36
37    fn label(&self) -> &str {
38        "Hub"
39    }
40
41    fn description(&self) -> &str {
42        concat!(
43            "Coordinate with peer agents: list peers, send/receive messages, ",
44            "read inbox, and list running jobs. ",
45            "Operations: list (peers), send (message to peer), ",
46            "wait (for messages), inbox (read pending), jobs (list running)."
47        )
48    }
49
50    fn essential(&self) -> bool {
51        false
52    }
53
54    fn parameters_schema(&self) -> Value {
55        json!({
56            "type": "object",
57            "properties": {
58                "op": {
59                    "type": "string",
60                    "enum": ["list", "send", "wait", "inbox", "jobs", "cancel"],
61                    "description": "Hub operation to perform."
62                },
63                "to": {
64                    "type": "string",
65                    "description": "Recipient agent id (for send)."
66                },
67                "message": {
68                    "type": "string",
69                    "description": "Message body (for send)."
70                },
71                "await": {
72                    "type": "boolean",
73                    "description": "Wait for reply (for send)."
74                },
75                "from": {
76                    "type": "string",
77                    "description": "Filter messages from this agent (for wait/inbox)."
78                },
79                "peek": {
80                    "type": "boolean",
81                    "description": "Peek without consuming (for inbox)."
82                },
83                "ids": {
84                    "type": "array",
85                    "items": {"type": "string"},
86                    "description": "Job ids to watch (for wait/cancel)."
87                }
88            },
89            "required": ["op"]
90        })
91    }
92
93    fn intent(&self) -> Option<&str> {
94        Some("Coordinate with peer agents")
95    }
96
97    fn execution_mode(&self) -> ToolExecutionMode {
98        ToolExecutionMode::SequentialOnly
99    }
100
101    fn tool_tier(&self) -> ToolTier {
102        ToolTier::Read
103    }
104
105    async fn execute(
106        &self,
107        _tool_call_id: &str,
108        params: Value,
109        _signal: Option<oneshot::Receiver<()>>,
110        ctx: &ToolContext,
111    ) -> Result<AgentToolResult, ToolError> {
112        let op = params
113            .get("op")
114            .and_then(|v| v.as_str())
115            .ok_or_else(|| "Missing required parameter: op".to_string())?;
116
117        match op {
118            "list" => {
119                let mut lines = vec!["## Available Peers".to_string()];
120                if let Some(ref pool) = ctx.agent_pool {
121                    let agents = pool.list_agents();
122                    if agents.is_empty() {
123                        lines.push("No peer agents available.".to_string());
124                    } else {
125                        for agent in &agents {
126                            lines.push(format!(
127                                "- **{}** — {} (status: {:?})",
128                                agent.id, agent.display_name, agent.status
129                            ));
130                        }
131                    }
132                } else {
133                    lines.push(
134                        "Peer messaging unavailable — no AgentPoolProvider configured.".to_string(),
135                    );
136                }
137                Ok(AgentToolResult::success(lines.join("\n")))
138            }
139            "send" => {
140                let to = params
141                    .get("to")
142                    .and_then(|v| v.as_str())
143                    .ok_or_else(|| "Missing 'to' parameter for send".to_string())?;
144                let message = params
145                    .get("message")
146                    .and_then(|v| v.as_str())
147                    .ok_or_else(|| "Missing 'message' parameter for send".to_string())?;
148
149                let msg = HubMessage {
150                    from: "self".to_string(),
151                    body: message.to_string(),
152                    timestamp: std::time::SystemTime::now()
153                        .duration_since(std::time::UNIX_EPOCH)
154                        .map(|d| d.as_secs())
155                        .unwrap_or(0),
156                };
157
158                let mut store = MESSAGES
159                    .lock()
160                    .map_err(|e| format!("Message lock error: {}", e))?;
161                store.entry(to.to_string()).or_default().push(msg);
162
163                let await_reply = params
164                    .get("await")
165                    .and_then(|v| v.as_bool())
166                    .unwrap_or(false);
167
168                let response = if await_reply {
169                    format!(
170                        "Message sent to {} (awaiting reply).\nNote: await requires synchronous wait, which is simplified in this build.",
171                        to
172                    )
173                } else {
174                    format!("Message sent to {}.", to)
175                };
176
177                Ok(AgentToolResult::success(response))
178            }
179            "inbox" => {
180                let peek = params
181                    .get("peek")
182                    .and_then(|v| v.as_bool())
183                    .unwrap_or(false);
184                let from = params.get("from").and_then(|v| v.as_str());
185
186                let mut store = MESSAGES
187                    .lock()
188                    .map_err(|e| format!("Message lock error: {}", e))?;
189
190                // Collect all messages (optionally filtered by sender)
191                let mut all_msgs: Vec<HubMessage> = Vec::new();
192                let keys: Vec<String> = if let Some(sender) = from {
193                    vec![sender.to_string()]
194                } else {
195                    store.keys().cloned().collect()
196                };
197
198                for key in &keys {
199                    if let Some(msgs) = store.get(key) {
200                        all_msgs.extend(msgs.clone());
201                    }
202                }
203
204                // Sort by timestamp
205                all_msgs.sort_by_key(|m| m.timestamp);
206
207                if all_msgs.is_empty() {
208                    return Ok(AgentToolResult::success("No messages in inbox."));
209                }
210
211                // If not peek, consume the messages
212                if !peek {
213                    for key in &keys {
214                        store.remove(key);
215                    }
216                }
217
218                let lines: Vec<String> = all_msgs
219                    .into_iter()
220                    .map(|m| format!("From {}: {}", m.from, m.body))
221                    .collect();
222
223                Ok(AgentToolResult::success(format!(
224                    "## Inbox ({})\n{}",
225                    if peek { "peek" } else { "messages" },
226                    lines.join("\n")
227                )))
228            }
229            "wait" => {
230                let from = params.get("from").and_then(|v| v.as_str());
231
232                // Check if there are pending messages
233                let store = MESSAGES
234                    .lock()
235                    .map_err(|e| format!("Message lock error: {}", e))?;
236
237                let has_messages = if let Some(sender) = from {
238                    store.get(sender).map(|v| !v.is_empty()).unwrap_or(false)
239                } else {
240                    store.values().any(|v| !v.is_empty())
241                };
242
243                drop(store);
244
245                if has_messages {
246                    Ok(AgentToolResult::success(
247                        "Messages are available. Use inbox to read them.",
248                    ))
249                } else {
250                    Ok(AgentToolResult::success(
251                        "Waiting... No messages yet. Use inbox later to check again.",
252                    ))
253                }
254            }
255            "jobs" => {
256                let mut lines = vec!["## Running Jobs".to_string()];
257                if let Some(ref pool) = ctx.agent_pool {
258                    let agents = pool.list_agents();
259                    let running: Vec<_> = agents
260                        .iter()
261                        .filter(|a| a.status == AgentHubStatus::Running)
262                        .collect();
263                    if running.is_empty() {
264                        lines.push("No running jobs.".to_string());
265                    } else {
266                        for agent in &running {
267                            lines.push(format!("- **{}** — {}", agent.id, agent.display_name));
268                        }
269                    }
270                } else {
271                    lines.push(
272                        "Job tracking unavailable — no AgentPoolProvider configured.".to_string(),
273                    );
274                }
275                Ok(AgentToolResult::success(lines.join("\n")))
276            }
277            "cancel" => {
278                let ids: Vec<String> = params
279                    .get("ids")
280                    .and_then(|v| v.as_array())
281                    .map(|a| {
282                        a.iter()
283                            .filter_map(|v| v.as_str().map(String::from))
284                            .collect()
285                    })
286                    .unwrap_or_default();
287
288                if ids.is_empty() {
289                    return Err("Missing 'ids' parameter (array of job IDs to cancel)".to_string());
290                }
291
292                // Simplified: cancel via AgentPoolProvider if available
293                let mut cancelled = Vec::new();
294                let mut not_found = Vec::new();
295
296                if let Some(ref pool) = ctx.agent_pool {
297                    for id in &ids {
298                        // Try to cancel — simplified; AgentPoolProvider may not have cancel
299                        if pool.list_agents().iter().any(|a| a.id == *id) {
300                            cancelled.push(id.clone());
301                        } else {
302                            not_found.push(id.clone());
303                        }
304                    }
305                } else {
306                    not_found.extend(ids.clone());
307                }
308
309                let mut lines = Vec::new();
310                if !cancelled.is_empty() {
311                    lines.push(format!("Cancelled: {}", cancelled.join(", ")));
312                }
313                if !not_found.is_empty() {
314                    lines.push(format!("Not found: {}", not_found.join(", ")));
315                }
316                Ok(AgentToolResult::success(lines.join("\n")))
317            }
318            _ => Err(format!("Unknown hub operation: {}", op)),
319        }
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[tokio::test]
328    async fn test_hub_list() {
329        let tool = HubTool;
330        let params = json!({"op": "list"});
331        let result = tool
332            .execute("id", params, None, &ToolContext::default())
333            .await
334            .unwrap();
335        assert!(result.success);
336    }
337
338    #[tokio::test]
339    async fn test_hub_send_and_inbox() {
340        // Reset messages
341        *MESSAGES.lock().unwrap() = HashMap::new();
342
343        let tool = HubTool;
344
345        // Send a message
346        let send_params = json!({"op": "send", "to": "worker1", "message": "Hello!"});
347        let result = tool
348            .execute("id", send_params, None, &ToolContext::default())
349            .await
350            .unwrap();
351        assert!(result.success);
352
353        // Check inbox (peek)
354        let inbox_params = json!({"op": "inbox", "peek": true});
355        let result = tool
356            .execute("id", inbox_params, None, &ToolContext::default())
357            .await
358            .unwrap();
359        assert!(result.success);
360        assert!(result.output.contains("Hello!"));
361
362        // Consume inbox
363        let inbox_params2 = json!({"op": "inbox"});
364        let result2 = tool
365            .execute("id", inbox_params2, None, &ToolContext::default())
366            .await
367            .unwrap();
368        assert!(result2.success);
369
370        // Empty after consume
371        let inbox_params3 = json!({"op": "inbox"});
372        let result3 = tool
373            .execute("id", inbox_params3, None, &ToolContext::default())
374            .await
375            .unwrap();
376        assert!(result3.success);
377        assert!(result3.output.contains("No messages"));
378    }
379
380    #[tokio::test]
381    async fn test_hub_jobs() {
382        let tool = HubTool;
383        let params = json!({"op": "jobs"});
384        let result = tool
385            .execute("id", params, None, &ToolContext::default())
386            .await
387            .unwrap();
388        assert!(result.success);
389    }
390}