1use std::path::PathBuf;
11use std::sync::Arc;
12
13use serde::{Deserialize, Serialize};
14use tokio::sync::mpsc::UnboundedSender;
15use tracing::info;
16use uuid::Uuid;
17
18use crate::error::{AgentId, SdkResult};
19use crate::tools::command_tools::RunCommandTool;
20use crate::tools::fs_tools::{ListDirectoryTool, ReadFileTool, WriteFileTool};
21use crate::tools::registry::ToolRegistry;
22use crate::tools::search_tools::SearchFilesTool;
23use crate::tools::web_tools::WebSearchTool;
24use crate::traits::llm_client::LlmClient;
25use crate::traits::tool::Tool;
26
27use super::agent_loop::{AgentLoop, AgentLoopResult};
28use super::events::AgentEvent;
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct SubAgentDef {
35 pub name: String,
37 pub description: String,
39 pub prompt: String,
41 #[serde(default)]
44 pub allowed_tools: Vec<String>,
45 #[serde(default)]
47 pub disallowed_tools: Vec<String>,
48 #[serde(default)]
50 pub model: Option<String>,
51 #[serde(default = "default_max_turns")]
53 pub max_turns: usize,
54 #[serde(default = "default_max_context_tokens")]
56 pub max_context_tokens: usize,
57 #[serde(default)]
59 pub background: bool,
60}
61
62fn default_max_turns() -> usize {
63 30
64}
65
66fn default_max_context_tokens() -> usize {
67 200_000
68}
69
70impl SubAgentDef {
71 pub fn new(
73 name: impl Into<String>,
74 description: impl Into<String>,
75 prompt: impl Into<String>,
76 ) -> Self {
77 Self {
78 name: name.into(),
79 description: description.into(),
80 prompt: prompt.into(),
81 allowed_tools: Vec::new(),
82 disallowed_tools: Vec::new(),
83 model: None,
84 max_turns: default_max_turns(),
85 max_context_tokens: default_max_context_tokens(),
86 background: false,
87 }
88 }
89
90 pub fn with_allowed_tools(mut self, tools: Vec<String>) -> Self {
92 self.allowed_tools = tools;
93 self
94 }
95
96 pub fn with_disallowed_tools(mut self, tools: Vec<String>) -> Self {
98 self.disallowed_tools = tools;
99 self
100 }
101
102 pub fn with_model(mut self, model: impl Into<String>) -> Self {
104 self.model = Some(model.into());
105 self
106 }
107
108 pub fn with_max_turns(mut self, max_turns: usize) -> Self {
110 self.max_turns = max_turns;
111 self
112 }
113
114 pub fn with_max_context_tokens(mut self, tokens: usize) -> Self {
116 self.max_context_tokens = tokens;
117 self
118 }
119
120 pub fn with_background(mut self, background: bool) -> Self {
122 self.background = background;
123 self
124 }
125}
126
127#[derive(Debug, Clone, Serialize)]
129pub struct SubAgentResult {
130 pub agent_id: AgentId,
131 pub name: String,
132 pub final_content: String,
133 pub total_tokens: u64,
134 pub iterations: usize,
135 pub tool_calls_count: usize,
136}
137
138impl From<(AgentId, &str, AgentLoopResult)> for SubAgentResult {
139 fn from((agent_id, name, result): (AgentId, &str, AgentLoopResult)) -> Self {
140 Self {
141 agent_id,
142 name: name.to_string(),
143 final_content: result.final_content,
144 total_tokens: result.total_tokens,
145 iterations: result.iterations,
146 tool_calls_count: result.tool_calls_count,
147 }
148 }
149}
150
151pub struct SubAgentRunner {
157 pub work_dir: PathBuf,
158 pub source_root: PathBuf,
159 pub llm_client: Arc<dyn LlmClient>,
160 pub event_tx: Option<UnboundedSender<AgentEvent>>,
161 pub override_llm_client: Option<Arc<dyn LlmClient>>,
163}
164
165impl SubAgentRunner {
166 pub fn new(
167 work_dir: PathBuf,
168 source_root: PathBuf,
169 llm_client: Arc<dyn LlmClient>,
170 ) -> Self {
171 Self {
172 work_dir,
173 source_root,
174 llm_client,
175 event_tx: None,
176 override_llm_client: None,
177 }
178 }
179
180 pub fn with_event_sink(mut self, tx: UnboundedSender<AgentEvent>) -> Self {
181 self.event_tx = Some(tx);
182 self
183 }
184
185 pub fn with_override_llm_client(mut self, client: Arc<dyn LlmClient>) -> Self {
186 self.override_llm_client = Some(client);
187 self
188 }
189
190 pub async fn run(
194 &self,
195 def: &SubAgentDef,
196 task_prompt: &str,
197 ) -> SdkResult<SubAgentResult> {
198 let agent_id = Uuid::new_v4();
199 let client = self
200 .override_llm_client
201 .clone()
202 .unwrap_or_else(|| self.llm_client.clone());
203
204 info!(
205 agent_id = %agent_id,
206 subagent = %def.name,
207 "Spawning subagent"
208 );
209
210 self.emit(AgentEvent::SubAgentSpawned {
211 agent_id,
212 name: def.name.clone(),
213 description: def.description.clone(),
214 });
215
216 let tools = self.build_tools(def);
218
219 let system_prompt = crate::prompts::subagent_system_prompt(
221 &def.prompt,
222 &self.source_root,
223 &self.work_dir,
224 );
225
226 let mut agent_loop = AgentLoop::new(
227 agent_id,
228 client,
229 tools,
230 system_prompt,
231 def.max_turns,
232 )
233 .with_max_context_tokens(def.max_context_tokens)
234 .with_agent_name(&def.name);
235
236 if let Some(ref tx) = self.event_tx {
237 agent_loop.set_event_sink(tx.clone());
238 }
239
240 match agent_loop.run(task_prompt.to_string()).await {
241 Ok(loop_result) => {
242 let result = SubAgentResult::from((agent_id, def.name.as_str(), loop_result));
243
244 self.emit(AgentEvent::SubAgentCompleted {
245 agent_id,
246 name: def.name.clone(),
247 tokens_used: result.total_tokens,
248 iterations: result.iterations,
249 tool_calls: result.tool_calls_count,
250 final_content: result.final_content.clone(),
251 });
252
253 Ok(result)
254 }
255 Err(e) => {
256 self.emit(AgentEvent::SubAgentFailed {
257 agent_id,
258 name: def.name.clone(),
259 error: e.to_string(),
260 });
261 Err(e)
262 }
263 }
264 }
265
266 pub fn run_background(
268 &self,
269 def: SubAgentDef,
270 task_prompt: String,
271 ) -> tokio::task::JoinHandle<SdkResult<SubAgentResult>> {
272 let runner = SubAgentRunner {
273 work_dir: self.work_dir.clone(),
274 source_root: self.source_root.clone(),
275 llm_client: self.llm_client.clone(),
276 event_tx: self.event_tx.clone(),
277 override_llm_client: self.override_llm_client.clone(),
278 };
279
280 tokio::spawn(async move { runner.run(&def, &task_prompt).await })
281 }
282
283 fn build_tools(&self, def: &SubAgentDef) -> ToolRegistry {
285 let all_tools: Vec<(String, Arc<dyn Tool>)> = vec![
288 (
289 "read_file".to_string(),
290 Arc::new(ReadFileTool {
291 source_root: self.source_root.clone(),
292 work_dir: self.work_dir.clone(),
293 }),
294 ),
295 (
296 "write_file".to_string(),
297 Arc::new(WriteFileTool {
298 work_dir: self.work_dir.clone(),
299 }),
300 ),
301 (
302 "list_directory".to_string(),
303 Arc::new(ListDirectoryTool {
304 source_root: self.source_root.clone(),
305 work_dir: self.work_dir.clone(),
306 }),
307 ),
308 (
309 "search_files".to_string(),
310 Arc::new(SearchFilesTool {
311 source_root: self.source_root.clone(),
312 }),
313 ),
314 (
315 "web_search".to_string(),
316 Arc::new(WebSearchTool),
317 ),
318 (
319 "run_command".to_string(),
320 Arc::new(RunCommandTool::with_defaults(self.work_dir.clone())),
321 ),
322 ];
323
324 let allowed_set: Option<std::collections::HashSet<&str>> =
325 if def.allowed_tools.is_empty() {
326 None
327 } else {
328 Some(def.allowed_tools.iter().map(|s| s.as_str()).collect())
329 };
330 let denied_set: std::collections::HashSet<&str> =
331 def.disallowed_tools.iter().map(|s| s.as_str()).collect();
332
333 let mut registry = ToolRegistry::new();
334 for (name, tool) in all_tools {
335 let pass_allow = match &allowed_set {
336 Some(set) => set.contains(name.as_str()),
337 None => true,
338 };
339 let pass_deny = !denied_set.contains(name.as_str());
340 if pass_allow && pass_deny {
341 registry.register(tool);
342 }
343 }
344
345 registry
346 }
347
348 fn emit(&self, event: AgentEvent) {
349 if let Some(ref tx) = self.event_tx {
350 let _ = tx.send(event);
351 }
352 }
353}
354
355#[derive(Debug, Clone, Default)]
357pub struct SubAgentRegistry {
358 defs: Vec<SubAgentDef>,
359}
360
361impl SubAgentRegistry {
362 pub fn new() -> Self {
363 Self { defs: Vec::new() }
364 }
365
366 pub fn register(&mut self, def: SubAgentDef) {
368 self.defs.retain(|d| d.name != def.name);
370 self.defs.push(def);
371 }
372
373 pub fn get(&self, name: &str) -> Option<&SubAgentDef> {
375 self.defs.iter().find(|d| d.name == name)
376 }
377
378 pub fn list(&self) -> &[SubAgentDef] {
380 &self.defs
381 }
382
383 pub fn is_empty(&self) -> bool {
385 self.defs.is_empty()
386 }
387}
388
389pub fn builtin_subagents() -> Vec<SubAgentDef> {
391 vec![
392 SubAgentDef {
393 name: "explore".to_string(),
394 description: "Fast, read-only agent for searching and analyzing codebases. \
395 Use when you need to quickly find files, search code, or understand \
396 the codebase without making changes. Keeps exploration out of your \
397 main context."
398 .to_string(),
399 prompt: "You are a codebase exploration specialist. Your job is to search, \
400 read, and analyze code efficiently. Report your findings concisely.\n\n\
401 You have read-only access. Do NOT attempt to modify any files."
402 .to_string(),
403 allowed_tools: vec![
404 "read_file".to_string(),
405 "list_directory".to_string(),
406 "search_files".to_string(),
407 "run_command".to_string(),
408 ],
409 disallowed_tools: vec![
410 "write_file".to_string(),
411 ],
412 model: None,
413 max_turns: 20,
414 max_context_tokens: 200_000,
415 background: false,
416 },
417 SubAgentDef {
418 name: "plan".to_string(),
419 description: "Research agent for gathering context before presenting a plan. \
420 Use when you need to understand the codebase to plan an implementation \
421 strategy."
422 .to_string(),
423 prompt: "You are a software architect. Analyze the codebase and produce a \
424 detailed implementation plan. Include:\n\
425 1. What files need to be read/created/modified\n\
426 2. The approach and key decisions\n\
427 3. Potential risks or edge cases\n\
428 4. Verification steps\n\n\
429 You have read-only access. Do NOT attempt to modify any files."
430 .to_string(),
431 allowed_tools: vec![
432 "read_file".to_string(),
433 "list_directory".to_string(),
434 "search_files".to_string(),
435 "run_command".to_string(),
436 ],
437 disallowed_tools: vec![
438 "write_file".to_string(),
439 ],
440 model: None,
441 max_turns: 25,
442 max_context_tokens: 200_000,
443 background: false,
444 },
445 SubAgentDef {
446 name: "general-purpose".to_string(),
447 description: "Capable agent for complex, multi-step tasks requiring both \
448 exploration and action. Use for research, multi-step operations, or \
449 code modifications that benefit from isolated context."
450 .to_string(),
451 prompt: "You are an expert coding assistant handling a delegated task. \
452 Work independently and return a clear, concise result summary. \
453 Read files before modifying them. Verify your work."
454 .to_string(),
455 allowed_tools: Vec::new(), disallowed_tools: Vec::new(),
457 model: None,
458 max_turns: 30,
459 max_context_tokens: 200_000,
460 background: false,
461 },
462 ]
463}