1use 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
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(
92 Parameter::builder("prompt")
93 .param_type(ParameterType::String)
94 .required(true)
95 .description("The task/prompt for the sub-agent")
96 .build(),
97 )
98 .parameter(
99 Parameter::builder("model")
100 .param_type(ParameterType::String)
101 .required(false)
102 .description("Model to use (optional, defaults to parent's model)")
103 .build(),
104 )
105 .parameter(
106 Parameter::builder("timeout")
107 .param_type(ParameterType::Integer)
108 .required(false)
109 .description("Timeout in seconds (default: 120)")
110 .build(),
111 )
112 .parameter(
113 Parameter::builder("workspace")
114 .param_type(ParameterType::String)
115 .required(false)
116 .description("Workspace directory for the sub-agent (default: same as parent)")
117 .build(),
118 )
119 .parameter(
120 Parameter::builder("retries")
121 .param_type(ParameterType::Integer)
122 .required(false)
123 .description("Number of retry attempts on failure (default: 0, max: 2)")
124 .build(),
125 )
126 .build()
127 }
128
129 async fn execute(&self, args: Value) -> Result<Value> {
130 let prompt = args["prompt"]
131 .as_str()
132 .ok_or_else(|| PawanError::Tool("prompt is required for spawn_agent".into()))?;
133
134 let timeout = args["timeout"].as_u64().unwrap_or(120);
135 let model = args["model"].as_str();
136 let workspace = args["workspace"]
137 .as_str()
138 .map(PathBuf::from)
139 .unwrap_or_else(|| self.workspace_root.clone());
140 let max_retries = args["retries"].as_u64().unwrap_or(0).min(2) as usize;
141
142 let agent_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
144 let status_path = format!("/tmp/pawan-agent-{}.status", agent_id);
145 let started_at = chrono::Utc::now().to_rfc3339();
146
147 let pawan_bin = self.find_pawan_binary();
148
149 for attempt in 0..=max_retries {
150 let mut cmd = Command::new(&pawan_bin);
151 cmd.arg("run")
152 .arg("-o")
153 .arg("json")
154 .arg("--timeout")
155 .arg(timeout.to_string())
156 .arg("-w")
157 .arg(workspace.to_string_lossy().to_string());
158
159 if let Some(m) = model {
160 cmd.arg("-m").arg(m);
161 }
162
163 cmd.arg(prompt);
164
165 cmd.stdout(Stdio::piped())
166 .stderr(Stdio::piped())
167 .stdin(Stdio::null());
168
169 if let Ok(mut f) = std::fs::File::create(&status_path) {
171 let _ = write!(
172 f,
173 r#"{{"state":"running","prompt":"{}","started_at":"{}","attempt":{}}}"#,
174 prompt
175 .chars()
176 .take(100)
177 .collect::<String>()
178 .replace('"', "'"),
179 started_at,
180 attempt + 1
181 );
182 }
183
184 let mut child = cmd.spawn().map_err(|e| {
185 PawanError::Tool(format!(
186 "Failed to spawn sub-agent: {}. Binary: {}",
187 e, pawan_bin
188 ))
189 })?;
190
191 let mut stdout = String::new();
192 let mut stderr = String::new();
193
194 if let Some(mut handle) = child.stdout.take() {
195 handle.read_to_string(&mut stdout).await.ok();
196 }
197 if let Some(mut handle) = child.stderr.take() {
198 handle.read_to_string(&mut stderr).await.ok();
199 }
200
201 let status = child.wait().await.map_err(PawanError::Io)?;
202
203 let result = if let Ok(json_result) = serde_json::from_str::<Value>(&stdout) {
204 json_result
205 } else {
206 json!({
207 "content": stdout.trim(),
208 "raw_output": true
209 })
210 };
211
212 if status.success() || attempt == max_retries {
213 let duration_ms = chrono::Utc::now()
215 .signed_duration_since(
216 chrono::DateTime::parse_from_rfc3339(&started_at).unwrap_or_default(),
217 )
218 .num_milliseconds();
219 if let Ok(mut f) = std::fs::File::create(&status_path) {
220 let state = if status.success() { "done" } else { "failed" };
221 let _ = write!(
222 f,
223 r#"{{"state":"{}","exit_code":{},"duration_ms":{},"attempt":{}}}"#,
224 state,
225 status.code().unwrap_or(-1),
226 duration_ms,
227 attempt + 1
228 );
229 }
230
231 return Ok(json!({
232 "success": status.success(),
233 "attempt": attempt + 1,
234 "total_attempts": attempt + 1,
235 "result": result,
236 "stderr": stderr.trim(),
237 }));
238 }
239 tracing::warn!(
242 attempt = attempt + 1,
243 "spawn_agent attempt failed, retrying"
244 );
245 }
246
247 Err(PawanError::Tool(
249 "spawn_agent: all retry attempts exhausted".into(),
250 ))
251 }
252}
253
254pub struct SpawnAgentsTool {
256 workspace_root: PathBuf,
257}
258
259impl SpawnAgentsTool {
260 pub fn new(workspace_root: PathBuf) -> Self {
261 Self { workspace_root }
262 }
263}
264
265#[async_trait]
266impl Tool for SpawnAgentsTool {
267 fn name(&self) -> &str {
268 "spawn_agents"
269 }
270
271 fn description(&self) -> &str {
272 "Spawn multiple sub-agents in parallel. Each task runs concurrently and results are returned as an array."
273 }
274
275 fn parameters_schema(&self) -> Value {
276 json!({
277 "type": "object",
278 "properties": {
279 "tasks": {
280 "type": "array",
281 "items": {
282 "type": "object",
283 "properties": {
284 "prompt": {"type": "string"},
285 "model": {"type": "string"},
286 "timeout": {"type": "integer"},
287 "workspace": {"type": "string"}
288 },
289 "required": ["prompt"]
290 }
291 }
292 },
293 "required": ["tasks"]
294 })
295 }
296
297 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
298 use thulp_core::{Parameter, ParameterType};
299 thulp_core::ToolDefinition::builder("spawn_agents")
300 .description(self.description())
301 .parameter(Parameter::builder("tasks").param_type(ParameterType::Array).required(true)
302 .description("Array of task objects, each with prompt (required), model, timeout, workspace").build())
303 .build()
304 }
305
306 fn mutating(&self) -> bool {
307 true }
309
310 async fn execute(&self, args: Value) -> Result<Value> {
311 let tasks = args["tasks"]
312 .as_array()
313 .ok_or_else(|| PawanError::Tool("tasks array is required for spawn_agents".into()))?;
314
315 let single_tool = SpawnAgentTool::new(self.workspace_root.clone());
316
317 let futures: Vec<_> = tasks
318 .iter()
319 .map(|task| single_tool.execute(task.clone()))
320 .collect();
321
322 let results = futures::future::join_all(futures).await;
323
324 let output: Vec<Value> = results
325 .into_iter()
326 .map(|r| match r {
327 Ok(v) => v,
328 Err(e) => json!({"success": false, "error": e.to_string()}),
329 })
330 .collect();
331
332 Ok(json!({
333 "success": true,
334 "results": output,
335 "total_tasks": tasks.len(),
336 }))
337 }
338}
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use tempfile::TempDir;
343 #[test]
344 fn test_spawn_agent_tool_name() {
345 let tmp = TempDir::new().unwrap();
346 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
347 assert_eq!(tool.name(), "spawn_agent");
348 }
349
350 #[test]
351 fn test_spawn_agents_tool_name() {
352 let tmp = TempDir::new().unwrap();
353 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
354 assert_eq!(tool.name(), "spawn_agents");
355 }
356
357 #[test]
358 fn test_spawn_agent_schema_has_prompt() {
359 let tmp = TempDir::new().unwrap();
360 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
361 let schema = tool.parameters_schema();
362 assert!(schema["properties"]["prompt"].is_object());
363 assert!(schema["required"]
364 .as_array()
365 .unwrap()
366 .iter()
367 .any(|v| v == "prompt"));
368 }
369
370 #[test]
371 fn test_find_pawan_binary_prefers_release_over_debug() {
372 let tmp = TempDir::new().unwrap();
373 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
375 std::fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
376 let release = tmp.path().join("target/release/pawan");
377 let debug = tmp.path().join("target/debug/pawan");
378 std::fs::write(&release, "#!/bin/sh\necho release").unwrap();
379 std::fs::write(&debug, "#!/bin/sh\necho debug").unwrap();
380
381 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
382 let binary = tool.find_pawan_binary();
383 assert_eq!(
384 binary,
385 release.to_string_lossy().to_string(),
386 "release binary must win over debug"
387 );
388 }
389
390 #[test]
391 fn test_find_pawan_binary_falls_back_to_debug_when_no_release() {
392 let tmp = TempDir::new().unwrap();
393 std::fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
394 let debug = tmp.path().join("target/debug/pawan");
395 std::fs::write(&debug, "#!/bin/sh\necho debug").unwrap();
396
397 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
398 let binary = tool.find_pawan_binary();
399 assert_eq!(binary, debug.to_string_lossy().to_string());
400 }
401
402 #[test]
403 fn test_find_pawan_binary_falls_through_to_path_when_nothing_in_workspace() {
404 let tmp = TempDir::new().unwrap();
405 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
407 let binary = tool.find_pawan_binary();
408 assert_eq!(binary, "pawan");
410 }
411
412 #[tokio::test]
413 async fn test_spawn_agent_missing_prompt_errors() {
414 let tmp = TempDir::new().unwrap();
415 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
416 let result = tool.execute(json!({ "model": "test-model" })).await;
418 assert!(result.is_err(), "missing prompt must error");
419 let err = format!("{}", result.unwrap_err());
420 assert!(
421 err.contains("prompt"),
422 "error message should mention prompt, got: {}",
423 err
424 );
425 }
426
427 #[test]
428 fn test_spawn_agents_schema_requires_tasks_array() {
429 let tmp = TempDir::new().unwrap();
430 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
431 let schema = tool.parameters_schema();
432 let required = schema["required"].as_array().unwrap();
433 assert!(
434 required.iter().any(|v| v == "tasks"),
435 "tasks must be required"
436 );
437 let tasks_type = schema["properties"]["tasks"]["type"].as_str();
439 assert_eq!(tasks_type, Some("array"));
440 }
441
442 #[tokio::test]
443 async fn test_spawn_agents_empty_tasks_succeeds_with_zero_results() {
444 let tmp = TempDir::new().unwrap();
445 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
446 let result = tool.execute(json!({ "tasks": [] })).await.unwrap();
447 assert_eq!(result["success"], true);
448 assert_eq!(result["total_tasks"], 0);
449 assert_eq!(result["results"].as_array().unwrap().len(), 0);
450 }
451
452 #[tokio::test]
453 async fn test_spawn_agents_missing_tasks_errors() {
454 let tmp = TempDir::new().unwrap();
455 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
456 let result = tool.execute(json!({})).await;
458 assert!(result.is_err());
459 let err = format!("{}", result.unwrap_err());
460 assert!(err.contains("tasks"));
461 }
462
463 #[tokio::test]
464 async fn test_spawn_agent_prompt_non_string_errors() {
465 let tmp = TempDir::new().unwrap();
470 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
471 let result = tool.execute(json!({ "prompt": 42 })).await;
472 assert!(result.is_err(), "non-string prompt must error");
473 let err = format!("{}", result.unwrap_err());
474 assert!(
475 err.contains("prompt"),
476 "error should mention 'prompt', got: {}",
477 err
478 );
479 }
480
481 #[tokio::test]
482 async fn test_spawn_agents_tasks_non_array_errors() {
483 let tmp = TempDir::new().unwrap();
486 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
487 let result = tool.execute(json!({ "tasks": "not an array" })).await;
488 assert!(result.is_err(), "non-array tasks must error");
489 let err = format!("{}", result.unwrap_err());
490 assert!(
491 err.contains("tasks"),
492 "error should mention 'tasks', got: {}",
493 err
494 );
495 }
496
497 #[test]
498 fn test_spawn_agent_schema_lists_all_optional_params() {
499 let tmp = TempDir::new().unwrap();
503 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
504 let schema = tool.parameters_schema();
505 let props = schema["properties"].as_object().unwrap();
506 for p in &["prompt", "model", "timeout", "workspace", "retries"] {
507 assert!(props.contains_key(*p), "schema missing '{}'", p);
508 }
509 let required = schema["required"].as_array().unwrap();
511 assert_eq!(required.len(), 1);
512 assert_eq!(required[0], "prompt");
513 }
514
515 #[test]
516 fn test_spawn_agents_schema_tasks_items_has_prompt_required() {
517 let tmp = TempDir::new().unwrap();
522 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
523 let schema = tool.parameters_schema();
524 let items_required = schema["properties"]["tasks"]["items"]["required"]
525 .as_array()
526 .expect("tasks.items.required should exist");
527 assert!(items_required.iter().any(|v| v == "prompt"));
528 }
529
530 #[test]
531 fn test_spawn_agent_thulp_definition_has_all_5_params() {
532 let tmp = TempDir::new().unwrap();
535 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
536 let def = tool.thulp_definition();
537 assert_eq!(def.name, "spawn_agent");
538 let param_names: Vec<&str> = def.parameters.iter().map(|p| p.name.as_str()).collect();
539 for p in &["prompt", "model", "timeout", "workspace", "retries"] {
540 assert!(param_names.contains(p), "thulp definition missing '{}'", p);
541 }
542 let required_count = def.parameters.iter().filter(|p| p.required).count();
544 assert_eq!(required_count, 1, "only prompt should be required");
545 }
546
547 #[test]
548 fn test_spawn_agents_thulp_definition_has_tasks_param() {
549 let tmp = TempDir::new().unwrap();
553 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
554 let def = tool.thulp_definition();
555 assert_eq!(def.name, "spawn_agents");
556 assert_eq!(def.parameters.len(), 1);
557 let tasks_param = &def.parameters[0];
558 assert_eq!(tasks_param.name, "tasks");
559 assert!(tasks_param.required);
560 }
561
562 #[test]
563 fn test_spawn_agent_mutating_returns_true() {
564 let tmp = TempDir::new().unwrap();
565 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
566 assert!(tool.mutating(), "spawn_agent can mutate state");
567 }
568
569 #[test]
570 fn test_spawn_agents_mutating_returns_true() {
571 let tmp = TempDir::new().unwrap();
572 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
573 assert!(tool.mutating(), "spawn_agents can mutate state");
574 }
575
576 #[test]
577 fn test_spawn_agent_description_non_empty() {
578 let tmp = TempDir::new().unwrap();
579 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
580 let desc = tool.description();
581 assert!(!desc.is_empty());
582 assert!(desc.contains("sub-agent"));
583 }
584
585 #[test]
586 fn test_spawn_agents_description_non_empty() {
587 let tmp = TempDir::new().unwrap();
588 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
589 let desc = tool.description();
590 assert!(!desc.is_empty());
591 assert!(desc.contains("parallel"));
592 }
593
594 #[tokio::test]
595 async fn test_spawn_agent_timeout_defaults_to_120() {
596 let tmp = TempDir::new().unwrap();
597 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
598 let binary = tmp.path().join("target/release/pawan");
599 std::fs::write(
600 &binary,
601 r"#!/bin/sh
602exit 0",
603 )
604 .unwrap();
605 #[cfg(unix)]
606 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
607 .unwrap();
608
609 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
610 let result = tool.execute(json!({"prompt": "test"})).await.unwrap();
611 assert_eq!(result["success"], true);
612 }
613
614 #[tokio::test]
615 async fn test_spawn_agent_custom_timeout() {
616 let tmp = TempDir::new().unwrap();
617 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
618 let binary = tmp.path().join("target/release/pawan");
619 std::fs::write(
620 &binary,
621 r"#!/bin/sh
622exit 0",
623 )
624 .unwrap();
625 #[cfg(unix)]
626 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
627 .unwrap();
628
629 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
630 let result = tool
631 .execute(json!({"prompt": "test", "timeout": 60}))
632 .await
633 .unwrap();
634 assert_eq!(result["success"], true);
635 }
636
637 #[tokio::test]
638 async fn test_spawn_agent_custom_model() {
639 let tmp = TempDir::new().unwrap();
640 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
641 let binary = tmp.path().join("target/release/pawan");
642 std::fs::write(&binary, "#!/bin/sh\necho '{\"content\":\"test response\"}'").unwrap();
643 #[cfg(unix)]
644 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
645 .unwrap();
646
647 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
648 let result = tool
649 .execute(json!({"prompt": "test", "model": "gpt-4"}))
650 .await
651 .unwrap();
652 assert_eq!(result["success"], true);
653 assert_eq!(result["result"]["content"], "test response");
654 }
655
656 #[tokio::test]
657 async fn test_spawn_agent_retries_on_failure() {
658 let tmp = TempDir::new().unwrap();
659 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
660 let binary = tmp.path().join("target/release/pawan");
661 let counter_file = tmp.path().join("counter");
662 std::fs::write(&counter_file, "0").unwrap();
663 let script = format!(
664 "#!/bin/sh\ncount=$(cat {})\necho $((count + 1)) > {}\nif [ $count -eq 0 ]; then\n exit 1\nelse\n exit 0\nfi",
665 counter_file.display(), counter_file.display()
666 );
667 std::fs::write(&binary, script).unwrap();
668 #[cfg(unix)]
669 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
670 .unwrap();
671
672 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
673 let result = tool
674 .execute(json!({
675 "prompt": "test",
676 "retries": 1
677 }))
678 .await
679 .unwrap();
680 assert_eq!(result["success"], true);
681 assert_eq!(result["attempt"], 2);
682 assert_eq!(result["total_attempts"], 2);
683 }
684
685 #[tokio::test]
686 async fn test_spawn_agent_stderr_captured() {
687 let tmp = TempDir::new().unwrap();
688 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
689 let binary = tmp.path().join("target/release/pawan");
690 std::fs::write(
691 &binary,
692 r"#!/bin/sh
693echo 'error message' >&2
694exit 0",
695 )
696 .unwrap();
697 #[cfg(unix)]
698 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
699 .unwrap();
700
701 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
702 let result = tool.execute(json!({"prompt": "test"})).await.unwrap();
703 assert_eq!(result["success"], true);
704 assert_eq!(result["stderr"], "error message");
705 }
706
707 #[serial_test::serial(pawan_session_tests)]
708 #[tokio::test]
709 async fn test_spawn_agents_single_task() {
710 let tmp = TempDir::new().unwrap();
711 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
712 let binary = tmp.path().join("target/release/pawan");
713 std::fs::write(&binary, "#!/bin/sh\necho '{\"result\":\"done\"}'").unwrap();
714 #[cfg(unix)]
715 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
716 .unwrap();
717
718 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
719 let result = tool
720 .execute(json!({
721 "tasks": [
722 {"prompt": "task1"}
723 ]
724 }))
725 .await
726 .unwrap();
727 assert_eq!(result["success"], true);
728 assert_eq!(result["total_tasks"], 1);
729 assert_eq!(result["results"].as_array().unwrap().len(), 1);
730 assert_eq!(result["results"][0]["result"]["result"], "done");
731 }
732
733 #[serial_test::serial(pawan_session_tests)]
734 #[tokio::test]
735 async fn test_spawn_agents_multiple_tasks() {
736 let tmp = TempDir::new().unwrap();
737 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
738 let binary = tmp.path().join("target/release/pawan");
739 std::fs::write(&binary, "#!/bin/sh\necho '{\"result\":\"done\"}'").unwrap();
740 #[cfg(unix)]
741 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
742 .unwrap();
743
744 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
745 let result = tool
746 .execute(json!({
747 "tasks": [
748 {"prompt": "task1"},
749 {"prompt": "task2"},
750 {"prompt": "task3"}
751 ]
752 }))
753 .await
754 .unwrap();
755 assert_eq!(result["success"], true);
756 assert_eq!(result["total_tasks"], 3);
757 assert_eq!(result["results"].as_array().unwrap().len(), 3);
758 }
759
760 #[tokio::test]
761 async fn test_spawn_agents_task_missing_prompt() {
762 let tmp = TempDir::new().unwrap();
763 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
764 let result = tool
765 .execute(json!({
766 "tasks": [{"model": "gpt-4"}]
767 }))
768 .await
769 .unwrap();
770 assert_eq!(result["success"], true);
771 assert_eq!(result["total_tasks"], 1);
772 assert_eq!(result["results"][0]["success"], false);
773 assert!(result["results"][0]["error"]
774 .as_str()
775 .unwrap()
776 .contains("prompt"));
777 }
778}