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#[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#[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#[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}