1use super::Tool;
7use crate::{PawanError, Result};
8use async_trait::async_trait;
9use serde_json::{json, Value};
10use std::path::PathBuf;
11use std::process::Stdio;
12use tokio::io::AsyncReadExt;
13use tokio::process::Command;
14use std::io::Write;
15use tracing;
16
17pub struct SpawnAgentTool {
19 workspace_root: PathBuf,
20}
21
22impl SpawnAgentTool {
23 pub fn new(workspace_root: PathBuf) -> Self {
24 Self { workspace_root }
25 }
26
27 fn find_pawan_binary(&self) -> String {
29 for candidate in &[
31 self.workspace_root.join("target/release/pawan"),
32 self.workspace_root.join("target/debug/pawan"),
33 ] {
34 if candidate.exists() {
35 return candidate.to_string_lossy().to_string();
36 }
37 }
38 "pawan".to_string()
40 }
41}
42
43#[async_trait]
44impl Tool for SpawnAgentTool {
45 fn name(&self) -> &str {
46 "spawn_agent"
47 }
48
49 fn description(&self) -> &str {
50 "Spawn a sub-agent (pawan subprocess) to handle a task independently. \
51 Returns the agent's response as JSON. Use this for parallel or delegated tasks."
52 }
53
54 fn mutating(&self) -> bool {
55 true }
57
58 fn parameters_schema(&self) -> Value {
59 json!({
60 "type": "object",
61 "properties": {
62 "prompt": {
63 "type": "string",
64 "description": "The task/prompt for the sub-agent"
65 },
66 "model": {
67 "type": "string",
68 "description": "Model to use (optional, defaults to parent's model)"
69 },
70 "timeout": {
71 "type": "integer",
72 "description": "Timeout in seconds (default: 120)"
73 },
74 "workspace": {
75 "type": "string",
76 "description": "Workspace directory for the sub-agent (default: same as parent)"
77 },
78 "retries": {
79 "type": "integer",
80 "description": "Number of retry attempts on failure (default: 0, max: 2)"
81 }
82 },
83 "required": ["prompt"]
84 })
85 }
86
87 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
88 use thulp_core::{Parameter, ParameterType};
89 thulp_core::ToolDefinition::builder("spawn_agent")
90 .description(self.description())
91 .parameter(Parameter::builder("prompt").param_type(ParameterType::String).required(true)
92 .description("The task/prompt for the sub-agent").build())
93 .parameter(Parameter::builder("model").param_type(ParameterType::String).required(false)
94 .description("Model to use (optional, defaults to parent's model)").build())
95 .parameter(Parameter::builder("timeout").param_type(ParameterType::Integer).required(false)
96 .description("Timeout in seconds (default: 120)").build())
97 .parameter(Parameter::builder("workspace").param_type(ParameterType::String).required(false)
98 .description("Workspace directory for the sub-agent (default: same as parent)").build())
99 .parameter(Parameter::builder("retries").param_type(ParameterType::Integer).required(false)
100 .description("Number of retry attempts on failure (default: 0, max: 2)").build())
101 .build()
102 }
103
104 async fn execute(&self, args: Value) -> Result<Value> {
105 let prompt = args["prompt"]
106 .as_str()
107 .ok_or_else(|| PawanError::Tool("prompt is required for spawn_agent".into()))?;
108
109 let timeout = args["timeout"].as_u64().unwrap_or(120);
110 let model = args["model"].as_str();
111 let workspace = args["workspace"]
112 .as_str()
113 .map(PathBuf::from)
114 .unwrap_or_else(|| self.workspace_root.clone());
115 let max_retries = args["retries"].as_u64().unwrap_or(0).min(2) as usize;
116
117 let agent_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
119 let status_path = format!("/tmp/pawan-agent-{}.status", agent_id);
120 let started_at = chrono::Utc::now().to_rfc3339();
121
122 let pawan_bin = self.find_pawan_binary();
123
124 for attempt in 0..=max_retries {
125 let mut cmd = Command::new(&pawan_bin);
126 cmd.arg("run")
127 .arg("-o")
128 .arg("json")
129 .arg("--timeout")
130 .arg(timeout.to_string())
131 .arg("-w")
132 .arg(workspace.to_string_lossy().to_string());
133
134 if let Some(m) = model {
135 cmd.arg("-m").arg(m);
136 }
137
138 cmd.arg(prompt);
139
140 cmd.stdout(Stdio::piped())
141 .stderr(Stdio::piped())
142 .stdin(Stdio::null());
143
144 if let Ok(mut f) = std::fs::File::create(&status_path) {
146 let _ = write!(f, r#"{{"state":"running","prompt":"{}","started_at":"{}","attempt":{}}}"#,
147 prompt.chars().take(100).collect::<String>().replace('"', "'"), started_at, attempt + 1);
148 }
149
150 let mut child = cmd.spawn().map_err(|e| {
151 PawanError::Tool(format!(
152 "Failed to spawn sub-agent: {}. Binary: {}",
153 e, pawan_bin
154 ))
155 })?;
156
157 let mut stdout = String::new();
158 let mut stderr = String::new();
159
160 if let Some(mut handle) = child.stdout.take() {
161 handle.read_to_string(&mut stdout).await.ok();
162 }
163 if let Some(mut handle) = child.stderr.take() {
164 handle.read_to_string(&mut stderr).await.ok();
165 }
166
167 let status = child.wait().await.map_err(PawanError::Io)?;
168
169 let result = if let Ok(json_result) = serde_json::from_str::<Value>(&stdout) {
170 json_result
171 } else {
172 json!({
173 "content": stdout.trim(),
174 "raw_output": true
175 })
176 };
177
178 if status.success() || attempt == max_retries {
179 let duration_ms = chrono::Utc::now().signed_duration_since(chrono::DateTime::parse_from_rfc3339(&started_at).unwrap_or_default()).num_milliseconds();
181 if let Ok(mut f) = std::fs::File::create(&status_path) {
182 let state = if status.success() { "done" } else { "failed" };
183 let _ = write!(f, r#"{{"state":"{}","exit_code":{},"duration_ms":{},"attempt":{}}}"#,
184 state, status.code().unwrap_or(-1), duration_ms, attempt + 1);
185 }
186
187 return Ok(json!({
188 "success": status.success(),
189 "attempt": attempt + 1,
190 "total_attempts": attempt + 1,
191 "result": result,
192 "stderr": stderr.trim(),
193 }));
194 }
195 tracing::warn!(attempt = attempt + 1, "spawn_agent attempt failed, retrying");
198 }
199
200 Err(PawanError::Tool("spawn_agent: all retry attempts exhausted".into()))
202 }
203}
204
205pub struct SpawnAgentsTool {
207 workspace_root: PathBuf,
208}
209
210impl SpawnAgentsTool {
211 pub fn new(workspace_root: PathBuf) -> Self {
212 Self { workspace_root }
213 }
214}
215
216#[async_trait]
217impl Tool for SpawnAgentsTool {
218 fn name(&self) -> &str {
219 "spawn_agents"
220 }
221
222 fn description(&self) -> &str {
223 "Spawn multiple sub-agents in parallel. Each task runs concurrently and results are returned as an array."
224 }
225
226 fn parameters_schema(&self) -> Value {
227 json!({
228 "type": "object",
229 "properties": {
230 "tasks": {
231 "type": "array",
232 "items": {
233 "type": "object",
234 "properties": {
235 "prompt": {"type": "string"},
236 "model": {"type": "string"},
237 "timeout": {"type": "integer"},
238 "workspace": {"type": "string"}
239 },
240 "required": ["prompt"]
241 }
242 }
243 },
244 "required": ["tasks"]
245 })
246 }
247
248 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
249 use thulp_core::{Parameter, ParameterType};
250 thulp_core::ToolDefinition::builder("spawn_agents")
251 .description(self.description())
252 .parameter(Parameter::builder("tasks").param_type(ParameterType::Array).required(true)
253 .description("Array of task objects, each with prompt (required), model, timeout, workspace").build())
254 .build()
255 }
256
257 async fn execute(&self, args: Value) -> Result<Value> {
258 let tasks = args["tasks"]
259 .as_array()
260 .ok_or_else(|| PawanError::Tool("tasks array is required for spawn_agents".into()))?;
261
262 let single_tool = SpawnAgentTool::new(self.workspace_root.clone());
263
264 let futures: Vec<_> = tasks
265 .iter()
266 .map(|task| single_tool.execute(task.clone()))
267 .collect();
268
269 let results = futures::future::join_all(futures).await;
270
271 let output: Vec<Value> = results
272 .into_iter()
273 .map(|r| match r {
274 Ok(v) => v,
275 Err(e) => json!({"success": false, "error": e.to_string()}),
276 })
277 .collect();
278
279 Ok(json!({
280 "success": true,
281 "results": output,
282 "total_tasks": tasks.len(),
283 }))
284 }
285}
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use tempfile::TempDir;
290
291 #[test]
292 fn test_spawn_agent_tool_name() {
293 let tmp = TempDir::new().unwrap();
294 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
295 assert_eq!(tool.name(), "spawn_agent");
296 }
297
298 #[test]
299 fn test_spawn_agents_tool_name() {
300 let tmp = TempDir::new().unwrap();
301 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
302 assert_eq!(tool.name(), "spawn_agents");
303 }
304
305 #[test]
306 fn test_spawn_agent_schema_has_prompt() {
307 let tmp = TempDir::new().unwrap();
308 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
309 let schema = tool.parameters_schema();
310 assert!(schema["properties"]["prompt"].is_object());
311 assert!(schema["required"].as_array().unwrap().iter().any(|v| v == "prompt"));
312 }
313
314 #[test]
315 fn test_find_pawan_binary_prefers_release_over_debug() {
316 let tmp = TempDir::new().unwrap();
317 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
319 std::fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
320 let release = tmp.path().join("target/release/pawan");
321 let debug = tmp.path().join("target/debug/pawan");
322 std::fs::write(&release, "#!/bin/sh\necho release").unwrap();
323 std::fs::write(&debug, "#!/bin/sh\necho debug").unwrap();
324
325 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
326 let binary = tool.find_pawan_binary();
327 assert_eq!(
328 binary,
329 release.to_string_lossy().to_string(),
330 "release binary must win over debug"
331 );
332 }
333
334 #[test]
335 fn test_find_pawan_binary_falls_back_to_debug_when_no_release() {
336 let tmp = TempDir::new().unwrap();
337 std::fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
338 let debug = tmp.path().join("target/debug/pawan");
339 std::fs::write(&debug, "#!/bin/sh\necho debug").unwrap();
340
341 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
342 let binary = tool.find_pawan_binary();
343 assert_eq!(binary, debug.to_string_lossy().to_string());
344 }
345
346 #[test]
347 fn test_find_pawan_binary_falls_through_to_path_when_nothing_in_workspace() {
348 let tmp = TempDir::new().unwrap();
349 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
351 let binary = tool.find_pawan_binary();
352 assert_eq!(binary, "pawan");
354 }
355
356 #[tokio::test]
357 async fn test_spawn_agent_missing_prompt_errors() {
358 let tmp = TempDir::new().unwrap();
359 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
360 let result = tool.execute(json!({ "model": "test-model" })).await;
362 assert!(result.is_err(), "missing prompt must error");
363 let err = format!("{}", result.unwrap_err());
364 assert!(
365 err.contains("prompt"),
366 "error message should mention prompt, got: {}",
367 err
368 );
369 }
370
371 #[test]
372 fn test_spawn_agents_schema_requires_tasks_array() {
373 let tmp = TempDir::new().unwrap();
374 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
375 let schema = tool.parameters_schema();
376 let required = schema["required"].as_array().unwrap();
377 assert!(required.iter().any(|v| v == "tasks"), "tasks must be required");
378 let tasks_type = schema["properties"]["tasks"]["type"].as_str();
380 assert_eq!(tasks_type, Some("array"));
381 }
382
383 #[tokio::test]
384 async fn test_spawn_agents_empty_tasks_succeeds_with_zero_results() {
385 let tmp = TempDir::new().unwrap();
386 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
387 let result = tool.execute(json!({ "tasks": [] })).await.unwrap();
388 assert_eq!(result["success"], true);
389 assert_eq!(result["total_tasks"], 0);
390 assert_eq!(result["results"].as_array().unwrap().len(), 0);
391 }
392
393 #[tokio::test]
394 async fn test_spawn_agents_missing_tasks_errors() {
395 let tmp = TempDir::new().unwrap();
396 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
397 let result = tool.execute(json!({})).await;
399 assert!(result.is_err());
400 let err = format!("{}", result.unwrap_err());
401 assert!(err.contains("tasks"));
402 }
403
404 #[tokio::test]
405 async fn test_spawn_agent_prompt_non_string_errors() {
406 let tmp = TempDir::new().unwrap();
411 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
412 let result = tool.execute(json!({ "prompt": 42 })).await;
413 assert!(result.is_err(), "non-string prompt must error");
414 let err = format!("{}", result.unwrap_err());
415 assert!(err.contains("prompt"), "error should mention 'prompt', got: {}", err);
416 }
417
418 #[tokio::test]
419 async fn test_spawn_agents_tasks_non_array_errors() {
420 let tmp = TempDir::new().unwrap();
423 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
424 let result = tool.execute(json!({ "tasks": "not an array" })).await;
425 assert!(result.is_err(), "non-array tasks must error");
426 let err = format!("{}", result.unwrap_err());
427 assert!(err.contains("tasks"), "error should mention 'tasks', got: {}", err);
428 }
429
430 #[test]
431 fn test_spawn_agent_schema_lists_all_optional_params() {
432 let tmp = TempDir::new().unwrap();
436 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
437 let schema = tool.parameters_schema();
438 let props = schema["properties"].as_object().unwrap();
439 for p in &["prompt", "model", "timeout", "workspace", "retries"] {
440 assert!(props.contains_key(*p), "schema missing '{}'", p);
441 }
442 let required = schema["required"].as_array().unwrap();
444 assert_eq!(required.len(), 1);
445 assert_eq!(required[0], "prompt");
446 }
447
448 #[test]
449 fn test_spawn_agents_schema_tasks_items_has_prompt_required() {
450 let tmp = TempDir::new().unwrap();
455 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
456 let schema = tool.parameters_schema();
457 let items_required = schema["properties"]["tasks"]["items"]["required"]
458 .as_array()
459 .expect("tasks.items.required should exist");
460 assert!(items_required.iter().any(|v| v == "prompt"));
461 }
462
463 #[test]
464 fn test_spawn_agent_thulp_definition_has_all_5_params() {
465 let tmp = TempDir::new().unwrap();
468 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
469 let def = tool.thulp_definition();
470 assert_eq!(def.name, "spawn_agent");
471 let param_names: Vec<&str> = def.parameters.iter().map(|p| p.name.as_str()).collect();
472 for p in &["prompt", "model", "timeout", "workspace", "retries"] {
473 assert!(param_names.contains(p), "thulp definition missing '{}'", p);
474 }
475 let required_count = def.parameters.iter().filter(|p| p.required).count();
477 assert_eq!(required_count, 1, "only prompt should be required");
478 }
479
480 #[test]
481 fn test_spawn_agents_thulp_definition_has_tasks_param() {
482 let tmp = TempDir::new().unwrap();
486 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
487 let def = tool.thulp_definition();
488 assert_eq!(def.name, "spawn_agents");
489 assert_eq!(def.parameters.len(), 1);
490 let tasks_param = &def.parameters[0];
491 assert_eq!(tasks_param.name, "tasks");
492 assert!(tasks_param.required);
493 }
494}