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 parameters_schema(&self) -> Value {
55 json!({
56 "type": "object",
57 "properties": {
58 "prompt": {
59 "type": "string",
60 "description": "The task/prompt for the sub-agent"
61 },
62 "model": {
63 "type": "string",
64 "description": "Model to use (optional, defaults to parent's model)"
65 },
66 "timeout": {
67 "type": "integer",
68 "description": "Timeout in seconds (default: 120)"
69 },
70 "workspace": {
71 "type": "string",
72 "description": "Workspace directory for the sub-agent (default: same as parent)"
73 },
74 "retries": {
75 "type": "integer",
76 "description": "Number of retry attempts on failure (default: 0, max: 2)"
77 }
78 },
79 "required": ["prompt"]
80 })
81 }
82
83 async fn execute(&self, args: Value) -> Result<Value> {
84 let prompt = args["prompt"]
85 .as_str()
86 .ok_or_else(|| PawanError::Tool("prompt is required for spawn_agent".into()))?;
87
88 let timeout = args["timeout"].as_u64().unwrap_or(120);
89 let model = args["model"].as_str();
90 let workspace = args["workspace"]
91 .as_str()
92 .map(PathBuf::from)
93 .unwrap_or_else(|| self.workspace_root.clone());
94 let max_retries = args["retries"].as_u64().unwrap_or(0).min(2) as usize;
95
96 let agent_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
98 let status_path = format!("/tmp/pawan-agent-{}.status", agent_id);
99 let started_at = chrono::Utc::now().to_rfc3339();
100
101 let pawan_bin = self.find_pawan_binary();
102
103 for attempt in 0..=max_retries {
104 let mut cmd = Command::new(&pawan_bin);
105 cmd.arg("run")
106 .arg("-o")
107 .arg("json")
108 .arg("--timeout")
109 .arg(timeout.to_string())
110 .arg("-w")
111 .arg(workspace.to_string_lossy().to_string());
112
113 if let Some(m) = model {
114 cmd.arg("-m").arg(m);
115 }
116
117 cmd.arg(prompt);
118
119 cmd.stdout(Stdio::piped())
120 .stderr(Stdio::piped())
121 .stdin(Stdio::null());
122
123 if let Ok(mut f) = std::fs::File::create(&status_path) {
125 let _ = write!(f, r#"{{"state":"running","prompt":"{}","started_at":"{}","attempt":{}}}"#,
126 prompt.chars().take(100).collect::<String>().replace('"', "'"), started_at, attempt + 1);
127 }
128
129 let mut child = cmd.spawn().map_err(|e| {
130 PawanError::Tool(format!(
131 "Failed to spawn sub-agent: {}. Binary: {}",
132 e, pawan_bin
133 ))
134 })?;
135
136 let mut stdout = String::new();
137 let mut stderr = String::new();
138
139 if let Some(mut handle) = child.stdout.take() {
140 handle.read_to_string(&mut stdout).await.ok();
141 }
142 if let Some(mut handle) = child.stderr.take() {
143 handle.read_to_string(&mut stderr).await.ok();
144 }
145
146 let status = child.wait().await.map_err(PawanError::Io)?;
147
148 let result = if let Ok(json_result) = serde_json::from_str::<Value>(&stdout) {
149 json_result
150 } else {
151 json!({
152 "content": stdout.trim(),
153 "raw_output": true
154 })
155 };
156
157 if status.success() || attempt == max_retries {
158 let duration_ms = chrono::Utc::now().signed_duration_since(chrono::DateTime::parse_from_rfc3339(&started_at).unwrap_or_default()).num_milliseconds();
160 if let Ok(mut f) = std::fs::File::create(&status_path) {
161 let state = if status.success() { "done" } else { "failed" };
162 let _ = write!(f, r#"{{"state":"{}","exit_code":{},"duration_ms":{},"attempt":{}}}"#,
163 state, status.code().unwrap_or(-1), duration_ms, attempt + 1);
164 }
165
166 return Ok(json!({
167 "success": status.success(),
168 "attempt": attempt + 1,
169 "total_attempts": attempt + 1,
170 "result": result,
171 "stderr": stderr.trim(),
172 }));
173 }
174 tracing::warn!(attempt = attempt + 1, "spawn_agent attempt failed, retrying");
177 }
178
179 Err(PawanError::Tool("spawn_agent: all retry attempts exhausted".into()))
181 }
182}
183
184pub struct SpawnAgentsTool {
186 workspace_root: PathBuf,
187}
188
189impl SpawnAgentsTool {
190 pub fn new(workspace_root: PathBuf) -> Self {
191 Self { workspace_root }
192 }
193}
194
195#[async_trait]
196impl Tool for SpawnAgentsTool {
197 fn name(&self) -> &str {
198 "spawn_agents"
199 }
200
201 fn description(&self) -> &str {
202 "Spawn multiple sub-agents in parallel. Each task runs concurrently and results are returned as an array."
203 }
204
205 fn parameters_schema(&self) -> Value {
206 json!({
207 "type": "object",
208 "properties": {
209 "tasks": {
210 "type": "array",
211 "items": {
212 "type": "object",
213 "properties": {
214 "prompt": {"type": "string"},
215 "model": {"type": "string"},
216 "timeout": {"type": "integer"},
217 "workspace": {"type": "string"}
218 },
219 "required": ["prompt"]
220 }
221 }
222 },
223 "required": ["tasks"]
224 })
225 }
226
227 async fn execute(&self, args: Value) -> Result<Value> {
228 let tasks = args["tasks"]
229 .as_array()
230 .ok_or_else(|| PawanError::Tool("tasks array is required for spawn_agents".into()))?;
231
232 let single_tool = SpawnAgentTool::new(self.workspace_root.clone());
233
234 let futures: Vec<_> = tasks
235 .iter()
236 .map(|task| single_tool.execute(task.clone()))
237 .collect();
238
239 let results = futures::future::join_all(futures).await;
240
241 let output: Vec<Value> = results
242 .into_iter()
243 .map(|r| match r {
244 Ok(v) => v,
245 Err(e) => json!({"success": false, "error": e.to_string()}),
246 })
247 .collect();
248
249 Ok(json!({
250 "success": true,
251 "results": output,
252 "total_tasks": tasks.len(),
253 }))
254 }
255}
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use tempfile::TempDir;
260
261 #[test]
262 fn test_spawn_agent_tool_name() {
263 let tmp = TempDir::new().unwrap();
264 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
265 assert_eq!(tool.name(), "spawn_agent");
266 }
267
268 #[test]
269 fn test_spawn_agents_tool_name() {
270 let tmp = TempDir::new().unwrap();
271 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
272 assert_eq!(tool.name(), "spawn_agents");
273 }
274
275 #[test]
276 fn test_spawn_agent_schema_has_prompt() {
277 let tmp = TempDir::new().unwrap();
278 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
279 let schema = tool.parameters_schema();
280 assert!(schema["properties"]["prompt"].is_object());
281 assert!(schema["required"].as_array().unwrap().iter().any(|v| v == "prompt"));
282 }
283}