1use 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#[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 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 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
119pub 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 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 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 ToolSideEffect::External
217 }
218
219 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 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 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 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 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 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 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#[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 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}