Skip to main content

pawan/tools/
agent.rs

1//! Sub-agent spawning tool
2//!
3//! Spawns a pawan subprocess to handle a task independently.
4//! This is the OMO replacement — enables multi-agent orchestration.
5
6use super::Tool;
7use crate::{PawanError, Result};
8use async_trait::async_trait;
9use serde_json::{json, Value};
10use std::io::Write;
11use std::path::PathBuf;
12use std::process::Stdio;
13use tokio::io::AsyncReadExt;
14use tokio::process::Command;
15use tracing;
16
17use crate::subagent::SubagentHandle;
18
19/// Tool for spawning a sub-agent (pawan subprocess)
20pub struct SpawnAgentTool {
21    workspace_root: PathBuf,
22}
23
24impl SpawnAgentTool {
25    pub fn new(workspace_root: PathBuf) -> Self {
26        Self { workspace_root }
27    }
28
29    /// Find the pawan binary — tries cargo target first, then PATH
30    fn find_pawan_binary(&self) -> String {
31        // Check for debug/release binary in workspace target
32        for candidate in &[
33            self.workspace_root.join("target/release/pawan"),
34            self.workspace_root.join("target/debug/pawan"),
35        ] {
36            if candidate.exists() {
37                return candidate.to_string_lossy().to_string();
38            }
39        }
40        // Fall back to PATH
41        "pawan".to_string()
42    }
43}
44
45#[async_trait]
46impl Tool for SpawnAgentTool {
47    fn name(&self) -> &str {
48        "spawn_agent"
49    }
50
51    fn description(&self) -> &str {
52        "Spawn a sub-agent (pawan subprocess) to handle a task independently. \
53         Returns the agent's response as JSON. Use this for parallel or delegated tasks."
54    }
55
56    fn mutating(&self) -> bool {
57        true // Spawning agents can mutate state
58    }
59
60    fn parameters_schema(&self) -> Value {
61        json!({
62            "type": "object",
63            "properties": {
64                "prompt": {
65                    "type": "string",
66                    "description": "The task/prompt for the sub-agent"
67                },
68                "model": {
69                    "type": "string",
70                    "description": "Model to use (optional, defaults to parent's model)"
71                },
72                "timeout": {
73                    "type": "integer",
74                    "description": "Timeout in seconds (default: 120)"
75                },
76                "workspace": {
77                    "type": "string",
78                    "description": "Workspace directory for the sub-agent (default: same as parent)"
79                },
80                "retries": {
81                    "type": "integer",
82                    "description": "Number of retry attempts on failure (default: 0, max: 2)"
83                }
84            },
85            "required": ["prompt"]
86        })
87    }
88
89    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
90        use thulp_core::{Parameter, ParameterType};
91        thulp_core::ToolDefinition::builder("spawn_agent")
92            .description(self.description())
93            .parameter(
94                Parameter::builder("prompt")
95                    .param_type(ParameterType::String)
96                    .required(true)
97                    .description("The task/prompt for the sub-agent")
98                    .build(),
99            )
100            .parameter(
101                Parameter::builder("model")
102                    .param_type(ParameterType::String)
103                    .required(false)
104                    .description("Model to use (optional, defaults to parent's model)")
105                    .build(),
106            )
107            .parameter(
108                Parameter::builder("timeout")
109                    .param_type(ParameterType::Integer)
110                    .required(false)
111                    .description("Timeout in seconds (default: 120)")
112                    .build(),
113            )
114            .parameter(
115                Parameter::builder("workspace")
116                    .param_type(ParameterType::String)
117                    .required(false)
118                    .description("Workspace directory for the sub-agent (default: same as parent)")
119                    .build(),
120            )
121            .parameter(
122                Parameter::builder("retries")
123                    .param_type(ParameterType::Integer)
124                    .required(false)
125                    .description("Number of retry attempts on failure (default: 0, max: 2)")
126                    .build(),
127            )
128            .build()
129    }
130
131    async fn execute(&self, args: Value) -> Result<Value> {
132        let prompt = args["prompt"]
133            .as_str()
134            .ok_or_else(|| PawanError::Tool("prompt is required for spawn_agent".into()))?;
135
136        let timeout = args["timeout"].as_u64().unwrap_or(120);
137        let model = args["model"].as_str();
138        let workspace = args["workspace"]
139            .as_str()
140            .map(PathBuf::from)
141            .unwrap_or_else(|| self.workspace_root.clone());
142        let max_retries = args["retries"].as_u64().unwrap_or(0).min(2) as usize;
143
144        // Generate unique agent ID for progress tracking
145        let label: String = prompt
146            .lines()
147            .next()
148            .unwrap_or(prompt)
149            .chars()
150            .take(48)
151            .collect();
152        let progress = SubagentHandle::start(&label, "spawn_agent", None);
153
154        let agent_id = progress.id().to_string();
155        let status_path = format!("/tmp/pawan-agent-{}.status", agent_id);
156        let started_at = chrono::Utc::now().to_rfc3339();
157
158        let pawan_bin = self.find_pawan_binary();
159
160        for attempt in 0..=max_retries {
161            let mut cmd = Command::new(&pawan_bin);
162            cmd.arg("run")
163                .arg("-o")
164                .arg("json")
165                .arg("--timeout")
166                .arg(timeout.to_string())
167                .arg("-w")
168                .arg(workspace.to_string_lossy().to_string());
169
170            if let Some(m) = model {
171                cmd.arg("-m").arg(m);
172            }
173
174            cmd.arg(prompt);
175
176            cmd.stdout(Stdio::piped())
177                .stderr(Stdio::piped())
178                .stdin(Stdio::null());
179
180            // Write initial status
181            if let Ok(mut f) = std::fs::File::create(&status_path) {
182                let _ = write!(
183                    f,
184                    r#"{{"state":"running","prompt":"{}","started_at":"{}","attempt":{}}}"#,
185                    prompt
186                        .chars()
187                        .take(100)
188                        .collect::<String>()
189                        .replace('"', "'"),
190                    started_at,
191                    attempt + 1
192                );
193            }
194
195            let mut child = match cmd.spawn() {
196                Ok(c) => c,
197                Err(e) => {
198                    progress.complete_err(format!("spawn failed: {e}"));
199                    progress.dismiss();
200                    return Err(PawanError::Tool(format!(
201                        "Failed to spawn sub-agent: {e}. Binary: {pawan_bin}"
202                    )));
203                }
204            };
205
206            let mut stdout = String::new();
207            let mut stderr = String::new();
208
209            if let Some(mut handle) = child.stdout.take() {
210                handle.read_to_string(&mut stdout).await.ok();
211            }
212            if let Some(mut handle) = child.stderr.take() {
213                handle.read_to_string(&mut stderr).await.ok();
214            }
215
216            let status = match child.wait().await {
217                Ok(s) => s,
218                Err(e) => {
219                    progress.complete_err(format!("wait failed: {e}"));
220                    progress.dismiss();
221                    return Err(PawanError::Io(e));
222                }
223            };
224
225            let result = if let Ok(json_result) = serde_json::from_str::<Value>(&stdout) {
226                json_result
227            } else {
228                json!({
229                    "content": stdout.trim(),
230                    "raw_output": true
231                })
232            };
233
234            if status.success() || attempt == max_retries {
235                // Update status file with completion
236                let duration_ms = chrono::Utc::now()
237                    .signed_duration_since(
238                        chrono::DateTime::parse_from_rfc3339(&started_at).unwrap_or_default(),
239                    )
240                    .num_milliseconds();
241                if let Ok(mut f) = std::fs::File::create(&status_path) {
242                    let state = if status.success() { "done" } else { "failed" };
243                    let _ = write!(
244                        f,
245                        r#"{{"state":"{}","exit_code":{},"duration_ms":{},"attempt":{}}}"#,
246                        state,
247                        status.code().unwrap_or(-1),
248                        duration_ms,
249                        attempt + 1
250                    );
251                }
252
253                if status.success() {
254                    progress.complete_ok();
255                } else {
256                    progress.complete_err(format!(
257                        "exit code {}",
258                        status.code().unwrap_or(-1)
259                    ));
260                }
261                let out = json!({
262                    "success": status.success(),
263                    "attempt": attempt + 1,
264                    "total_attempts": attempt + 1,
265                    "result": result,
266                    "stderr": stderr.trim(),
267                    "subagent_id": progress.id(),
268                    "duration_ms": duration_ms,
269                });
270                progress.dismiss();
271                return Ok(out);
272            }
273            // Failed but retries remaining — continue loop
274            // Failed but retries remaining — continue loop
275            tracing::warn!(
276                attempt = attempt + 1,
277                "spawn_agent attempt failed, retrying"
278            );
279        }
280
281        progress.complete_err("all retry attempts exhausted");
282        progress.dismiss();
283        Err(PawanError::Tool(
284            "spawn_agent: all retry attempts exhausted".into(),
285        ))
286    }
287}
288
289/// Tool for spawning multiple sub-agents in parallel
290pub struct SpawnAgentsTool {
291    workspace_root: PathBuf,
292}
293
294impl SpawnAgentsTool {
295    pub fn new(workspace_root: PathBuf) -> Self {
296        Self { workspace_root }
297    }
298}
299
300#[async_trait]
301impl Tool for SpawnAgentsTool {
302    fn name(&self) -> &str {
303        "spawn_agents"
304    }
305
306    fn description(&self) -> &str {
307        "Spawn multiple sub-agents in parallel. Each task runs concurrently and results are returned as an array."
308    }
309
310    fn parameters_schema(&self) -> Value {
311        json!({
312            "type": "object",
313            "properties": {
314                "tasks": {
315                    "type": "array",
316                    "items": {
317                        "type": "object",
318                        "properties": {
319                            "prompt": {"type": "string"},
320                            "model": {"type": "string"},
321                            "timeout": {"type": "integer"},
322                            "workspace": {"type": "string"}
323                        },
324                        "required": ["prompt"]
325                    }
326                }
327            },
328            "required": ["tasks"]
329        })
330    }
331
332    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
333        use thulp_core::{Parameter, ParameterType};
334        thulp_core::ToolDefinition::builder("spawn_agents")
335            .description(self.description())
336            .parameter(Parameter::builder("tasks").param_type(ParameterType::Array).required(true)
337                .description("Array of task objects, each with prompt (required), model, timeout, workspace").build())
338            .build()
339    }
340
341    fn mutating(&self) -> bool {
342        true // Spawning agents can mutate state
343    }
344
345    async fn execute(&self, args: Value) -> Result<Value> {
346        let tasks = args["tasks"]
347            .as_array()
348            .ok_or_else(|| PawanError::Tool("tasks array is required for spawn_agents".into()))?;
349
350        for (index, task) in tasks.iter().enumerate() {
351            if task.get("prompt").and_then(|p| p.as_str()).is_none() {
352                return Err(PawanError::Tool(format!(
353                    "spawn_agents: task {index} is missing prompt"
354                )));
355            }
356        }
357
358        let single_tool = SpawnAgentTool::new(self.workspace_root.clone());
359
360        let futures: Vec<_> = tasks
361            .iter()
362            .enumerate()
363            .map(|(index, task)| {
364                let tool = &single_tool;
365                let task = task.clone();
366                async move {
367                    let started = std::time::Instant::now();
368                    let result = tool.execute(task).await;
369                    (index, started.elapsed().as_millis() as u64, result)
370                }
371            })
372            .collect();
373
374        let results = futures::future::join_all(futures).await;
375
376        let mut succeeded = 0usize;
377        let mut failed = 0usize;
378        let mut output: Vec<Value> = Vec::with_capacity(results.len());
379
380        for (index, duration_ms, result) in results {
381            match result {
382                Ok(mut v) => {
383                    if v.get("success").and_then(|s| s.as_bool()) == Some(true) {
384                        succeeded += 1;
385                    } else {
386                        failed += 1;
387                    }
388                    if let Some(obj) = v.as_object_mut() {
389                        obj.insert("index".into(), json!(index));
390                        obj.insert("duration_ms".into(), json!(duration_ms));
391                    }
392                    output.push(v);
393                }
394                Err(e) => {
395                    failed += 1;
396                    output.push(json!({
397                        "index": index,
398                        "success": false,
399                        "error": e.to_string(),
400                        "duration_ms": duration_ms,
401                    }));
402                }
403            }
404        }
405
406        let total = tasks.len();
407        Ok(json!({
408            "success": failed == 0,
409            "total_tasks": total,
410            "succeeded": succeeded,
411            "failed": failed,
412            "results": output,
413        }))
414    }
415}
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use tempfile::TempDir;
420    #[test]
421    fn test_spawn_agent_tool_name() {
422        let tmp = TempDir::new().unwrap();
423        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
424        assert_eq!(tool.name(), "spawn_agent");
425    }
426
427    #[test]
428    fn test_spawn_agents_tool_name() {
429        let tmp = TempDir::new().unwrap();
430        let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
431        assert_eq!(tool.name(), "spawn_agents");
432    }
433
434    #[test]
435    fn test_spawn_agent_schema_has_prompt() {
436        let tmp = TempDir::new().unwrap();
437        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
438        let schema = tool.parameters_schema();
439        assert!(schema["properties"]["prompt"].is_object());
440        assert!(schema["required"]
441            .as_array()
442            .unwrap()
443            .iter()
444            .any(|v| v == "prompt"));
445    }
446
447    #[test]
448    fn test_find_pawan_binary_prefers_release_over_debug() {
449        let tmp = TempDir::new().unwrap();
450        // Create both release and debug pawan binaries
451        std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
452        std::fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
453        let release = tmp.path().join("target/release/pawan");
454        let debug = tmp.path().join("target/debug/pawan");
455        std::fs::write(&release, "#!/bin/sh\necho release").unwrap();
456        std::fs::write(&debug, "#!/bin/sh\necho debug").unwrap();
457
458        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
459        let binary = tool.find_pawan_binary();
460        assert_eq!(
461            binary,
462            release.to_string_lossy().to_string(),
463            "release binary must win over debug"
464        );
465    }
466
467    #[test]
468    fn test_find_pawan_binary_falls_back_to_debug_when_no_release() {
469        let tmp = TempDir::new().unwrap();
470        std::fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
471        let debug = tmp.path().join("target/debug/pawan");
472        std::fs::write(&debug, "#!/bin/sh\necho debug").unwrap();
473
474        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
475        let binary = tool.find_pawan_binary();
476        assert_eq!(binary, debug.to_string_lossy().to_string());
477    }
478
479    #[test]
480    fn test_find_pawan_binary_falls_through_to_path_when_nothing_in_workspace() {
481        let tmp = TempDir::new().unwrap();
482        // No target/ at all
483        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
484        let binary = tool.find_pawan_binary();
485        // Falls back to bare "pawan" name (will be resolved via PATH at exec time)
486        assert_eq!(binary, "pawan");
487    }
488
489    #[tokio::test]
490    async fn test_spawn_agent_missing_prompt_errors() {
491        let tmp = TempDir::new().unwrap();
492        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
493        // No "prompt" field in args
494        let result = tool.execute(json!({ "model": "test-model" })).await;
495        assert!(result.is_err(), "missing prompt must error");
496        let err = format!("{}", result.unwrap_err());
497        assert!(
498            err.contains("prompt"),
499            "error message should mention prompt, got: {}",
500            err
501        );
502    }
503
504    #[test]
505    fn test_spawn_agents_schema_requires_tasks_array() {
506        let tmp = TempDir::new().unwrap();
507        let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
508        let schema = tool.parameters_schema();
509        let required = schema["required"].as_array().unwrap();
510        assert!(
511            required.iter().any(|v| v == "tasks"),
512            "tasks must be required"
513        );
514        // tasks should be declared as an array type with an items.required = [prompt]
515        let tasks_type = schema["properties"]["tasks"]["type"].as_str();
516        assert_eq!(tasks_type, Some("array"));
517    }
518
519    #[tokio::test]
520    async fn test_spawn_agents_empty_tasks_succeeds_with_zero_results() {
521        let tmp = TempDir::new().unwrap();
522        let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
523        let result = tool.execute(json!({ "tasks": [] })).await.unwrap();
524        assert_eq!(result["success"], true);
525        assert_eq!(result["total_tasks"], 0);
526        assert_eq!(result["results"].as_array().unwrap().len(), 0);
527    }
528
529    #[tokio::test]
530    async fn test_spawn_agents_missing_tasks_errors() {
531        let tmp = TempDir::new().unwrap();
532        let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
533        // tasks field absent entirely
534        let result = tool.execute(json!({})).await;
535        assert!(result.is_err());
536        let err = format!("{}", result.unwrap_err());
537        assert!(err.contains("tasks"));
538    }
539
540    #[tokio::test]
541    async fn test_spawn_agent_prompt_non_string_errors() {
542        // Prompt present but as integer — `.as_str()` returns None so
543        // ok_or_else must fire. Guards against a refactor that silently
544        // coerces numeric prompts (which would then panic or send garbage
545        // to the pawan run subcommand).
546        let tmp = TempDir::new().unwrap();
547        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
548        let result = tool.execute(json!({ "prompt": 42 })).await;
549        assert!(result.is_err(), "non-string prompt must error");
550        let err = format!("{}", result.unwrap_err());
551        assert!(
552            err.contains("prompt"),
553            "error should mention 'prompt', got: {}",
554            err
555        );
556    }
557
558    #[tokio::test]
559    async fn test_spawn_agents_tasks_non_array_errors() {
560        // tasks present but as string — `.as_array()` returns None so
561        // ok_or_else must fire. Prevents silent coercion.
562        let tmp = TempDir::new().unwrap();
563        let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
564        let result = tool.execute(json!({ "tasks": "not an array" })).await;
565        assert!(result.is_err(), "non-array tasks must error");
566        let err = format!("{}", result.unwrap_err());
567        assert!(
568            err.contains("tasks"),
569            "error should mention 'tasks', got: {}",
570            err
571        );
572    }
573
574    #[test]
575    fn test_spawn_agent_schema_lists_all_optional_params() {
576        // All 5 advertised parameters must be declared as schema properties.
577        // If someone adds a new one without updating the schema, consumers
578        // that introspect parameters_schema() will miss it.
579        let tmp = TempDir::new().unwrap();
580        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
581        let schema = tool.parameters_schema();
582        let props = schema["properties"].as_object().unwrap();
583        for p in &["prompt", "model", "timeout", "workspace", "retries"] {
584            assert!(props.contains_key(*p), "schema missing '{}'", p);
585        }
586        // Only prompt is required
587        let required = schema["required"].as_array().unwrap();
588        assert_eq!(required.len(), 1);
589        assert_eq!(required[0], "prompt");
590    }
591
592    #[test]
593    fn test_spawn_agents_schema_tasks_items_has_prompt_required() {
594        // The nested items schema inside tasks must mark prompt required —
595        // otherwise the array can hold task objects missing the prompt
596        // field, and each sub-execute() would error one-by-one instead of
597        // validating up front.
598        let tmp = TempDir::new().unwrap();
599        let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
600        let schema = tool.parameters_schema();
601        let items_required = schema["properties"]["tasks"]["items"]["required"]
602            .as_array()
603            .expect("tasks.items.required should exist");
604        assert!(items_required.iter().any(|v| v == "prompt"));
605    }
606
607    #[test]
608    fn test_spawn_agent_thulp_definition_has_all_5_params() {
609        // thulp_definition() must mirror parameters_schema() — if they drift,
610        // thulp-registry callers will see a different API than MCP callers.
611        let tmp = TempDir::new().unwrap();
612        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
613        let def = tool.thulp_definition();
614        assert_eq!(def.name, "spawn_agent");
615        let param_names: Vec<&str> = def.parameters.iter().map(|p| p.name.as_str()).collect();
616        for p in &["prompt", "model", "timeout", "workspace", "retries"] {
617            assert!(param_names.contains(p), "thulp definition missing '{}'", p);
618        }
619        // Only prompt is required
620        let required_count = def.parameters.iter().filter(|p| p.required).count();
621        assert_eq!(required_count, 1, "only prompt should be required");
622    }
623
624    #[test]
625    fn test_spawn_agents_thulp_definition_has_tasks_param() {
626        // spawn_agents' thulp definition should declare exactly one required
627        // parameter: tasks (an array). If this drifts, parallel-agent callers
628        // get confused schemas.
629        let tmp = TempDir::new().unwrap();
630        let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
631        let def = tool.thulp_definition();
632        assert_eq!(def.name, "spawn_agents");
633        assert_eq!(def.parameters.len(), 1);
634        let tasks_param = &def.parameters[0];
635        assert_eq!(tasks_param.name, "tasks");
636        assert!(tasks_param.required);
637    }
638
639    #[test]
640    fn test_spawn_agent_mutating_returns_true() {
641        let tmp = TempDir::new().unwrap();
642        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
643        assert!(tool.mutating(), "spawn_agent can mutate state");
644    }
645
646    #[test]
647    fn test_spawn_agents_mutating_returns_true() {
648        let tmp = TempDir::new().unwrap();
649        let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
650        assert!(tool.mutating(), "spawn_agents can mutate state");
651    }
652
653    #[test]
654    fn test_spawn_agent_description_non_empty() {
655        let tmp = TempDir::new().unwrap();
656        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
657        let desc = tool.description();
658        assert!(!desc.is_empty());
659        assert!(desc.contains("sub-agent"));
660    }
661
662    #[test]
663    fn test_spawn_agents_description_non_empty() {
664        let tmp = TempDir::new().unwrap();
665        let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
666        let desc = tool.description();
667        assert!(!desc.is_empty());
668        assert!(desc.contains("parallel"));
669    }
670
671    #[tokio::test]
672    async fn test_spawn_agent_timeout_defaults_to_120() {
673        let tmp = TempDir::new().unwrap();
674        std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
675        let binary = tmp.path().join("target/release/pawan");
676        std::fs::write(
677            &binary,
678            r"#!/bin/sh
679exit 0",
680        )
681        .unwrap();
682        #[cfg(unix)]
683        std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
684            .unwrap();
685
686        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
687        let result = tool.execute(json!({"prompt": "test"})).await.unwrap();
688        assert_eq!(result["success"], true);
689    }
690
691    #[tokio::test]
692    async fn test_spawn_agent_custom_timeout() {
693        let tmp = TempDir::new().unwrap();
694        std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
695        let binary = tmp.path().join("target/release/pawan");
696        std::fs::write(
697            &binary,
698            r"#!/bin/sh
699exit 0",
700        )
701        .unwrap();
702        #[cfg(unix)]
703        std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
704            .unwrap();
705
706        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
707        let result = tool
708            .execute(json!({"prompt": "test", "timeout": 60}))
709            .await
710            .unwrap();
711        assert_eq!(result["success"], true);
712    }
713
714    #[tokio::test]
715    async fn test_spawn_agent_custom_model() {
716        let tmp = TempDir::new().unwrap();
717        std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
718        let binary = tmp.path().join("target/release/pawan");
719        std::fs::write(&binary, "#!/bin/sh\necho '{\"content\":\"test response\"}'").unwrap();
720        #[cfg(unix)]
721        std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
722            .unwrap();
723
724        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
725        let result = tool
726            .execute(json!({"prompt": "test", "model": "gpt-4"}))
727            .await
728            .unwrap();
729        assert_eq!(result["success"], true);
730        assert_eq!(result["result"]["content"], "test response");
731    }
732
733    #[tokio::test]
734    async fn test_spawn_agent_retries_on_failure() {
735        let tmp = TempDir::new().unwrap();
736        std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
737        let binary = tmp.path().join("target/release/pawan");
738        let counter_file = tmp.path().join("counter");
739        std::fs::write(&counter_file, "0").unwrap();
740        let script = format!(
741            "#!/bin/sh\ncount=$(cat {})\necho $((count + 1)) > {}\nif [ $count -eq 0 ]; then\n    exit 1\nelse\n    exit 0\nfi",
742            counter_file.display(), counter_file.display()
743        );
744        std::fs::write(&binary, script).unwrap();
745        #[cfg(unix)]
746        std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
747            .unwrap();
748
749        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
750        let result = tool
751            .execute(json!({
752                "prompt": "test",
753                "retries": 1
754            }))
755            .await
756            .unwrap();
757        assert_eq!(result["success"], true);
758        assert_eq!(result["attempt"], 2);
759        assert_eq!(result["total_attempts"], 2);
760    }
761
762    #[tokio::test]
763    async fn test_spawn_agent_stderr_captured() {
764        let tmp = TempDir::new().unwrap();
765        std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
766        let binary = tmp.path().join("target/release/pawan");
767        std::fs::write(
768            &binary,
769            r"#!/bin/sh
770echo 'error message' >&2
771exit 0",
772        )
773        .unwrap();
774        #[cfg(unix)]
775        std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
776            .unwrap();
777
778        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
779        let result = tool.execute(json!({"prompt": "test"})).await.unwrap();
780        assert_eq!(result["success"], true);
781        assert_eq!(result["stderr"], "error message");
782    }
783
784    #[serial_test::serial(pawan_session_tests)]
785    #[tokio::test]
786    async fn test_spawn_agents_single_task() {
787        let tmp = TempDir::new().unwrap();
788        std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
789        let binary = tmp.path().join("target/release/pawan");
790        std::fs::write(&binary, "#!/bin/sh\necho '{\"result\":\"done\"}'").unwrap();
791        #[cfg(unix)]
792        std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
793            .unwrap();
794
795        let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
796        let result = tool
797            .execute(json!({
798                "tasks": [
799                    {"prompt": "task1"}
800                ]
801            }))
802            .await
803            .unwrap();
804        assert_eq!(result["success"], true);
805        assert_eq!(result["total_tasks"], 1);
806        assert_eq!(result["results"].as_array().unwrap().len(), 1);
807        assert_eq!(result["results"][0]["result"]["result"], "done");
808    }
809
810    #[serial_test::serial(pawan_session_tests)]
811    #[tokio::test]
812    async fn test_spawn_agents_multiple_tasks() {
813        let tmp = TempDir::new().unwrap();
814        std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
815        let binary = tmp.path().join("target/release/pawan");
816        std::fs::write(&binary, "#!/bin/sh\necho '{\"result\":\"done\"}'").unwrap();
817        #[cfg(unix)]
818        std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
819            .unwrap();
820
821        let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
822        let result = tool
823            .execute(json!({
824                "tasks": [
825                    {"prompt": "task1"},
826                    {"prompt": "task2"},
827                    {"prompt": "task3"}
828                ]
829            }))
830            .await
831            .unwrap();
832        assert_eq!(result["success"], true);
833        assert_eq!(result["total_tasks"], 3);
834        assert_eq!(result["results"].as_array().unwrap().len(), 3);
835    }
836
837    #[tokio::test]
838    async fn test_spawn_agents_task_missing_prompt() {
839        let tmp = TempDir::new().unwrap();
840        let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
841        let result = tool
842            .execute(json!({
843                "tasks": [{"model": "gpt-4"}]
844            }))
845            .await;
846        // Upfront validation now rejects tasks missing a prompt
847        assert!(result.is_err());
848        let err = result.unwrap_err();
849        assert!(err.to_string().contains("missing prompt"));
850    }
851}