Skip to main content

batuta/agent/
task_tool.rs

1//! Task tool — Claude-Code Agent-equivalent for typed sub-agent delegation.
2//!
3//! PMAT-CODE-SPAWN-PARITY-001: the `Task` tool mirrors Claude Code's
4//! `Agent` tool, which takes `subagent_type` + `description` + `prompt`
5//! and returns the child's final response. This is the 1:1 equivalent
6//! for `apr code` and is **default-registered** (no capability gate) so
7//! the agent can always delegate, matching Claude Code 1:1.
8//!
9//! Distinct from `SpawnTool`:
10//!   - `SpawnTool` is untyped — caller passes any manifest inline.
11//!   - `TaskTool` resolves `subagent_type` via [`SubagentRegistry`] to a
12//!     preset personality (system-prompt + narrow tool allowlist). This
13//!     matches Claude's pattern where each agent type has a fixed role.
14//!
15//! Toyota Production System:
16//!   - **Poka-Yoke**: subagent_type must exist in the registry — an
17//!     unknown type errors out before spawn, so typos can't launch a
18//!     misconfigured child.
19//!   - **Jidoka**: child `max_iterations` is clamped and recursion is
20//!     bounded by `max_depth`.
21//!   - **Muda**: child shares parent's driver + memory Arc, so no
22//!     duplicate model load.
23
24use std::collections::BTreeMap;
25use std::sync::Arc;
26
27use async_trait::async_trait;
28use tokio::sync::Mutex;
29
30use crate::agent::capability::Capability;
31use crate::agent::driver::{LlmDriver, ToolDefinition};
32use crate::agent::manifest::{AgentManifest, ResourceQuota};
33use crate::agent::pool::{AgentPool, SpawnConfig};
34
35use super::tool::{Tool, ToolResult};
36
37/// A pre-configured subagent personality resolved by `subagent_type`.
38///
39/// Holds the pieces of a child `AgentManifest` that vary per type
40/// (system prompt, iteration budget). Other fields inherit from the
41/// parent manifest so settings like `model_path` stay consistent.
42#[derive(Debug, Clone)]
43pub struct SubagentSpec {
44    /// Identifier used by the parent agent (e.g. `"general-purpose"`).
45    pub name: String,
46    /// One-line purpose shown in the tool description.
47    pub description: String,
48    /// Prepended to the child's system prompt to scope its role.
49    pub system_prompt: String,
50    /// Hard cap on child iterations (Jidoka).
51    pub max_iterations: u32,
52}
53
54impl SubagentSpec {
55    /// The canonical `general-purpose` subagent used by default.
56    pub fn general_purpose() -> Self {
57        Self {
58            name: "general-purpose".into(),
59            description: "General-purpose agent for researching questions, searching for code, \
60                 and executing multi-step tasks. Use when the target is not known \
61                 and you are not confident you will find the right match in the \
62                 first few tries."
63                .into(),
64            system_prompt: "You are a general-purpose research subagent. Your job is to answer \
65                 the user's question by gathering evidence from the codebase. Return \
66                 a concise summary of findings, not running commentary."
67                .into(),
68            max_iterations: 10,
69        }
70    }
71
72    /// `explore` subagent — codebase search specialist.
73    pub fn explore() -> Self {
74        Self {
75            name: "explore".into(),
76            description: "Fast agent specialized for exploring codebases. Use to find files \
77                 by pattern, search code for keywords, or answer questions about \
78                 structure. Returns a digest of findings."
79                .into(),
80            system_prompt: "You are a codebase exploration subagent. Prefer pmat_query over \
81                 raw grep. Never edit files. Return a short digest of what you found \
82                 with file:line citations."
83                .into(),
84            max_iterations: 8,
85        }
86    }
87
88    /// `plan` subagent — designs implementation strategies.
89    pub fn plan() -> Self {
90        Self {
91            name: "plan".into(),
92            description: "Software architect subagent for designing implementation plans. \
93                 Use when you need a step-by-step plan, critical-file list, or \
94                 architectural trade-offs before implementing."
95                .into(),
96            system_prompt: "You are a planning subagent. Read the relevant code, then return a \
97                 numbered plan with (a) files to change, (b) order of changes, and \
98                 (c) the key trade-off. Do not write code."
99                .into(),
100            max_iterations: 6,
101        }
102    }
103}
104
105/// Registry of subagent types available to [`TaskTool`].
106///
107/// Claude Code ships with `general-purpose`, `Explore`, and `Plan`.
108/// `apr code` mirrors the same three by default; users can register
109/// additional types via `register`.
110#[derive(Debug, Clone, Default)]
111pub struct SubagentRegistry {
112    by_name: BTreeMap<String, SubagentSpec>,
113}
114
115impl SubagentRegistry {
116    /// Empty registry — use [`default_registry`] for the stock 3.
117    pub fn new() -> Self {
118        Self { by_name: BTreeMap::new() }
119    }
120
121    /// Register a subagent spec (replaces any existing entry with the
122    /// same name).
123    pub fn register(&mut self, spec: SubagentSpec) {
124        self.by_name.insert(spec.name.clone(), spec);
125    }
126
127    /// Resolve a subagent by type name. Returns `None` if not found.
128    pub fn resolve(&self, name: &str) -> Option<&SubagentSpec> {
129        self.by_name.get(name)
130    }
131
132    /// Number of registered subagent types.
133    pub fn len(&self) -> usize {
134        self.by_name.len()
135    }
136
137    /// `true` when no subagent types are registered.
138    pub fn is_empty(&self) -> bool {
139        self.by_name.is_empty()
140    }
141
142    /// All registered names, alphabetical.
143    pub fn names(&self) -> Vec<String> {
144        self.by_name.keys().cloned().collect()
145    }
146}
147
148/// Default registry populated with the 3 canonical subagent types
149/// (`general-purpose`, `explore`, `plan`) — identical roster to
150/// Claude Code's built-in agents.
151pub fn default_registry() -> SubagentRegistry {
152    let mut r = SubagentRegistry::new();
153    r.register(SubagentSpec::general_purpose());
154    r.register(SubagentSpec::explore());
155    r.register(SubagentSpec::plan());
156    r
157}
158
159/// Task tool — Claude-Code-equivalent `Agent`/`Task` sub-agent dispatcher.
160///
161/// Unlike [`crate::agent::tool::spawn::SpawnTool`], this tool is
162/// **default-registered** in `apr code` with no capability gate: the
163/// parent agent can always delegate via `Task`, matching Claude Code's
164/// behavior where `Agent` is part of the default toolbelt.
165pub struct TaskTool {
166    registry: Arc<SubagentRegistry>,
167    pool: Arc<Mutex<AgentPool>>,
168    parent_manifest: AgentManifest,
169    current_depth: u32,
170    max_depth: u32,
171}
172
173impl TaskTool {
174    /// Create a Task tool.
175    ///
176    /// * `registry` — resolves `subagent_type` to a [`SubagentSpec`].
177    /// * `pool` — shared agent pool; child executions run here.
178    /// * `parent_manifest` — used as the base for child manifests
179    ///    (model, capabilities, memory settings are inherited).
180    /// * `current_depth` / `max_depth` — recursion guard.
181    pub fn new(
182        registry: Arc<SubagentRegistry>,
183        pool: Arc<Mutex<AgentPool>>,
184        parent_manifest: AgentManifest,
185        current_depth: u32,
186        max_depth: u32,
187    ) -> Self {
188        Self { registry, pool, parent_manifest, current_depth, max_depth }
189    }
190
191    /// Convenience constructor — driver + default subagent registry.
192    /// Used by `apr code` to install a task tool in the default
193    /// registry without the caller threading the pool.
194    pub fn from_driver(
195        driver: Arc<dyn LlmDriver>,
196        parent_manifest: AgentManifest,
197        max_depth: u32,
198    ) -> Self {
199        Self::from_driver_with_registry(driver, parent_manifest, max_depth, default_registry())
200    }
201
202    /// Like [`from_driver`] but uses a caller-supplied registry. Used by
203    /// [`register_task_tool`] to install the tool with user-defined
204    /// subagents (from `.apr/agents/` / `.claude/agents/`) merged in on
205    /// top of the 3 canonical built-ins.
206    pub fn from_driver_with_registry(
207        driver: Arc<dyn LlmDriver>,
208        parent_manifest: AgentManifest,
209        max_depth: u32,
210        registry: SubagentRegistry,
211    ) -> Self {
212        let pool = Arc::new(Mutex::new(AgentPool::new(driver, 4)));
213        Self::new(Arc::new(registry), pool, parent_manifest, 0, max_depth)
214    }
215
216    fn build_child_manifest(&self, spec: &SubagentSpec) -> AgentManifest {
217        let mut child = self.parent_manifest.clone();
218        child.name = format!("{}/{}", self.parent_manifest.name, spec.name);
219        child.model.system_prompt = spec.system_prompt.clone();
220        child.resources = ResourceQuota {
221            max_iterations: child.resources.max_iterations.min(spec.max_iterations),
222            ..child.resources.clone()
223        };
224        child
225    }
226}
227
228#[async_trait]
229impl Tool for TaskTool {
230    fn name(&self) -> &'static str {
231        "task"
232    }
233
234    fn definition(&self) -> ToolDefinition {
235        let names = self.registry.names();
236        let listed = if names.is_empty() { "(none registered)".into() } else { names.join(", ") };
237        ToolDefinition {
238            name: "task".into(),
239            description: format!(
240                "Launch a typed sub-agent to handle a delegated task. \
241                 Registered subagent types: {listed}. \
242                 The child runs its own perceive-reason-act loop and its \
243                 final response is returned as the tool result."
244            ),
245            input_schema: serde_json::json!({
246                "type": "object",
247                "properties": {
248                    "subagent_type": {
249                        "type": "string",
250                        "description": "Registered subagent type (e.g. general-purpose, explore, plan)"
251                    },
252                    "description": {
253                        "type": "string",
254                        "description": "Short (3-7 word) label for the task — shown in telemetry"
255                    },
256                    "prompt": {
257                        "type": "string",
258                        "description": "The full task delegated to the child subagent"
259                    }
260                },
261                "required": ["subagent_type", "prompt"]
262            }),
263        }
264    }
265
266    async fn execute(&self, input: serde_json::Value) -> ToolResult {
267        if self.current_depth >= self.max_depth {
268            return ToolResult::error(format!(
269                "task depth limit reached ({}/{})",
270                self.current_depth, self.max_depth,
271            ));
272        }
273
274        let subagent_type = match input.get("subagent_type").and_then(|v| v.as_str()) {
275            Some(s) => s,
276            None => {
277                return ToolResult::error("missing required field: subagent_type");
278            }
279        };
280
281        let prompt = match input.get("prompt").and_then(|v| v.as_str()) {
282            Some(s) => s.to_string(),
283            None => {
284                return ToolResult::error("missing required field: prompt");
285            }
286        };
287
288        let spec = match self.registry.resolve(subagent_type) {
289            Some(s) => s.clone(),
290            None => {
291                let names = self.registry.names().join(", ");
292                return ToolResult::error(format!(
293                    "unknown subagent_type '{subagent_type}' (registered: {names})"
294                ));
295            }
296        };
297
298        let child_manifest = self.build_child_manifest(&spec);
299        let config = SpawnConfig { manifest: child_manifest, query: prompt };
300
301        let mut pool = self.pool.lock().await;
302        let id = match pool.spawn(config) {
303            Ok(id) => id,
304            Err(e) => return ToolResult::error(format!("task spawn failed: {e}")),
305        };
306
307        match pool.join_next().await {
308            Some((completed_id, Ok(result))) if completed_id == id => {
309                ToolResult::success(result.text)
310            }
311            Some((_, Ok(result))) => ToolResult::success(result.text),
312            Some((_, Err(e))) => ToolResult::error(format!("subagent error: {e}")),
313            None => ToolResult::error("subagent produced no result"),
314        }
315    }
316
317    fn required_capability(&self) -> Capability {
318        // Task tool is part of the default toolbelt (like Claude Code's
319        // Agent), but we still model it under the Spawn capability so
320        // manifests that *deny* spawning can keep denying it. The
321        // default manifest grants Spawn implicitly via [`register_task_tool`].
322        Capability::Spawn { max_depth: self.max_depth }
323    }
324}
325
326/// Register `TaskTool` + `Spawn { max_depth }` capability in the given
327/// registry, unless the manifest already denies `Spawn` explicitly.
328///
329/// Also discovers user-defined subagents from the current working
330/// directory's `.apr/agents/` (or `.claude/agents/` for cross-compat)
331/// and merges them on top of the 3 canonical built-ins. This is the
332/// default-registration hook used by `apr code` so the `Task` tool
333/// ships in the default toolbelt (Claude-Code parity).
334pub fn register_task_tool(
335    registry: &mut super::tool::ToolRegistry,
336    manifest: &AgentManifest,
337    driver: Arc<dyn LlmDriver>,
338    max_depth: u32,
339) {
340    let mut subagents = default_registry();
341    if let Ok(cwd) = std::env::current_dir() {
342        let _ = super::custom_agents::register_discovered_into(&mut subagents, &cwd);
343    }
344    let tool = TaskTool::from_driver_with_registry(
345        Arc::clone(&driver),
346        manifest.clone(),
347        max_depth,
348        subagents,
349    );
350    registry.register(Box::new(tool));
351}
352
353#[cfg(test)]
354mod tests;