opendev_tools_impl/agents/
spawn.rs1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use opendev_tools_core::{BaseTool, ToolContext, ToolResult};
6use tokio::sync::mpsc;
7use tokio_util::sync::CancellationToken;
8use tracing::{info, warn};
9
10use super::events::{ChannelProgressCallback, SubagentEvent};
11
12#[derive(Debug)]
18pub struct SpawnSubagentTool {
19 manager: Arc<opendev_agents::SubagentManager>,
21 tool_registry: Arc<opendev_tools_core::ToolRegistry>,
23 http_client: Arc<opendev_http::AdaptedClient>,
25 session_dir: PathBuf,
27 parent_model: String,
29 working_dir: String,
31 event_tx: Option<mpsc::UnboundedSender<SubagentEvent>>,
33 parent_max_tokens: u64,
35 parent_reasoning_effort: Option<String>,
37}
38
39impl SpawnSubagentTool {
40 pub fn new(
42 manager: Arc<opendev_agents::SubagentManager>,
43 tool_registry: Arc<opendev_tools_core::ToolRegistry>,
44 http_client: Arc<opendev_http::AdaptedClient>,
45 session_dir: PathBuf,
46 parent_model: impl Into<String>,
47 working_dir: impl Into<String>,
48 ) -> Self {
49 Self {
50 manager,
51 tool_registry,
52 http_client,
53 session_dir,
54 parent_model: parent_model.into(),
55 working_dir: working_dir.into(),
56 event_tx: None,
57 parent_max_tokens: 16384,
58 parent_reasoning_effort: None,
59 }
60 }
61
62 pub fn with_event_sender(mut self, tx: mpsc::UnboundedSender<SubagentEvent>) -> Self {
64 self.event_tx = Some(tx);
65 self
66 }
67
68 pub fn with_parent_max_tokens(mut self, max_tokens: u64) -> Self {
70 self.parent_max_tokens = max_tokens;
71 self
72 }
73
74 pub fn with_parent_reasoning_effort(mut self, effort: Option<String>) -> Self {
76 self.parent_reasoning_effort = effort;
77 self
78 }
79}
80
81#[async_trait::async_trait]
82impl BaseTool for SpawnSubagentTool {
83 fn name(&self) -> &str {
84 "spawn_subagent"
85 }
86
87 fn description(&self) -> &str {
88 "Spawn a subagent to handle an isolated task. The subagent runs its own \
89 ReAct loop with restricted tools and returns the result. Use for tasks \
90 that require multiple tool calls and benefit from isolated context \
91 (code exploration, summarization, codebase analysis, planning, web cloning, etc.). \
92 This is the correct tool for 'summarize the codebase', 'how does X work', \
93 'explore the code', etc. — NOT invoke_skill. \
94 Do NOT spawn a subagent for tasks that only need 1-2 tool calls — \
95 use the tools directly instead."
96 }
97
98 fn parameter_schema(&self) -> serde_json::Value {
99 let agent_names: Vec<String> = self.manager.names().iter().map(|s| s.to_string()).collect();
101
102 serde_json::json!({
103 "type": "object",
104 "properties": {
105 "agent_type": {
106 "type": "string",
107 "description": "The type of subagent to spawn.",
108 "enum": agent_names
109 },
110 "task": {
111 "type": "string",
112 "description": "Detailed task description for the subagent. \
113 Be specific: which directories to explore, which patterns to search, \
114 what questions to answer. When spawning multiple agents in parallel, \
115 each task MUST be distinct — split by directory or question."
116 },
117 "task_id": {
118 "type": "string",
119 "description": "Resume a previous subagent session by its task_id. \
120 If provided, the subagent continues from where it left off."
121 },
122 "working_dir": {
123 "type": "string",
124 "description": "Working directory for the subagent. Use this when the task \
125 targets a different directory than the current project \
126 (e.g., exploring another codebase at a specific path). \
127 The subagent's tools will resolve relative paths from this directory."
128 },
129 "description": {
130 "type": "string",
131 "description": "A short (3-8 word) summary of the task for display. \
132 Examples: 'Trace tool_call_count updates', 'Find auth middleware chain'."
133 }
134 },
135 "required": ["agent_type", "task"]
136 })
137 }
138
139 async fn execute(
140 &self,
141 args: HashMap<String, serde_json::Value>,
142 ctx: &ToolContext,
143 ) -> ToolResult {
144 let agent_type = match args.get("agent_type").and_then(|v| v.as_str()) {
145 Some(t) => t,
146 None => return ToolResult::fail("Missing required parameter: agent_type"),
147 };
148
149 let task = match args.get("task").and_then(|v| v.as_str()) {
150 Some(t) => t,
151 None => return ToolResult::fail("Missing required parameter: task"),
152 };
153
154 if ctx.is_subagent {
156 return ToolResult::fail(
157 "Subagents cannot spawn other subagents. Complete your task directly \
158 using the tools available to you.",
159 );
160 }
161
162 if self.manager.get(agent_type).is_none() {
164 return ToolResult::fail(format!("Unknown subagent type: {agent_type}"));
165 }
166
167 let agent_type_lower = agent_type.to_lowercase();
169 if agent_type_lower == "planner"
170 && let Some(ref shared) = ctx.shared_state
171 && let Ok(state) = shared.lock()
172 {
173 let phase = state
174 .get("planning_phase")
175 .and_then(|v| v.as_str())
176 .unwrap_or("");
177 if phase == "explore" {
178 return ToolResult::fail(
179 "Before planning, first list the current directory structure \
180 and review relevant files to understand the codebase context. \
181 Use list_files, read_file, or search, then spawn Planner.",
182 );
183 }
184 }
185
186 let task_id = args.get("task_id").and_then(|v| v.as_str());
187
188 info!(
189 agent_type = %agent_type,
190 task_len = task.len(),
191 resume = task_id.is_some(),
192 "spawn_subagent called"
193 );
194
195 let wd = {
197 let raw = if let Some(ewd) = args.get("working_dir").and_then(|v| v.as_str()) {
198 std::path::PathBuf::from(ewd)
199 } else if !ctx.working_dir.as_os_str().is_empty()
200 && ctx.working_dir != std::path::Path::new(".")
201 {
202 ctx.working_dir.clone()
203 } else {
204 std::path::PathBuf::from(&self.working_dir)
205 };
206
207 let resolved = if raw.is_relative() {
209 std::path::PathBuf::from(&self.working_dir).join(&raw)
210 } else {
211 raw
212 };
213
214 match resolved.canonicalize() {
216 Ok(p) if p.is_dir() => p.to_string_lossy().to_string(),
217 Ok(p) => {
218 return ToolResult::fail(format!(
219 "working_dir '{}' is not a directory",
220 p.display()
221 ));
222 }
223 Err(_) => {
224 return ToolResult::fail(format!(
225 "working_dir '{}' does not exist or cannot be resolved",
226 resolved.display()
227 ));
228 }
229 }
230 };
231
232 let child_session_id = task_id
234 .map(|id| id.to_string())
235 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
236
237 let subagent_id = uuid::Uuid::new_v4().to_string();
239
240 let subagent_cancel = if let Some(parent) = ctx.cancel_token.as_ref() {
242 parent.child_token()
243 } else {
244 CancellationToken::new()
245 };
246
247 let progress: Arc<dyn opendev_agents::SubagentProgressCallback> =
249 if let Some(ref tx) = self.event_tx {
250 Arc::new(ChannelProgressCallback::new(
251 tx.clone(),
252 subagent_id.clone(),
253 Some(subagent_cancel.clone()),
254 ))
255 } else {
256 Arc::new(opendev_agents::NoopProgressCallback)
257 };
258
259 let result = self
261 .manager
262 .spawn(
263 agent_type,
264 task,
265 &self.parent_model,
266 Arc::clone(&self.tool_registry),
267 Arc::clone(&self.http_client),
268 &wd,
269 progress,
270 None,
271 None,
272 self.parent_max_tokens,
273 self.parent_reasoning_effort.clone(),
274 Some(subagent_cancel),
275 )
276 .await;
277
278 match result {
279 Ok(run_result) => {
280 let child_mgr = opendev_history::SessionManager::new(self.session_dir.clone());
282 if let Ok(child_mgr) = child_mgr {
283 let mut session = opendev_models::session::Session::new();
284 session.id = child_session_id.clone();
285 session.parent_id = ctx.session_id.clone();
286 session.working_directory = Some(self.working_dir.clone());
287 session.metadata.insert(
288 "title".to_string(),
289 serde_json::json!(format!(
290 "{} (@{})",
291 task.chars().take(80).collect::<String>(),
292 agent_type
293 )),
294 );
295 session
296 .metadata
297 .insert("subagent_type".to_string(), serde_json::json!(agent_type));
298 let messages = opendev_history::message_convert::api_values_to_chatmessages(
299 &run_result.agent_result.messages,
300 );
301 session.messages = messages;
302 let _ = child_mgr.save_session(&session);
303 }
304
305 let interrupted = run_result.agent_result.interrupted;
307 let mut output = format!("__subagent_stats__:tc={}\n", run_result.tool_call_count);
308 output.push_str(&format!("task_id: {child_session_id} (for resuming)\n\n"));
309 if interrupted {
310 output.push_str("[WARNING: subagent was interrupted — result is partial]\n\n");
311 }
312
313 const MAX_SUBAGENT_OUTPUT: usize = 50 * 1024;
314 let content = &run_result.agent_result.content;
315 if content.len() > MAX_SUBAGENT_OUTPUT {
316 let half = MAX_SUBAGENT_OUTPUT / 2;
317 output.push_str(&format!(
318 "[WARNING: output truncated from {} to {} chars — result may be incomplete]\n\n",
319 content.len(),
320 MAX_SUBAGENT_OUTPUT
321 ));
322 output.push_str(opendev_runtime::safe_truncate(content, half));
323 output.push_str(&format!(
324 "\n\n[...truncated {} chars...]\n\n",
325 content.len() - MAX_SUBAGENT_OUTPUT
326 ));
327 let mut tail_start = content.len() - half;
329 while tail_start < content.len() && !content.is_char_boundary(tail_start) {
330 tail_start += 1;
331 }
332 output.push_str(&content[tail_start..]);
333 } else {
334 output.push_str(content);
335 }
336
337 output = clean_subagent_output(&output);
339
340 if let Some(ref warning) = run_result.shallow_warning {
341 output.push_str(warning);
342 }
343
344 let effective_success = run_result.agent_result.success && !interrupted;
346 if let Some(ref tx) = self.event_tx {
347 let _ = tx.send(SubagentEvent::Finished {
348 subagent_id: subagent_id.clone(),
349 subagent_name: agent_type.to_string(),
350 success: effective_success,
351 result_summary: if content.len() > 200 {
352 format!("{}...", opendev_runtime::safe_truncate(content, 200))
353 } else {
354 content.clone()
355 },
356 tool_call_count: run_result.tool_call_count,
357 shallow_warning: run_result.shallow_warning,
358 });
359 }
360
361 if agent_type_lower == "explore"
363 && let Some(ref shared) = ctx.shared_state
364 && let Ok(mut state) = shared.lock()
365 {
366 let count = state
367 .get("explore_count")
368 .and_then(|v| v.as_u64())
369 .unwrap_or(0);
370 state.insert("explore_count".into(), serde_json::json!(count + 1));
371 if state.get("planning_phase").and_then(|v| v.as_str()) == Some("explore") {
372 state.insert("planning_phase".into(), serde_json::json!("plan"));
373 }
374 }
375
376 ToolResult::ok(output)
377 }
378 Err(e) => {
379 warn!(agent_type = %agent_type, error = %e, "Subagent failed");
380 if let Some(ref tx) = self.event_tx {
382 let _ = tx.send(SubagentEvent::Finished {
383 subagent_id,
384 subagent_name: agent_type.to_string(),
385 success: false,
386 result_summary: e.to_string(),
387 tool_call_count: 0,
388 shallow_warning: None,
389 });
390 }
391 ToolResult::fail(format!("Subagent failed: {e}"))
392 }
393 }
394 }
395}
396
397fn clean_subagent_output(text: &str) -> String {
400 let mut result = String::with_capacity(text.len());
401 for line in text.lines() {
402 let trimmed = line.trim();
403 if (trimmed.starts_with("---") || trimmed.starts_with("***") || trimmed.starts_with("___"))
405 && trimmed
406 .chars()
407 .all(|c| c == '-' || c == '*' || c == '_' || c == ' ')
408 && trimmed.len() >= 3
409 {
410 result.push('\n');
411 continue;
412 }
413 if trimmed.starts_with('<') && trimmed.ends_with('>') {
415 continue;
416 }
417 if trimmed.starts_with('|') && trimmed.ends_with('|') {
419 if trimmed.contains("---") {
421 continue;
422 }
423 let cleaned: String = trimmed
424 .trim_matches('|')
425 .split('|')
426 .map(|cell| cell.trim())
427 .collect::<Vec<_>>()
428 .join(" ");
429 result.push_str(&cleaned);
430 result.push('\n');
431 continue;
432 }
433 result.push_str(line);
434 result.push('\n');
435 }
436 if result.ends_with('\n') && !text.ends_with('\n') {
438 result.pop();
439 }
440 result
441}
442
443#[cfg(test)]
444#[path = "spawn_tests.rs"]
445mod tests;