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