Skip to main content

ai_agent/tools/
team.rs

1// Source: ~/claudecode/openclaudecode/src/tools/TeamCreateTool/TeamCreateTool.ts
2// Source: ~/claudecode/openclaudecode/src/tools/TeamDeleteTool/TeamDeleteTool.ts
3// Source: ~/claudecode/openclaudecode/src/tools/SendMessageTool/SendMessageTool.ts
4//! Team management tools.
5//!
6//! Provides tools for creating and deleting multi-agent teams and sending messages.
7
8use crate::error::AgentError;
9use crate::types::*;
10use std::collections::HashMap;
11use std::sync::{Mutex, OnceLock};
12
13pub const TEAM_CREATE_TOOL_NAME: &str = "TeamCreate";
14pub const TEAM_DELETE_TOOL_NAME: &str = "TeamDelete";
15pub const SEND_MESSAGE_TOOL_NAME: &str = "SendMessage";
16
17/// Global team store
18static TEAMS: OnceLock<Mutex<HashMap<String, Team>>> = OnceLock::new();
19/// Message inbox store
20static INBOX: OnceLock<Mutex<Vec<AgentMessage>>> = OnceLock::new();
21
22fn get_teams_map() -> &'static Mutex<HashMap<String, Team>> {
23    TEAMS.get_or_init(|| Mutex::new(HashMap::new()))
24}
25
26fn get_inbox() -> &'static Mutex<Vec<AgentMessage>> {
27    INBOX.get_or_init(|| Mutex::new(Vec::new()))
28}
29
30#[derive(Debug, Clone)]
31struct Team {
32    name: String,
33    description: String,
34    agents: Vec<AgentInfo>,
35    team_file_path: Option<String>,
36}
37
38#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
39pub struct AgentInfo {
40    pub name: String,
41    pub description: Option<String>,
42    pub model: Option<String>,
43}
44
45#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
46pub struct AgentMessage {
47    pub to: String,
48    pub from: Option<String>,
49    pub message: String,
50    pub timestamp: u64,
51}
52
53/// TeamCreate tool - create a team of agents
54pub struct TeamCreateTool;
55
56impl TeamCreateTool {
57    pub fn new() -> Self {
58        Self
59    }
60
61    pub fn name(&self) -> &str {
62        TEAMCREATE_TOOL_NAME
63    }
64
65    pub fn description(&self) -> &str {
66        "Create a team of agents that can work in parallel. Teams enable swarm mode where agents collaborate on complex tasks."
67    }
68
69    pub fn user_facing_name(&self, _input: Option<&serde_json::Value>) -> String {
70        "TeamCreate".to_string()
71    }
72
73    pub fn get_tool_use_summary(&self, input: Option<&serde_json::Value>) -> Option<String> {
74        input.and_then(|inp| inp["name"].as_str().map(String::from))
75    }
76
77    pub fn render_tool_result_message(
78        &self,
79        content: &serde_json::Value,
80    ) -> Option<String> {
81        content["content"].as_str().map(|s| s.to_string())
82    }
83
84    pub fn input_schema(&self) -> ToolInputSchema {
85        ToolInputSchema {
86            schema_type: "object".to_string(),
87            properties: serde_json::json!({
88                "name": {
89                    "type": "string",
90                    "description": "Name of the team to create"
91                },
92                "description": {
93                    "type": "string",
94                    "description": "Description of what the team does"
95                },
96                "agents": {
97                    "type": "array",
98                    "items": {
99                        "type": "object",
100                        "properties": {
101                            "name": { "type": "string", "description": "Agent name" },
102                            "description": { "type": "string", "description": "Agent description" },
103                            "model": { "type": "string", "description": "Agent model" }
104                        }
105                    },
106                    "description": "List of agents in the team"
107                }
108            }),
109            required: Some(vec!["name".to_string()]),
110        }
111    }
112
113    pub async fn execute(
114        &self,
115        input: serde_json::Value,
116        _context: &ToolContext,
117    ) -> Result<ToolResult, AgentError> {
118        let name = input["name"]
119            .as_str()
120            .ok_or_else(|| AgentError::Tool("name is required".to_string()))?
121            .to_string();
122
123        let description = input["description"].as_str().unwrap_or("").to_string();
124
125        let agents: Vec<AgentInfo> = input["agents"]
126            .as_array()
127            .map(|arr| {
128                arr.iter()
129                    .filter_map(|v| {
130                        let name = v.get("name")?.as_str()?.to_string();
131                        let description = v
132                            .get("description")
133                            .and_then(|v| v.as_str())
134                            .map(|s| s.to_string());
135                        let model = v
136                            .get("model")
137                            .and_then(|v| v.as_str())
138                            .map(|s| s.to_string());
139                        Some(AgentInfo {
140                            name,
141                            description,
142                            model,
143                        })
144                    })
145                    .collect()
146            })
147            .unwrap_or_default();
148
149        // Check for duplicate team name
150        let mut guard = get_teams_map().lock().unwrap();
151        if guard.contains_key(&name) {
152            return Ok(ToolResult {
153                result_type: "text".to_string(),
154                tool_use_id: "".to_string(),
155                content: format!("Error: Team '{}' already exists.", name),
156                is_error: Some(true),
157                was_persisted: None,
158            });
159        }
160        drop(guard);
161
162        // In a full implementation, this would:
163        // 1. Write team file to .ai/teams/<name>/team.json
164        // 2. Initialize task list for the team
165        // 3. Set up AppState team context
166        // 4. Spawn agent processes for each team member
167
168        let team = Team {
169            name: name.clone(),
170            description: description.clone(),
171            agents,
172            team_file_path: None,
173        };
174
175        let mut guard = get_teams_map().lock().unwrap();
176        guard.insert(name.clone(), team);
177        let agent_count = guard.get(&name).map(|t| t.agents.len()).unwrap_or(0);
178        drop(guard);
179
180        Ok(ToolResult {
181            result_type: "text".to_string(),
182            tool_use_id: "".to_string(),
183            content: format!(
184                "Team '{}' created successfully.\n\
185                Description: {}\n\
186                Agents: {}\n\n\
187                The team is ready for coordination. \
188                Team members can communicate using the SendMessage tool.",
189                name, description, agent_count
190            ),
191            is_error: Some(false),
192            was_persisted: None,
193        })
194    }
195}
196
197impl Default for TeamCreateTool {
198    fn default() -> Self {
199        Self::new()
200    }
201}
202
203/// TeamDelete tool - delete a team
204pub struct TeamDeleteTool;
205
206impl TeamDeleteTool {
207    pub fn new() -> Self {
208        Self
209    }
210
211    pub fn name(&self) -> &str {
212        TEAM_DELETE_TOOL_NAME
213    }
214
215    pub fn description(&self) -> &str {
216        "Delete a previously created team. All team members will be stopped and the team configuration will be removed."
217    }
218
219    pub fn user_facing_name(&self, _input: Option<&serde_json::Value>) -> String {
220        "TeamDelete".to_string()
221    }
222
223    pub fn get_tool_use_summary(&self, input: Option<&serde_json::Value>) -> Option<String> {
224        input.and_then(|inp| inp["name"].as_str().map(String::from))
225    }
226
227    pub fn render_tool_result_message(
228        &self,
229        content: &serde_json::Value,
230    ) -> Option<String> {
231        content["content"].as_str().map(|s| s.to_string())
232    }
233
234    pub fn input_schema(&self) -> ToolInputSchema {
235        ToolInputSchema {
236            schema_type: "object".to_string(),
237            properties: serde_json::json!({
238                "name": {
239                    "type": "string",
240                    "description": "Name of the team to delete"
241                }
242            }),
243            required: Some(vec!["name".to_string()]),
244        }
245    }
246
247    pub async fn execute(
248        &self,
249        input: serde_json::Value,
250        _context: &ToolContext,
251    ) -> Result<ToolResult, AgentError> {
252        let name = input["name"]
253            .as_str()
254            .ok_or_else(|| AgentError::Tool("name is required".to_string()))?;
255
256        let mut guard = get_teams_map().lock().unwrap();
257        let team = guard.remove(name);
258        drop(guard);
259
260        let team = team.ok_or_else(|| AgentError::Tool(format!("Team '{}' not found", name)))?;
261
262        // In a full implementation, this would:
263        // 1. Check for active team members and warn/abort
264        // 2. Stop all running team agents
265        // 3. Clean up worktrees associated with the team
266        // 4. Reset team colors
267        // 5. Clear AppState team context
268        // 6. Delete team file
269
270        let agent_names: Vec<String> = team.agents.iter().map(|a| a.name.clone()).collect();
271
272        Ok(ToolResult {
273            result_type: "text".to_string(),
274            tool_use_id: "".to_string(),
275            content: format!(
276                "Team '{}' deleted successfully.\n\
277                Stopped {} agent(s): {}",
278                name,
279                agent_names.len(),
280                agent_names.join(", ")
281            ),
282            is_error: Some(false),
283            was_persisted: None,
284        })
285    }
286}
287
288impl Default for TeamDeleteTool {
289    fn default() -> Self {
290        Self::new()
291    }
292}
293
294/// SendMessage tool - send message between agents
295pub struct SendMessageTool;
296
297impl SendMessageTool {
298    pub fn new() -> Self {
299        Self
300    }
301
302    pub fn name(&self) -> &str {
303        SEND_MESSAGE_TOOL_NAME
304    }
305
306    pub fn description(&self) -> &str {
307        "Send a message to another agent. Use 'to: *' to broadcast to all agents. Supports direct messages, shutdown requests, and plan approvals."
308    }
309
310    pub fn user_facing_name(&self, _input: Option<&serde_json::Value>) -> String {
311        "SendMessage".to_string()
312    }
313
314    pub fn get_tool_use_summary(&self, input: Option<&serde_json::Value>) -> Option<String> {
315        input.and_then(|inp| inp["to"].as_str().map(String::from))
316    }
317
318    pub fn render_tool_result_message(
319        &self,
320        content: &serde_json::Value,
321    ) -> Option<String> {
322        content["content"].as_str().map(|s| s.to_string())
323    }
324
325    pub fn input_schema(&self) -> ToolInputSchema {
326        ToolInputSchema {
327            schema_type: "object".to_string(),
328            properties: serde_json::json!({
329                "to": {
330                    "type": "string",
331                    "description": "Agent name to send message to. Use '*' to broadcast to all agents."
332                },
333                "message": {
334                    "type": "string",
335                    "description": "Message content"
336                }
337            }),
338            required: Some(vec!["to".to_string(), "message".to_string()]),
339        }
340    }
341
342    pub async fn execute(
343        &self,
344        input: serde_json::Value,
345        _context: &ToolContext,
346    ) -> Result<ToolResult, AgentError> {
347        let to = input["to"]
348            .as_str()
349            .ok_or_else(|| AgentError::Tool("to is required".to_string()))?;
350
351        let message = input["message"]
352            .as_str()
353            .ok_or_else(|| AgentError::Tool("message is required".to_string()))?;
354
355        let timestamp = std::time::SystemTime::now()
356            .duration_since(std::time::UNIX_EPOCH)
357            .map(|d| d.as_secs())
358            .unwrap_or(0);
359
360        let msg = AgentMessage {
361            to: to.to_string(),
362            from: None,
363            message: message.to_string(),
364            timestamp,
365        };
366
367        // In a full implementation, this would:
368        // 1. For direct messages: deliver via UDS socket or in-process channel
369        // 2. For broadcasts (*): send to all connected agents
370        // 3. For shutdown requests: trigger agent termination
371        // 4. For plan approvals: route to plan approval handler
372        // 5. For bridge messaging: use cross-session communication
373
374        let mut inbox = get_inbox().lock().unwrap();
375        inbox.push(msg);
376        let inbox_len = inbox.len();
377        drop(inbox);
378
379        let recipient = if to == "*" {
380            "all agents (broadcast)".to_string()
381        } else {
382            format!("agent '{}'", to)
383        };
384
385        Ok(ToolResult {
386            result_type: "text".to_string(),
387            tool_use_id: "".to_string(),
388            content: format!(
389                "Message sent to {}.\n\
390                Message: {}\n\
391                Inbox size: {}",
392                recipient, message, inbox_len
393            ),
394            is_error: Some(false),
395            was_persisted: None,
396        })
397    }
398}
399
400impl Default for SendMessageTool {
401    fn default() -> Self {
402        Self::new()
403    }
404}
405
406// Fix: constant name
407const TEAMCREATE_TOOL_NAME: &str = "TeamCreate";
408
409/// Reset the global team and inbox stores for test isolation.
410pub fn reset_teams_for_testing() {
411    let mut guard = get_teams_map().lock().unwrap();
412    guard.clear();
413    drop(guard);
414    let mut inbox = get_inbox().lock().unwrap();
415    inbox.clear();
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    use crate::tests::common::clear_all_test_state;
423
424    #[tokio::test]
425    async fn test_team_create_and_delete() {
426        clear_all_test_state();
427        let create = TeamCreateTool::new();
428        let result = create
429            .execute(
430                serde_json::json!({
431                    "name": "test-team",
432                    "description": "A test team",
433                    "agents": [
434                        { "name": "agent1", "description": "First agent" }
435                    ]
436                }),
437                &ToolContext::default(),
438            )
439            .await;
440        assert!(result.is_ok());
441
442        let delete = TeamDeleteTool::new();
443        let result = delete
444            .execute(
445                serde_json::json!({ "name": "test-team" }),
446                &ToolContext::default(),
447            )
448            .await;
449        assert!(result.is_ok());
450        assert!(result.unwrap().content.contains("deleted"));
451    }
452
453    #[tokio::test]
454    async fn test_send_message() {
455        clear_all_test_state();
456        let send = SendMessageTool::new();
457        let result = send
458            .execute(
459                serde_json::json!({
460                    "to": "agent1",
461                    "message": "Hello from test"
462                }),
463                &ToolContext::default(),
464            )
465            .await;
466        assert!(result.is_ok());
467        assert!(result.unwrap().content.contains("agent 'agent1'"));
468    }
469
470    #[tokio::test]
471    async fn test_send_message_broadcast() {
472        clear_all_test_state();
473        let send = SendMessageTool::new();
474        let result = send
475            .execute(
476                serde_json::json!({
477                    "to": "*",
478                    "message": "Broadcast message"
479                }),
480                &ToolContext::default(),
481            )
482            .await;
483        assert!(result.is_ok());
484        assert!(result.unwrap().content.contains("broadcast"));
485    }
486}