Skip to main content

pawan/tools/
task.rs

1//! Task tool: spawn an in-process subagent with restricted tools.
2//!
3//! This tool runs a child `PawanAgent` with a narrowed `ToolRegistry`, a smaller
4//! context window, and a hard timeout. Subagents are depth-limited (they cannot
5//! spawn other agents).
6
7use super::Tool;
8use crate::agent::backend::LlmBackend;
9use crate::agent::PawanAgent;
10use crate::config::PawanConfig;
11use crate::tools::{bash, batch, edit, file, git, lsp_tool, mise, native, ToolRegistry, ToolTier};
12use crate::{PawanError, Result};
13use async_trait::async_trait;
14use serde::Deserialize;
15use serde_json::{json, Value};
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use std::time::Duration;
19use tokio::time::timeout;
20
21const DEFAULT_TIMEOUT_SECS: u64 = 300;
22
23#[derive(Debug, Clone, Deserialize)]
24struct TaskArgs {
25    agent: String,
26    assignment: String,
27    #[serde(default)]
28    context: Option<String>,
29    #[serde(default)]
30    model: Option<String>,
31    /// Timeout in seconds (default: 300).
32    #[serde(default)]
33    timeout: Option<u64>,
34}
35
36#[derive(Clone)]
37pub struct TaskTool {
38    workspace_root: PathBuf,
39}
40
41impl TaskTool {
42    pub fn new(workspace_root: PathBuf) -> Self {
43        Self { workspace_root }
44    }
45
46    fn known_agent_types() -> &'static [&'static str] {
47        &[
48            "explore",
49            "plan",
50            "task",
51            "reviewer",
52            "designer",
53            "librarian",
54        ]
55    }
56
57    fn validate_agent_type(agent: &str) -> std::result::Result<(), String> {
58        if Self::known_agent_types().contains(&agent) {
59            Ok(())
60        } else {
61            Err(format!(
62                "unknown agent type '{agent}'. Valid types: {}",
63                Self::known_agent_types().join(", ")
64            ))
65        }
66    }
67
68    fn validate_assignment(assignment: &str) -> std::result::Result<(), String> {
69        if assignment.trim().is_empty() {
70            Err("assignment must be non-empty".to_string())
71        } else {
72            Ok(())
73        }
74    }
75
76    fn system_prompt_for(agent: &str) -> String {
77        match agent {
78            "explore" => "You are a read-only exploration subagent. Use only the allowed read/search tools to gather facts. Do not propose or apply code edits. Return concise findings with file paths and evidence.".to_string(),
79            "plan" => "You are an architecture subagent. Do not modify code. Make design decisions and propose an implementation plan with tradeoffs, invariants, and acceptance criteria.".to_string(),
80            "reviewer" => "You are a code review subagent. Do not modify code. Identify bugs, security issues, and quality concerns. Return a structured review report with severity and recommendations.".to_string(),
81            "designer" => "You are a UI/UX design subagent. If editing tools are available, you may implement UI changes carefully. Prioritize accessibility and consistency.".to_string(),
82            "librarian" => "You are a research subagent. Verify details from authoritative sources and the local codebase. Do not modify code. Return actionable guidance.".to_string(),
83            _ => "You are a subagent executing a delegated task. Follow the assignment precisely and return the final result. Do not spawn other agents.".to_string(),
84        }
85    }
86
87    fn build_user_prompt(context: Option<&str>, assignment: &str) -> String {
88        match context {
89            Some(ctx) if !ctx.trim().is_empty() => format!(
90                "{ctx}\n\n[Assignment]\n{assignment}\n\n[Constraints]\n- Subagent depth limit: you cannot spawn other agents.\n"
91            ),
92            _ => format!(
93                "[Assignment]\n{assignment}\n\n[Constraints]\n- Subagent depth limit: you cannot spawn other agents.\n"
94            ),
95        }
96    }
97
98    fn registry_for(agent: &str, workspace_root: &Path) -> ToolRegistry {
99        use ToolTier::*;
100        let workspace_root = workspace_root.to_path_buf();
101        let mut reg = ToolRegistry::new();
102
103        // Read/search tools
104        reg.register_with_tier(
105            Arc::new(file::ReadFileTool::new(workspace_root.clone())),
106            Core,
107        );
108        reg.register_with_tier(
109            Arc::new(file::ListDirectoryTool::new(workspace_root.clone())),
110            Standard,
111        );
112        reg.register_with_tier(
113            Arc::new(native::GlobSearchTool::new(workspace_root.clone())),
114            Core,
115        );
116        reg.register_with_tier(
117            Arc::new(native::GrepSearchTool::new(workspace_root.clone())),
118            Core,
119        );
120        reg.register_with_tier(
121            Arc::new(native::AstGrepTool::new(workspace_root.clone())),
122            Core,
123        );
124        reg.register_with_tier(
125            Arc::new(native::RipgrepTool::new(workspace_root.clone())),
126            Extended,
127        );
128        reg.register_with_tier(
129            Arc::new(native::FdTool::new(workspace_root.clone())),
130            Extended,
131        );
132
133        match agent {
134            "explore" | "plan" | "reviewer" | "librarian" => {
135                reg.register_with_tier(
136                    Arc::new(git::GitStatusTool::new(workspace_root.clone())),
137                    Standard,
138                );
139                reg.register_with_tier(
140                    Arc::new(git::GitDiffTool::new(workspace_root.clone())),
141                    Standard,
142                );
143                reg.register_with_tier(
144                    Arc::new(git::GitLogTool::new(workspace_root.clone())),
145                    Standard,
146                );
147                reg.register_with_tier(
148                    Arc::new(git::GitBlameTool::new(workspace_root.clone())),
149                    Standard,
150                );
151                reg
152            }
153            "task" | "designer" => {
154                reg.register_with_tier(Arc::new(bash::BashTool::new(workspace_root.clone())), Core);
155                reg.register_with_tier(
156                    Arc::new(file::WriteFileTool::new(workspace_root.clone())),
157                    Core,
158                );
159                reg.register_with_tier(
160                    Arc::new(edit::EditFileTool::new(workspace_root.clone())),
161                    Core,
162                );
163                reg.register_with_tier(
164                    Arc::new(edit::EditFileLinesTool::new(workspace_root.clone())),
165                    Standard,
166                );
167                reg.register_with_tier(
168                    Arc::new(edit::InsertAfterTool::new(workspace_root.clone())),
169                    Standard,
170                );
171                reg.register_with_tier(
172                    Arc::new(edit::AppendFileTool::new(workspace_root.clone())),
173                    Standard,
174                );
175
176                reg.register_with_tier(
177                    Arc::new(git::GitStatusTool::new(workspace_root.clone())),
178                    Standard,
179                );
180                reg.register_with_tier(
181                    Arc::new(git::GitDiffTool::new(workspace_root.clone())),
182                    Standard,
183                );
184                reg.register_with_tier(
185                    Arc::new(git::GitAddTool::new(workspace_root.clone())),
186                    Standard,
187                );
188                reg.register_with_tier(
189                    Arc::new(git::GitCommitTool::new(workspace_root.clone())),
190                    Standard,
191                );
192                reg.register_with_tier(
193                    Arc::new(git::GitLogTool::new(workspace_root.clone())),
194                    Standard,
195                );
196                reg.register_with_tier(
197                    Arc::new(git::GitBlameTool::new(workspace_root.clone())),
198                    Standard,
199                );
200                reg.register_with_tier(
201                    Arc::new(git::GitBranchTool::new(workspace_root.clone())),
202                    Standard,
203                );
204                reg.register_with_tier(
205                    Arc::new(git::GitCheckoutTool::new(workspace_root.clone())),
206                    Standard,
207                );
208                reg.register_with_tier(
209                    Arc::new(git::GitStashTool::new(workspace_root.clone())),
210                    Standard,
211                );
212
213                reg.register_with_tier(
214                    Arc::new(batch::BatchTool::new(workspace_root.clone())),
215                    Standard,
216                );
217                reg.register_with_tier(
218                    Arc::new(lsp_tool::LspTool::new(workspace_root.clone())),
219                    Extended,
220                );
221                reg.register_with_tier(
222                    Arc::new(mise::MiseTool::new(workspace_root.clone())),
223                    Extended,
224                );
225                reg.register_with_tier(
226                    Arc::new(native::SdTool::new(workspace_root.clone())),
227                    Extended,
228                );
229                reg.register_with_tier(
230                    Arc::new(native::ErdTool::new(workspace_root.clone())),
231                    Extended,
232                );
233                reg
234            }
235            _ => reg,
236        }
237    }
238
239    async fn run_subagent(
240        &self,
241        agent_type: &str,
242        assignment: &str,
243        context: Option<&str>,
244        model: Option<&str>,
245        timeout_secs: u64,
246        backend_override: Option<Box<dyn LlmBackend>>,
247    ) -> Result<Value> {
248        let mut config = PawanConfig {
249            system_prompt: Some(Self::system_prompt_for(agent_type)),
250            max_context_tokens: 32_000,
251            max_tool_iterations: 20,
252            ..Default::default()
253        };
254        config.eruka.enabled = false;
255        if let Some(m) = model {
256            config.model = m.to_string();
257        }
258
259        let tools = Self::registry_for(agent_type, &self.workspace_root);
260        let prompt = Self::build_user_prompt(context, assignment);
261
262        let mut agent = PawanAgent::new(config, self.workspace_root.clone()).with_tools(tools);
263        if let Some(backend) = backend_override {
264            agent = agent.with_backend(backend);
265        }
266
267        let run = agent.execute(&prompt);
268        let response = match timeout(Duration::from_secs(timeout_secs), run).await {
269            Ok(res) => res.map_err(|e| PawanError::Tool(format!("subagent error: {e}")))?,
270            Err(_) => {
271                return Ok(json!({
272                    "agent": agent_type,
273                    "status": "error",
274                    "result": format!("subagent timeout after {timeout_secs}s"),
275                }));
276            }
277        };
278
279        Ok(json!({
280            "agent": agent_type,
281            "status": "completed",
282            "result": response.content,
283            "usage": {
284                "prompt_tokens": response.usage.prompt_tokens,
285                "completion_tokens": response.usage.completion_tokens,
286                "total_tokens": response.usage.total_tokens,
287                "reasoning_tokens": response.usage.reasoning_tokens,
288                "action_tokens": response.usage.action_tokens,
289            }
290        }))
291    }
292}
293
294#[async_trait]
295impl Tool for TaskTool {
296    fn name(&self) -> &str {
297        "task"
298    }
299
300    fn description(&self) -> &str {
301        "Spawn an in-process subagent with restricted tools to complete an assignment."
302    }
303
304    fn mutating(&self) -> bool {
305        true
306    }
307
308    fn parameters_schema(&self) -> Value {
309        json!({
310            "type": "object",
311            "properties": {
312                "agent": {"type": "string"},
313                "assignment": {"type": "string"},
314                "context": {"type": "string"},
315                "model": {"type": "string"},
316                "timeout": {"type": "integer"}
317            },
318            "required": ["agent", "assignment"]
319        })
320    }
321
322    async fn execute(&self, args: Value) -> Result<Value> {
323        let parsed: TaskArgs = serde_json::from_value(args)
324            .map_err(|e| PawanError::Tool(format!("invalid task args: {e}")))?;
325
326        Self::validate_agent_type(&parsed.agent).map_err(PawanError::Tool)?;
327        Self::validate_assignment(&parsed.assignment).map_err(PawanError::Tool)?;
328
329        let timeout_secs = parsed.timeout.unwrap_or(DEFAULT_TIMEOUT_SECS);
330
331        self.run_subagent(
332            &parsed.agent,
333            &parsed.assignment,
334            parsed.context.as_deref(),
335            parsed.model.as_deref(),
336            timeout_secs,
337            None,
338        )
339        .await
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use crate::agent::backend::mock::{MockBackend, MockResponse};
347    use serde_json::json;
348
349    #[tokio::test]
350    async fn unknown_agent_type_rejects() {
351        let dir = tempfile::tempdir().unwrap();
352        let tool = TaskTool::new(dir.path().to_path_buf());
353        let err = tool
354            .execute(json!({"agent": "nope", "assignment": "hi"}))
355            .await
356            .unwrap_err();
357        assert!(err.to_string().contains("unknown agent type"));
358    }
359
360    #[tokio::test]
361    async fn timeout_returns_error_status() {
362        let dir = tempfile::tempdir().unwrap();
363        let tool = TaskTool::new(dir.path().to_path_buf());
364
365        let out = tool
366            .run_subagent(
367                "explore",
368                "This will time out immediately.",
369                None,
370                None,
371                0,
372                None,
373            )
374            .await
375            .unwrap();
376
377        assert_eq!(out["status"].as_str().unwrap(), "error");
378        assert!(out["result"].as_str().unwrap().contains("timeout"));
379    }
380
381    #[tokio::test]
382    async fn explore_agent_runs_and_returns_findings_with_mock_backend() {
383        let dir = tempfile::tempdir().unwrap();
384        let tool = TaskTool::new(dir.path().to_path_buf());
385
386        let backend = Box::new(MockBackend::new(vec![MockResponse::text(
387            "Findings: crates/pawan-core/src/lib.rs is the crate root.",
388        )]));
389
390        let out = tool
391            .run_subagent(
392                "explore",
393                "Explore the repo and return findings.",
394                Some("Context here"),
395                None,
396                5,
397                Some(backend),
398            )
399            .await
400            .unwrap();
401
402        assert_eq!(out["agent"].as_str().unwrap(), "explore");
403        assert_eq!(out["status"].as_str().unwrap(), "completed");
404        assert!(out["result"].as_str().unwrap().contains("Findings:"));
405    }
406}