Skip to main content

recursive/tools/
spawn_worker.rs

1//! `spawn_worker` tool: first-class coordinator-pattern delegation.
2//!
3//! Unlike the lower-level `sub_agent` tool (which the LLM can use for arbitrary
4//! sub-tasks), `spawn_worker` is designed for the **coordinator pattern** where
5//! a lead agent explicitly delegates structured work to specialist workers.
6//!
7//! Motivation: The old `TeamOrchestrator` approach required the LLM to write
8//! `DELEGATE:<role>:<task>` text strings, which were then parsed — a fragile,
9//! non-type-safe mechanism. `spawn_worker` gives the coordinator agent a proper
10//! JSON tool call to express delegation, eliminating text parsing entirely.
11//!
12//! # Worker types
13//!
14//! | `worker_type` | System prompt focus | Tool access |
15//! |---------------|---------------------|-------------|
16//! | `general`     | General-purpose     | Full (parent registry) |
17//! | `explore`     | Read-only research  | read_file, list_dir, search_files, web_fetch |
18//! | `coder`       | Code implementation | Full |
19//! | `reviewer`    | Code review         | read_file, list_dir, search_files |
20//! | `researcher`  | External research   | read_file, search_files, web_fetch |
21
22use async_trait::async_trait;
23use serde_json::{json, Value};
24use std::sync::Arc;
25
26use crate::agent::{FinishReason, PlanningMode};
27use crate::error::{Error, Result};
28use crate::kernel::{AgentKernel, TurnContext};
29use crate::llm::{LlmProvider, ToolSpec};
30use crate::message::Message;
31use crate::permissions::PermissionMode;
32use crate::tools::PermissionHook;
33use crate::tools::{Tool, ToolRegistry, ToolSideEffect};
34
35// ---------------------------------------------------------------------------
36// WorkerType
37// ---------------------------------------------------------------------------
38
39/// Named worker personality for coordinator-pattern delegation.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum WorkerType {
42    General,
43    Explore,
44    Coder,
45    Reviewer,
46    Researcher,
47}
48
49impl WorkerType {
50    pub fn parse(s: &str) -> Option<Self> {
51        match s {
52            "general" => Some(Self::General),
53            "explore" => Some(Self::Explore),
54            "coder" => Some(Self::Coder),
55            "reviewer" => Some(Self::Reviewer),
56            "researcher" => Some(Self::Researcher),
57            _ => None,
58        }
59    }
60
61    /// Whether this worker type only reads (never writes files).
62    pub fn is_read_only(self) -> bool {
63        matches!(self, Self::Explore | Self::Reviewer | Self::Researcher)
64    }
65
66    pub fn system_prompt(self) -> &'static str {
67        match self {
68            Self::General => {
69                "You are a focused worker agent. Complete the given task using the \
70                 available tools. Be concise and report what you did."
71            }
72            Self::Explore => {
73                "You are a read-only exploration agent. Use only read tools to gather \
74                 information about the codebase or files. Do NOT write or modify any files. \
75                 Report findings clearly and concisely."
76            }
77            Self::Coder => {
78                "You are a coding specialist. Implement the requested feature or fix. \
79                 Write clean, tested code. Run cargo test or equivalent after changes. \
80                 Commit your work and report the result."
81            }
82            Self::Reviewer => {
83                "You are a code reviewer. Read the relevant code carefully and provide \
84                 a structured review: summary of changes, potential issues, and suggestions. \
85                 Do NOT modify any files."
86            }
87            Self::Researcher => {
88                "You are a research specialist. Gather information from files, search results, \
89                 or web sources to answer the question or produce a report. \
90                 Do NOT modify any files. Report findings in a structured format."
91            }
92        }
93    }
94
95    /// Restricted tool names for this worker type, or `None` for full access.
96    pub fn allowed_tool_names(self) -> Option<Vec<String>> {
97        match self {
98            Self::General | Self::Coder => None,
99            Self::Explore => Some(vec![
100                "read_file".to_string(),
101                "list_dir".to_string(),
102                "search_files".to_string(),
103                "web_fetch".to_string(),
104            ]),
105            Self::Reviewer => Some(vec![
106                "read_file".to_string(),
107                "list_dir".to_string(),
108                "search_files".to_string(),
109            ]),
110            Self::Researcher => Some(vec![
111                "read_file".to_string(),
112                "search_files".to_string(),
113                "web_fetch".to_string(),
114            ]),
115        }
116    }
117}
118
119// ---------------------------------------------------------------------------
120// SpawnWorkerTool
121// ---------------------------------------------------------------------------
122
123/// The `spawn_worker` tool — a first-class coordinator delegation mechanism.
124pub struct SpawnWorkerTool {
125    workspace: std::path::PathBuf,
126    provider: Arc<dyn LlmProvider>,
127    all_tools: ToolRegistry,
128    max_depth: usize,
129    current_depth: usize,
130    permission_hook: Option<Arc<dyn PermissionHook>>,
131    /// Optional registry — when set, each spawned worker is registered so
132    /// a coordinator can send mid-run messages via `send_message`.
133    registry: Option<crate::tools::send_message::WorkerRegistry>,
134}
135
136impl SpawnWorkerTool {
137    pub fn new(
138        workspace: impl Into<std::path::PathBuf>,
139        provider: Arc<dyn LlmProvider>,
140        all_tools: ToolRegistry,
141        max_depth: usize,
142        current_depth: usize,
143        permission_hook: Option<Arc<dyn PermissionHook>>,
144    ) -> Self {
145        Self {
146            workspace: workspace.into(),
147            provider,
148            all_tools,
149            max_depth,
150            current_depth,
151            permission_hook,
152            registry: None,
153        }
154    }
155
156    /// Attach a `WorkerRegistry` so spawned workers can receive mid-run messages.
157    pub fn with_registry(mut self, registry: crate::tools::send_message::WorkerRegistry) -> Self {
158        self.registry = Some(registry);
159        self
160    }
161
162    fn build_sub_registry(&self, tool_names: &[String]) -> ToolRegistry {
163        let mut reg = self.all_tools.with_same_transport();
164        for name in tool_names {
165            if let Some(tool) = self.all_tools.get(name) {
166                reg = reg.register(tool);
167            }
168        }
169        reg
170    }
171}
172
173#[async_trait]
174impl Tool for SpawnWorkerTool {
175    fn spec(&self) -> ToolSpec {
176        ToolSpec {
177            name: "spawn_worker".into(),
178            description: concat!(
179                "Spawn a specialist worker agent to handle a focused sub-task. ",
180                "This is the coordinator-pattern delegation tool: use it to assign work to ",
181                "a named specialist (explore, coder, reviewer, researcher, or general). ",
182                "The worker runs independently with an empty transcript and returns its result."
183            )
184            .into(),
185            parameters: json!({
186                "type": "object",
187                "properties": {
188                    "prompt": {
189                        "type": "string",
190                        "description": "Complete task description for the worker. Include all necessary context — the worker has no access to this conversation."
191                    },
192                    "worker_type": {
193                        "type": "string",
194                        "enum": ["general", "explore", "coder", "reviewer", "researcher"],
195                        "description": "Specialist type: 'explore'/'reviewer'/'researcher' are read-only; 'coder'/'general' have full tool access.",
196                        "default": "general"
197                    },
198                    "system_prompt": {
199                        "type": "string",
200                        "description": "Optional: override the default system prompt for this worker type."
201                    },
202                    "max_steps": {
203                        "type": "integer",
204                        "description": "Maximum steps for the worker (default 30, max 100).",
205                        "default": 30
206                    }
207                },
208                "required": ["prompt"]
209            }),
210        }
211    }
212
213    fn side_effect_class(&self) -> ToolSideEffect {
214        // Read-only workers are safe to run in parallel; general/coder workers
215        // may write files, so they are External (sequential by default).
216        ToolSideEffect::External
217    }
218
219    /// Allow parallel dispatch when the worker type is read-only.
220    fn is_readonly_for_args(&self, arguments: &Value) -> bool {
221        arguments
222            .get("worker_type")
223            .and_then(|v| v.as_str())
224            .and_then(WorkerType::parse)
225            .map(WorkerType::is_read_only)
226            .unwrap_or(false)
227    }
228
229    async fn execute(&self, arguments: Value) -> Result<String> {
230        let prompt = arguments["prompt"]
231            .as_str()
232            .ok_or_else(|| Error::BadToolArgs {
233                name: "spawn_worker".into(),
234                message: "missing required parameter: prompt".to_string(),
235            })?;
236
237        let worker_type = arguments
238            .get("worker_type")
239            .and_then(|v| v.as_str())
240            .and_then(WorkerType::parse)
241            .unwrap_or(WorkerType::General);
242
243        let max_steps = arguments["max_steps"].as_i64().unwrap_or(30).clamp(1, 100) as usize;
244
245        // Depth limit check (reuse same env var as sub_agent)
246        if self.current_depth >= self.max_depth {
247            return Ok(format!(
248                "ERROR: worker depth limit reached (max_depth={}). Cannot spawn deeper worker.",
249                self.max_depth
250            ));
251        }
252
253        // System prompt: caller override > worker type default
254        let sys_prompt = arguments
255            .get("system_prompt")
256            .and_then(|v| v.as_str())
257            .unwrap_or_else(|| worker_type.system_prompt())
258            .to_string();
259
260        // Assign a stable worker_id and optionally register in the registry so
261        // the coordinator can send mid-run messages via `send_message`.
262        let worker_id = arguments
263            .get("worker_id")
264            .and_then(|v| v.as_str())
265            .map(|s| s.to_string())
266            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
267
268        let mailbox = match &self.registry {
269            Some(reg) => Some(reg.register(&worker_id).await),
270            None => None,
271        };
272
273        // Build the tool registry for this worker
274        let tool_names = worker_type.allowed_tool_names();
275        let sub_registry = match &tool_names {
276            Some(names) => self.build_sub_registry(names),
277            None => {
278                // Full access: give worker all parent tools.
279                // Also register a child spawn_worker so workers can sub-delegate.
280                let mut reg = self.all_tools.clone();
281                let mut child = SpawnWorkerTool::new(
282                    &self.workspace,
283                    self.provider.clone(),
284                    self.all_tools.clone(),
285                    self.max_depth,
286                    self.current_depth + 1,
287                    self.permission_hook.clone(),
288                );
289                if let Some(r) = &self.registry {
290                    child = child.with_registry(r.clone());
291                }
292                reg = reg.register(Arc::new(child));
293                reg
294            }
295        };
296
297        let kernel = AgentKernel::builder()
298            .llm(self.provider.clone())
299            .tools(sub_registry)
300            .max_steps(max_steps)
301            .build()
302            .map_err(|e| Error::Tool {
303                name: "spawn_worker".into(),
304                message: format!("failed to build worker kernel: {e}"),
305            })?;
306
307        let ctx = TurnContext {
308            messages: vec![
309                Message::system(sys_prompt),
310                Message::user(prompt.to_string()),
311            ],
312            step_events_tx: None,
313            plan_confirmed: false,
314            plan_buffer: None,
315            tool_specs: kernel.tools().specs(),
316            streaming: false,
317            permission_hook: self.permission_hook.clone(),
318            planning_mode: PlanningMode::default(),
319            exploring_plan_mode: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
320            permission_mode: PermissionMode::Default,
321            mailbox,
322        };
323
324        let outcome = kernel.run(ctx).await;
325
326        // Deregister from the registry once the worker is done (success or error).
327        if let Some(reg) = &self.registry {
328            reg.deregister(&worker_id).await;
329        }
330
331        let outcome = outcome.map_err(|e| Error::Tool {
332            name: "spawn_worker".into(),
333            message: format!("worker failed: {e}"),
334        })?;
335
336        let finish_label = match &outcome.finish_reason {
337            FinishReason::NoMoreToolCalls => "NoMoreToolCalls",
338            FinishReason::BudgetExceeded => "BudgetExceeded",
339            FinishReason::ProviderStop(r) => r,
340            FinishReason::Stuck { .. } => "Stuck",
341            FinishReason::TranscriptLimit { .. } => "TranscriptLimit",
342            FinishReason::PlanPending => "PlanPending",
343            FinishReason::Cancelled => "Cancelled",
344            FinishReason::PermissionDenialLimit => "PermissionDenialLimit",
345        };
346
347        let final_text = outcome
348            .final_text
349            .unwrap_or_else(|| "(no final message)".to_string());
350
351        let type_label = match worker_type {
352            WorkerType::General => "general",
353            WorkerType::Explore => "explore",
354            WorkerType::Coder => "coder",
355            WorkerType::Reviewer => "reviewer",
356            WorkerType::Researcher => "researcher",
357        };
358
359        Ok(format!(
360            "[worker_id:{worker_id} type:{type_label} finished:{finish_label}]\n{final_text}"
361        ))
362    }
363}
364
365// ---------------------------------------------------------------------------
366// Tests
367// ---------------------------------------------------------------------------
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::llm::{Completion, MockProvider};
373    use crate::tools::{ListDir, LocalTransport, ReadFile, SearchFiles, ToolTransport, WriteFile};
374    use tempfile::TempDir;
375
376    fn mock_provider(script: Vec<Completion>) -> Arc<dyn LlmProvider> {
377        Arc::new(MockProvider::new(script))
378    }
379
380    fn full_registry(workspace: &std::path::Path) -> ToolRegistry {
381        let transport: Arc<dyn ToolTransport> = Arc::new(LocalTransport);
382        ToolRegistry::new(transport)
383            .register(Arc::new(ReadFile::new(workspace)))
384            .register(Arc::new(WriteFile::new(workspace)))
385            .register(Arc::new(ListDir::new(workspace)))
386            .register(Arc::new(SearchFiles::new(workspace)))
387    }
388
389    fn make_tool(workspace: &std::path::Path, provider: Arc<dyn LlmProvider>) -> SpawnWorkerTool {
390        let registry = full_registry(workspace);
391        SpawnWorkerTool::new(workspace, provider, registry, 2, 0, None)
392    }
393
394    #[tokio::test]
395    async fn spawn_worker_general_type() {
396        let dir = TempDir::new().unwrap();
397        let provider = mock_provider(vec![Completion {
398            content: "Task complete. I analysed the situation.".into(),
399            tool_calls: vec![],
400            finish_reason: Some("stop".into()),
401            usage: None,
402            reasoning_content: None,
403        }]);
404        let tool = make_tool(dir.path(), provider);
405        let result = tool
406            .execute(json!({"prompt": "Summarise the current directory."}))
407            .await
408            .unwrap();
409        assert!(result.contains("type:general finished:"), "got: {result}");
410        assert!(result.contains("Task complete"), "got: {result}");
411    }
412
413    #[tokio::test]
414    async fn spawn_worker_explore_type() {
415        let dir = TempDir::new().unwrap();
416        let provider = mock_provider(vec![Completion {
417            content: "Found 3 Rust source files.".into(),
418            tool_calls: vec![],
419            finish_reason: Some("stop".into()),
420            usage: None,
421            reasoning_content: None,
422        }]);
423        let tool = make_tool(dir.path(), provider);
424        let result = tool
425            .execute(json!({"prompt": "List Rust files.", "worker_type": "explore"}))
426            .await
427            .unwrap();
428        assert!(result.contains("type:explore finished:"), "got: {result}");
429    }
430
431    #[tokio::test]
432    async fn spawn_worker_custom_system_prompt() {
433        let dir = TempDir::new().unwrap();
434        let provider = mock_provider(vec![Completion {
435            content: "Custom result".into(),
436            tool_calls: vec![],
437            finish_reason: Some("stop".into()),
438            usage: None,
439            reasoning_content: None,
440        }]);
441        let tool = make_tool(dir.path(), provider);
442        let result = tool
443            .execute(json!({
444                "prompt": "Do something.",
445                "worker_type": "coder",
446                "system_prompt": "You are a custom expert."
447            }))
448            .await
449            .unwrap();
450        // Should succeed and use the coder worker type label
451        assert!(result.contains("type:coder finished:"), "got: {result}");
452    }
453
454    #[tokio::test]
455    async fn spawn_worker_missing_prompt() {
456        let dir = TempDir::new().unwrap();
457        let provider = mock_provider(vec![]);
458        let tool = make_tool(dir.path(), provider);
459        let result = tool.execute(json!({"worker_type": "general"})).await;
460        assert!(result.is_err());
461        let err = result.unwrap_err();
462        assert!(
463            err.to_string().contains("missing required parameter"),
464            "got: {err}"
465        );
466    }
467}