Skip to main content

agentzero_tools/
subagent_tools.rs

1use agentzero_core::{Tool, ToolContext, ToolResult};
2use anyhow::{anyhow, Context};
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use std::sync::Mutex;
6use std::time::{SystemTime, UNIX_EPOCH};
7
8const MAX_SUBAGENTS: usize = 10;
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11#[serde(rename_all = "snake_case")]
12pub enum SubAgentStatus {
13    Running,
14    Completed,
15    Failed,
16    Killed,
17}
18
19#[derive(Debug, Clone)]
20#[allow(dead_code)]
21struct SubAgentSession {
22    id: String,
23    agent: String,
24    task: String,
25    status: SubAgentStatus,
26    created_at: u64,
27    result: Option<String>,
28}
29
30#[derive(Default)]
31pub struct SubAgentRegistry {
32    sessions: Vec<SubAgentSession>,
33}
34
35// ── subagent_spawn ──
36
37#[derive(Debug, Deserialize)]
38struct SubAgentSpawnInput {
39    agent: String,
40    task: String,
41    #[serde(default)]
42    context: Option<String>,
43}
44
45pub struct SubAgentSpawnTool {
46    registry: Mutex<SubAgentRegistry>,
47}
48
49impl Default for SubAgentSpawnTool {
50    fn default() -> Self {
51        Self {
52            registry: Mutex::new(SubAgentRegistry::default()),
53        }
54    }
55}
56
57impl SubAgentSpawnTool {
58    pub fn registry(&self) -> &Mutex<SubAgentRegistry> {
59        &self.registry
60    }
61}
62
63#[async_trait]
64impl Tool for SubAgentSpawnTool {
65    fn name(&self) -> &'static str {
66        "subagent_spawn"
67    }
68
69    fn description(&self) -> &'static str {
70        "Spawn a sub-agent to handle a task asynchronously. Returns a session ID for tracking."
71    }
72
73    fn input_schema(&self) -> Option<serde_json::Value> {
74        Some(serde_json::json!({
75            "type": "object",
76            "properties": {
77                "agent": { "type": "string", "description": "Name of the sub-agent to spawn" },
78                "task": { "type": "string", "description": "Task description for the sub-agent" },
79                "context": { "type": "string", "description": "Optional context to pass to the sub-agent" }
80            },
81            "required": ["agent", "task"],
82            "additionalProperties": false
83        }))
84    }
85
86    async fn execute(&self, input: &str, _ctx: &ToolContext) -> anyhow::Result<ToolResult> {
87        let req: SubAgentSpawnInput = serde_json::from_str(input)
88            .context("subagent_spawn expects JSON: {\"agent\": \"...\", \"task\": \"...\"}")?;
89
90        if req.agent.trim().is_empty() {
91            return Err(anyhow!("agent must not be empty"));
92        }
93        if req.task.trim().is_empty() {
94            return Err(anyhow!("task must not be empty"));
95        }
96
97        let mut registry = self.registry.lock().map_err(|_| anyhow!("lock poisoned"))?;
98
99        let active = registry
100            .sessions
101            .iter()
102            .filter(|s| s.status == SubAgentStatus::Running)
103            .count();
104        if active >= MAX_SUBAGENTS {
105            return Err(anyhow!(
106                "max concurrent subagents reached ({MAX_SUBAGENTS})"
107            ));
108        }
109
110        let session_id = format!(
111            "sa-{}-{}",
112            registry.sessions.len(),
113            SystemTime::now()
114                .duration_since(UNIX_EPOCH)
115                .unwrap_or_default()
116                .as_millis()
117        );
118
119        let _context = req.context;
120
121        registry.sessions.push(SubAgentSession {
122            id: session_id.clone(),
123            agent: req.agent.clone(),
124            task: req.task.clone(),
125            status: SubAgentStatus::Running,
126            created_at: SystemTime::now()
127                .duration_since(UNIX_EPOCH)
128                .unwrap_or_default()
129                .as_secs(),
130            result: None,
131        });
132
133        Ok(ToolResult {
134            output: format!(
135                "spawned subagent: session_id={session_id} agent={} status=running\nUse subagent_list or subagent_manage to check progress.",
136                req.agent
137            ),
138        })
139    }
140}
141
142// ── subagent_list ──
143
144#[derive(Debug, Default, Clone, Copy)]
145pub struct SubAgentListTool;
146
147#[async_trait]
148impl Tool for SubAgentListTool {
149    fn name(&self) -> &'static str {
150        "subagent_list"
151    }
152
153    fn description(&self) -> &'static str {
154        "List all running sub-agent sessions and their statuses."
155    }
156
157    fn input_schema(&self) -> Option<serde_json::Value> {
158        Some(serde_json::json!({
159            "type": "object",
160            "properties": {},
161            "additionalProperties": false
162        }))
163    }
164
165    async fn execute(&self, _input: &str, _ctx: &ToolContext) -> anyhow::Result<ToolResult> {
166        Ok(ToolResult {
167            output: "subagent_list: no shared registry in standalone mode. Use subagent_spawn's registry.".to_string(),
168        })
169    }
170}
171
172// ── subagent_manage ──
173
174#[derive(Debug, Deserialize)]
175struct SubAgentManageInput {
176    session_id: String,
177    action: SubAgentManageAction,
178}
179
180#[derive(Debug, Deserialize)]
181#[serde(rename_all = "snake_case")]
182enum SubAgentManageAction {
183    Status,
184    Kill,
185    Result,
186}
187
188#[derive(Debug, Default, Clone, Copy)]
189pub struct SubAgentManageTool;
190
191#[async_trait]
192impl Tool for SubAgentManageTool {
193    fn name(&self) -> &'static str {
194        "subagent_manage"
195    }
196
197    fn description(&self) -> &'static str {
198        "Manage a sub-agent session: cancel, get result, or check status."
199    }
200
201    fn input_schema(&self) -> Option<serde_json::Value> {
202        Some(serde_json::json!({
203            "type": "object",
204            "properties": {
205                "session_id": { "type": "string", "description": "The session ID of the sub-agent to manage" },
206                "action": { "type": "string", "enum": ["status", "kill", "result"], "description": "The management action to perform" }
207            },
208            "required": ["session_id", "action"],
209            "additionalProperties": false
210        }))
211    }
212
213    async fn execute(&self, input: &str, _ctx: &ToolContext) -> anyhow::Result<ToolResult> {
214        let req: SubAgentManageInput = serde_json::from_str(input).context(
215            "subagent_manage expects JSON: {\"session_id\": \"...\", \"action\": \"...\"}",
216        )?;
217
218        if req.session_id.trim().is_empty() {
219            return Err(anyhow!("session_id must not be empty"));
220        }
221
222        Ok(ToolResult {
223            output: format!(
224                "subagent_manage: action={:?} for session {}. Standalone mode — use shared registry for full functionality.",
225                req.action, req.session_id
226            ),
227        })
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[tokio::test]
236    async fn subagent_spawn_creates_session() {
237        let tool = SubAgentSpawnTool::default();
238        let result = tool
239            .execute(
240                r#"{"agent": "researcher", "task": "find info"}"#,
241                &ToolContext::new(".".to_string()),
242            )
243            .await
244            .expect("spawn should succeed");
245        assert!(result.output.contains("spawned subagent"));
246        assert!(result.output.contains("researcher"));
247    }
248
249    #[tokio::test]
250    async fn subagent_spawn_rejects_empty_agent() {
251        let tool = SubAgentSpawnTool::default();
252        let err = tool
253            .execute(
254                r#"{"agent": "", "task": "find info"}"#,
255                &ToolContext::new(".".to_string()),
256            )
257            .await
258            .expect_err("empty agent should fail");
259        assert!(err.to_string().contains("agent must not be empty"));
260    }
261
262    #[tokio::test]
263    async fn subagent_list_standalone() {
264        let tool = SubAgentListTool;
265        let result = tool
266            .execute("{}", &ToolContext::new(".".to_string()))
267            .await
268            .expect("list should succeed");
269        assert!(result.output.contains("subagent_list"));
270    }
271
272    #[tokio::test]
273    async fn subagent_manage_standalone() {
274        let tool = SubAgentManageTool;
275        let result = tool
276            .execute(
277                r#"{"session_id": "sa-0-123", "action": "status"}"#,
278                &ToolContext::new(".".to_string()),
279            )
280            .await
281            .expect("manage should succeed");
282        assert!(result.output.contains("subagent_manage"));
283    }
284}