Skip to main content

opendev_tools_impl/agents/
spawn.rs

1use 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/// Tool that spawns and runs a subagent to handle an isolated task.
13///
14/// The LLM calls this tool with a subagent type and task description.
15/// The tool creates an isolated agent with its own ReAct loop, runs it,
16/// and returns the result back to the parent agent.
17#[derive(Debug)]
18pub struct SpawnSubagentTool {
19    /// Subagent manager holding registered specs.
20    manager: Arc<opendev_agents::SubagentManager>,
21    /// Full tool registry (subagents filter to their allowed subset).
22    tool_registry: Arc<opendev_tools_core::ToolRegistry>,
23    /// HTTP client for LLM API calls.
24    http_client: Arc<opendev_http::AdaptedClient>,
25    /// Session directory for persisting child sessions.
26    session_dir: PathBuf,
27    /// Parent agent's model (used as fallback).
28    parent_model: String,
29    /// Working directory for tool execution.
30    working_dir: String,
31    /// Optional channel for sending progress events to the TUI.
32    event_tx: Option<mpsc::UnboundedSender<SubagentEvent>>,
33    /// Parent agent's max_tokens from model registry (subagents inherit this as fallback).
34    parent_max_tokens: u64,
35    /// Parent agent's reasoning effort (subagents inherit this).
36    parent_reasoning_effort: Option<String>,
37}
38
39impl SpawnSubagentTool {
40    /// Create a new spawn subagent tool.
41    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    /// Set the event channel for progress reporting.
63    pub fn with_event_sender(mut self, tx: mpsc::UnboundedSender<SubagentEvent>) -> Self {
64        self.event_tx = Some(tx);
65        self
66    }
67
68    /// Set the parent agent's max_tokens (subagents inherit this as fallback).
69    pub fn with_parent_max_tokens(mut self, max_tokens: u64) -> Self {
70        self.parent_max_tokens = max_tokens;
71        self
72    }
73
74    /// Set the parent agent's reasoning effort (subagents inherit this).
75    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        // Build enum of available subagent types from manager
100        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        // Prevent recursive subagent spawning (subagents spawning subagents).
155        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        // Validate agent type exists before spawning background task
163        if self.manager.get(agent_type).is_none() {
164            return ToolResult::fail(format!("Unknown subagent type: {agent_type}"));
165        }
166
167        // Soft guard: block Planner spawn during explore phase
168        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        // Pick raw path: explicit arg > context working_dir > configured default
196        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            // Resolve relative paths against configured default working directory
208            let resolved = if raw.is_relative() {
209                std::path::PathBuf::from(&self.working_dir).join(&raw)
210            } else {
211                raw
212            };
213
214            // Canonicalize to resolve symlinks and .. components
215            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        // Generate child session ID (reuse task_id for resume, new UUID otherwise)
233        let child_session_id = task_id
234            .map(|id| id.to_string())
235            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
236
237        // Unique ID for this subagent instance (disambiguates parallel subagents)
238        let subagent_id = uuid::Uuid::new_v4().to_string();
239
240        // Create per-subagent child cancellation token
241        let subagent_cancel = if let Some(parent) = ctx.cancel_token.as_ref() {
242            parent.child_token()
243        } else {
244            CancellationToken::new()
245        };
246
247        // Create progress callback
248        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        // Execute subagent synchronously — blocking until it completes
260        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                // Save child session for future resume
281                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                // Build output for injection
306                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                    // Take last `half` bytes, walking forward to a char boundary
328                    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                // Clean up markdown constructs that the TUI renderer can't handle
338                output = clean_subagent_output(&output);
339
340                if let Some(ref warning) = run_result.shallow_warning {
341                    output.push_str(warning);
342                }
343
344                // Send finished event to TUI
345                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                // Track explore subagent completion for planning phase transition
362                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                // Send finished event to TUI
381                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
397/// Clean subagent output by stripping markdown constructs that the TUI
398/// renderer doesn't handle (horizontal rules, HTML tags, table syntax).
399fn 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        // Strip horizontal rules (---, ***, ___)
404        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        // Strip HTML tags (e.g. <br>, <div>, </div>)
414        if trimmed.starts_with('<') && trimmed.ends_with('>') {
415            continue;
416        }
417        // Simplify table rows: | col1 | col2 | → col1  col2
418        if trimmed.starts_with('|') && trimmed.ends_with('|') {
419            // Skip separator rows like |---|---|
420            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    // Remove trailing newline added by the loop
437    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;