1use 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 #[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 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}