Skip to main content

matrixcode_core/tools/
subagent_executor.rs

1//! Subagent Executor - Real agent instance execution for parallel tasks.
2//!
3//! This module implements true subagent execution by creating lightweight Agent
4//! instances that can run independently with optional worktree isolation.
5
6use anyhow::Result;
7use std::collections::HashMap;
8use std::path::PathBuf;
9use std::sync::Arc;
10use tokio::sync::mpsc;
11use uuid::Uuid;
12
13use crate::agent::{Agent, AgentBuilder};
14use crate::cancel::CancellationToken;
15use crate::event::AgentEvent;
16use crate::providers::create_minimal_provider;
17use crate::prompt::PromptProfile;
18use crate::tools::Tool;
19
20/// Subagent task definition
21#[derive(Debug, Clone)]
22pub struct SubagentTask {
23    /// Unique task identifier
24    pub id: String,
25    /// Task description (3-5 words)
26    pub description: String,
27    /// Full task prompt with context
28    pub prompt: String,
29    /// Agent type: "general-purpose", "Explore", "Plan"
30    pub subagent_type: String,
31    /// Isolation mode: "none", "worktree"
32    pub isolation: String,
33    /// Working directory path
34    pub work_path: Option<PathBuf>,
35}
36
37/// Subagent execution result
38#[derive(Debug, Clone)]
39pub struct SubagentResult {
40    /// Task identifier
41    pub task_id: String,
42    /// Execution output content
43    pub content: String,
44    /// Success status
45    pub success: bool,
46    /// Token usage statistics
47    pub usage: TokenUsage,
48}
49
50/// Token usage for subagent
51#[derive(Debug, Clone, Default)]
52pub struct TokenUsage {
53    pub input_tokens: u64,
54    pub output_tokens: u64,
55}
56
57/// Configuration for subagent executor
58#[derive(Debug, Clone)]
59pub struct SubagentConfig {
60    /// Model name for subagent (typically a fast model)
61    pub model_name: String,
62    /// Maximum tokens for subagent responses
63    pub max_tokens: u32,
64    /// System prompt prefix for subagent
65    pub system_prompt_prefix: Option<String>,
66    /// Whether to enable thinking mode
67    pub think: bool,
68    /// Tools to include (subset of main agent tools)
69    pub tool_names: Option<Vec<String>>,
70}
71
72impl Default for SubagentConfig {
73    fn default() -> Self {
74        Self {
75            model_name: "claude-sonnet-4-20250514".to_string(),
76            max_tokens: 4096,
77            system_prompt_prefix: None,
78            think: false,
79            tool_names: None,
80        }
81    }
82}
83
84/// Subagent executor - manages parallel agent execution
85pub struct SubagentExecutor {
86    /// Configuration for subagents
87    config: SubagentConfig,
88    /// Main agent's event channel (for forwarding events)
89    event_tx: mpsc::Sender<AgentEvent>,
90    /// Active task cancellation tokens
91    cancel_tokens: HashMap<String, CancellationToken>,
92    /// Available tools from main agent
93    tools: Vec<Arc<dyn Tool>>,
94}
95
96impl SubagentExecutor {
97    /// Create a new subagent executor
98    ///
99    /// # Arguments
100    /// * `config` - Subagent configuration
101    /// * `event_tx` - Main agent's event sender for forwarding
102    /// * `tools` - Available tools from main agent
103    pub fn new(
104        config: SubagentConfig,
105        event_tx: mpsc::Sender<AgentEvent>,
106        tools: Vec<Arc<dyn Tool>>,
107    ) -> Self {
108        Self {
109            config,
110            event_tx,
111            cancel_tokens: HashMap::new(),
112            tools,
113        }
114    }
115
116    /// Create with default configuration
117    pub fn with_defaults(
118        event_tx: mpsc::Sender<AgentEvent>,
119        tools: Vec<Arc<dyn Tool>>,
120    ) -> Self {
121        Self::new(SubagentConfig::default(), event_tx, tools)
122    }
123
124    /// Execute a single subagent task
125    ///
126    /// Creates a lightweight Agent instance and runs the task.
127    /// Events are forwarded to the main agent's event channel.
128    pub async fn execute(&mut self, task: SubagentTask) -> Result<SubagentResult> {
129        let cancel_token = CancellationToken::new();
130        self.cancel_tokens.insert(task.id.clone(), cancel_token.clone());
131
132        // Create event channel for this subagent
133        let (subagent_tx, mut subagent_rx) = mpsc::channel::<AgentEvent>(100);
134
135        // Build the subagent
136        let provider = create_minimal_provider(&self.config.model_name);
137        let work_path = task.work_path.clone().unwrap_or_else(|| {
138            std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
139        });
140
141        // Filter tools based on configuration
142        let filtered_tools = self.filter_tools(&task.subagent_type);
143
144        // Build system prompt based on agent type
145        let system_prompt = self.build_system_prompt(&task.subagent_type, &task.description);
146
147        // Create lightweight agent
148        let mut agent = AgentBuilder::new(provider)
149            .model_name(&self.config.model_name)
150            .max_tokens(self.config.max_tokens)
151            .system_prompt(system_prompt)
152            .think(self.config.think)
153            .tools_with_provider(filtered_tools)
154            .project_path(work_path)
155            .event_tx(subagent_tx)
156            .profile(PromptProfile::Default)
157            .build();
158
159        agent.set_cancel_token(cancel_token);
160
161        // Start event forwarder before running the agent
162        let event_forwarder = tokio::spawn({
163            let main_tx = self.event_tx.clone();
164            let task_id = task.id.clone();
165            async move {
166                while let Some(event) = subagent_rx.recv().await {
167                    // Tag events with subagent context
168                    let tagged_event = Self::tag_event(event, &task_id);
169                    if main_tx.send(tagged_event).await.is_err() {
170                        break;
171                    }
172                }
173            }
174        });
175
176        // Run the agent
177        let run_result = agent.run(task.prompt.clone()).await;
178
179        // Wait for event forwarder to finish
180        event_forwarder.abort();
181
182        // Cleanup
183        self.cancel_tokens.remove(&task.id);
184
185        // Get token usage
186        let (input_tokens, output_tokens) = agent.get_token_counts();
187
188        match run_result {
189            Ok(_) => Ok(SubagentResult {
190                task_id: task.id.clone(),
191                content: Self::extract_content(&agent),
192                success: true,
193                usage: TokenUsage {
194                    input_tokens,
195                    output_tokens,
196                },
197            }),
198            Err(e) => Ok(SubagentResult {
199                task_id: task.id.clone(),
200                content: format!("Task failed: {}", e),
201                success: false,
202                usage: TokenUsage {
203                    input_tokens,
204                    output_tokens,
205                },
206            }),
207        }
208    }
209
210    /// Execute multiple tasks in parallel
211    ///
212    /// Spawns concurrent subagent tasks and collects results.
213    /// Each task runs independently with its own agent instance.
214    pub async fn execute_parallel(&mut self, tasks: Vec<SubagentTask>) -> Result<Vec<SubagentResult>> {
215        let mut results = Vec::with_capacity(tasks.len());
216        let mut futures = Vec::new();
217
218        for task in tasks {
219            // Create a separate executor for each parallel task
220            let config = self.config.clone();
221            let event_tx = self.event_tx.clone();
222            let tools = self.tools.clone();
223
224            let future = tokio::spawn(async move {
225                let mut executor = SubagentExecutor::new(config, event_tx, tools);
226                executor.execute(task).await
227            });
228
229            futures.push(future);
230        }
231
232        // Wait for all tasks to complete
233        for future in futures {
234            match future.await {
235                Ok(result) => {
236                    if let Ok(r) = result {
237                        results.push(r);
238                    }
239                }
240                Err(e) => {
241                    log::error!("Parallel task failed: {}", e);
242                }
243            }
244        }
245
246        Ok(results)
247    }
248
249    /// Cancel a running task
250    pub async fn cancel(&mut self, task_id: &str) -> Result<()> {
251        if let Some(token) = self.cancel_tokens.get(task_id) {
252            token.cancel();
253            log::info!("Task {} cancelled", task_id);
254        }
255        Ok(())
256    }
257
258    /// Check if a task is cancelled
259    pub fn is_cancelled(&self, task_id: &str) -> bool {
260        self.cancel_tokens
261            .get(task_id)
262            .map(|t| t.is_cancelled())
263            .unwrap_or(false)
264    }
265
266    // ========================================================================
267    // Internal Helper Methods
268    // ========================================================================
269
270    /// Filter tools based on agent type
271    fn filter_tools(&self, subagent_type: &str) -> Vec<Box<dyn Tool>> {
272        // For Explore agents, only use read-only tools
273        let read_only_tools = vec![
274            "read", "grep", "glob", "ls", "search",
275            "code_search", "code_callers", "code_callees", "code_status",
276        ];
277
278        // For Plan agents, use read-only + plan tools
279        let plan_tools = vec![
280            "read", "grep", "glob", "ls", "search",
281            "code_search", "code_callers", "code_callees", "code_status",
282            "enter_plan_mode", "exit_plan_mode", "todo_write",
283        ];
284
285        let allowed_tools = match subagent_type {
286            "Explore" => &read_only_tools,
287            "Plan" => &plan_tools,
288            _ => return Vec::new(), // General purpose gets all tools
289        };
290
291        // Filter from available tools
292        self.tools
293            .iter()
294            .filter(|t| {
295                let name = t.definition().name;
296                allowed_tools.contains(&name.as_str())
297            })
298            .map(|t| {
299                // Clone Arc<dyn Tool> to Box<dyn Tool>
300                // We need to create a new boxed instance
301                Self::arc_to_box_tool(t.clone())
302            })
303            .collect()
304    }
305
306    /// Convert Arc<dyn Tool> to Box<dyn Tool>
307    /// This is needed because AgentBuilder expects Box<dyn Tool>
308    fn arc_to_box_tool(arc_tool: Arc<dyn Tool>) -> Box<dyn Tool> {
309        // For now, we'll return a minimal subset
310        // In production, this would need proper tool cloning
311        // We use the tools from the main agent directly
312        // This is a workaround - actual implementation would need
313        // proper tool factory or clone mechanism
314        Box::new(SubagentToolWrapper(arc_tool))
315    }
316
317    /// Build system prompt based on agent type
318    fn build_system_prompt(&self, subagent_type: &str, description: &str) -> String {
319        let base_prompt = self.config.system_prompt_prefix.clone()
320            .unwrap_or_else(|| "You are a helpful AI assistant.".to_string());
321
322        match subagent_type {
323            "Explore" => {
324                format!(
325                    "{}\n\nYou are an Explore agent focused on fast, read-only search operations.\n\
326                    Your task: {}\n\n\
327                    Rules:\n\
328                    - Only use read-only tools (read, grep, glob, ls, search, code_search)\n\
329                    - Do not modify any files\n\
330                    - Provide concise summaries of findings\n\
331                    - Complete quickly and report results",
332                    base_prompt, description
333                )
334            }
335            "Plan" => {
336                format!(
337                    "{}\n\nYou are a Plan agent focused on architecture and planning.\n\
338                    Your task: {}\n\n\
339                    Rules:\n\
340                    - Analyze the codebase structure\n\
341                    - Create implementation plans\n\
342                    - Use todo_write to track plan steps\n\
343                    - Provide clear, actionable recommendations\n\
344                    - Do not modify files unless explicitly asked",
345                    base_prompt, description
346                )
347            }
348            _ => {
349                format!(
350                    "{}\n\nYou are a general-purpose agent handling a subtask.\n\
351                    Your task: {}\n\n\
352                    Complete the task efficiently and report your results.",
353                    base_prompt, description
354                )
355            }
356        }
357    }
358
359    /// Tag event with subagent context
360    fn tag_event(event: AgentEvent, task_id: &str) -> AgentEvent {
361        // Add subagent prefix to text events for identification
362        if let Some(ref data) = event.data {
363            if let crate::event::EventData::Text { delta } = data {
364                return AgentEvent::with_data(
365                    event.event_type,
366                    crate::event::EventData::Text {
367                        delta: format!("[Subagent {}] {}", task_id, delta),
368                    },
369                );
370            }
371        }
372        event
373    }
374
375    /// Extract final content from agent messages
376    fn extract_content(agent: &Agent) -> String {
377        // Get the last assistant message
378        let messages = agent.get_messages();
379        for msg in messages.iter().rev() {
380            if msg.role == crate::providers::Role::Assistant {
381                match &msg.content {
382                    crate::providers::MessageContent::Text(text) => {
383                        return text.clone();
384                    }
385                    crate::providers::MessageContent::Blocks(blocks) => {
386                        // Extract text from blocks
387                        let texts: Vec<String> = blocks
388                            .iter()
389                            .filter_map(|b| {
390                                if let crate::providers::ContentBlock::Text { text } = b {
391                                    Some(text.clone())
392                                } else {
393                                    None
394                                }
395                            })
396                            .collect();
397                        return texts.join("\n");
398                    }
399                }
400            }
401        }
402        "No output generated".to_string()
403    }
404}
405
406/// Wrapper to convert Arc<dyn Tool> to Box<dyn Tool>
407/// This is a workaround for the tool cloning issue
408struct SubagentToolWrapper(Arc<dyn Tool>);
409
410#[async_trait::async_trait]
411impl Tool for SubagentToolWrapper {
412    fn definition(&self) -> crate::tools::ToolDefinition {
413        self.0.definition()
414    }
415
416    async fn execute(&self, params: serde_json::Value) -> Result<String> {
417        self.0.execute(params).await
418    }
419
420    fn risk_level(&self) -> crate::approval::RiskLevel {
421        self.0.risk_level()
422    }
423}
424
425/// Create a SubagentTask from parameters
426pub fn create_task(
427    description: &str,
428    prompt: &str,
429    subagent_type: &str,
430    isolation: &str,
431) -> SubagentTask {
432    let id = Uuid::new_v4().to_string();
433
434    let work_path = if isolation == "worktree" {
435        // Create temporary worktree path
436        let temp_dir = std::env::temp_dir()
437            .join(format!("matrixcode-task-{}", id));
438        Some(temp_dir)
439    } else {
440        None
441    };
442
443    SubagentTask {
444        id,
445        description: description.to_string(),
446        prompt: prompt.to_string(),
447        subagent_type: subagent_type.to_string(),
448        isolation: isolation.to_string(),
449        work_path,
450    }
451}
452
453/// Setup worktree isolation for a task
454pub async fn setup_worktree(task: &SubagentTask) -> Result<PathBuf> {
455    if task.isolation != "worktree" {
456        return Ok(std::env::current_dir()?);
457    }
458
459    let work_path = task.work_path.clone().unwrap_or_else(|| {
460        std::env::temp_dir()
461            .join(format!("matrixcode-task-{}", task.id))
462    });
463
464    // Create the directory
465    std::fs::create_dir_all(&work_path)?;
466
467    // In a real implementation, this would:
468    // 1. Run `git worktree add <path> <branch>`
469    // 2. Set up the environment for isolated work
470    // 3. Track the worktree for cleanup
471
472    log::info!("Worktree created at {} for task {}", work_path.display(), task.id);
473
474    Ok(work_path)
475}
476
477/// Cleanup worktree after task completion
478pub async fn cleanup_worktree(task: &SubagentTask) -> Result<()> {
479    if task.isolation != "worktree" {
480        return Ok(());
481    }
482
483    if let Some(path) = &task.work_path {
484        // In a real implementation, this would:
485        // 1. Run `git worktree remove <path>`
486        // 2. Delete the directory
487
488        if path.exists() {
489            std::fs::remove_dir_all(path)?;
490            log::info!("Worktree cleaned up for task {}", task.id);
491        }
492    }
493
494    Ok(())
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    #[test]
502    fn test_create_task() {
503        let task = create_task(
504            "Search codebase",
505            "Find all occurrences of 'Agent'",
506            "Explore",
507            "none",
508        );
509
510        assert_eq!(task.description, "Search codebase");
511        assert_eq!(task.subagent_type, "Explore");
512        assert_eq!(task.isolation, "none");
513        assert!(task.work_path.is_none());
514    }
515
516    #[test]
517    fn test_create_worktree_task() {
518        let task = create_task(
519            "Refactor module",
520            "Refactor the agent module",
521            "general-purpose",
522            "worktree",
523        );
524
525        assert_eq!(task.isolation, "worktree");
526        assert!(task.work_path.is_some());
527    }
528
529    #[test]
530    fn test_subagent_config_default() {
531        let config = SubagentConfig::default();
532
533        assert_eq!(config.model_name, "claude-sonnet-4-20250514");
534        assert_eq!(config.max_tokens, 4096);
535        assert!(!config.think);
536    }
537
538    #[test]
539    fn test_build_system_prompt_explore() {
540        let executor = SubagentExecutor::new(
541            SubagentConfig::default(),
542            tokio::sync::mpsc::channel(1).0,
543            Vec::new(),
544        );
545
546        let prompt = executor.build_system_prompt("Explore", "Search for X");
547
548        assert!(prompt.contains("Explore agent"));
549        assert!(prompt.contains("read-only"));
550        assert!(prompt.contains("Search for X"));
551    }
552
553    #[test]
554    fn test_build_system_prompt_plan() {
555        let executor = SubagentExecutor::new(
556            SubagentConfig::default(),
557            tokio::sync::mpsc::channel(1).0,
558            Vec::new(),
559        );
560
561        let prompt = executor.build_system_prompt("Plan", "Create architecture");
562
563        assert!(prompt.contains("Plan agent"));
564        assert!(prompt.contains("architecture"));
565        assert!(prompt.contains("Create architecture"));
566    }
567}