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::path::PathBuf;
11use std::process::Stdio;
12use tokio::io::AsyncReadExt;
13use tokio::process::Command;
14use std::io::Write;
15use tracing;
16
17/// Tool for spawning a sub-agent (pawan subprocess)
18pub 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    /// Find the pawan binary — tries cargo target first, then PATH
28    fn find_pawan_binary(&self) -> String {
29        // Check for debug/release binary in workspace target
30        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        // Fall back to PATH
39        "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        // Generate unique agent ID for progress tracking
114        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            // Write initial status
141            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                // Update status file with completion
176                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            // Failed but retries remaining — continue loop
192            // Failed but retries remaining — continue loop
193            tracing::warn!(attempt = attempt + 1, "spawn_agent attempt failed, retrying");
194        }
195
196        // Should not reach here, but satisfy the compiler
197        Err(PawanError::Tool("spawn_agent: all retry attempts exhausted".into()))
198    }
199}
200
201/// Tool for spawning multiple sub-agents in parallel
202pub 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}