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    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        // Generate unique agent ID for progress tracking
97        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            // Write initial status
124            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                // Update status file with completion
159                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            // Failed but retries remaining — continue loop
175            // Failed but retries remaining — continue loop
176            tracing::warn!(attempt = attempt + 1, "spawn_agent attempt failed, retrying");
177        }
178
179        // Should not reach here, but satisfy the compiler
180        Err(PawanError::Tool("spawn_agent: all retry attempts exhausted".into()))
181    }
182}
183
184/// Tool for spawning multiple sub-agents in parallel
185pub 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}