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", "code_search",
275            "code_files", "code_node", "code_explore", "code_context",
276            "codegraph_search", "codegraph_files",
277        ];
278
279        // For Plan agents, use read-only + plan tools
280        let plan_tools = vec![
281            "read", "grep", "glob", "ls", "search", "code_search",
282            "code_files", "code_node", "code_explore", "code_context",
283            "enter_plan_mode", "exit_plan_mode", "todo_write",
284        ];
285
286        let allowed_tools = match subagent_type {
287            "Explore" => &read_only_tools,
288            "Plan" => &plan_tools,
289            _ => return Vec::new(), // General purpose gets all tools
290        };
291
292        // Filter from available tools
293        self.tools
294            .iter()
295            .filter(|t| {
296                let name = t.definition().name;
297                allowed_tools.contains(&name.as_str())
298            })
299            .map(|t| {
300                // Clone Arc<dyn Tool> to Box<dyn Tool>
301                // We need to create a new boxed instance
302                Self::arc_to_box_tool(t.clone())
303            })
304            .collect()
305    }
306
307    /// Convert Arc<dyn Tool> to Box<dyn Tool>
308    /// This is needed because AgentBuilder expects Box<dyn Tool>
309    fn arc_to_box_tool(arc_tool: Arc<dyn Tool>) -> Box<dyn Tool> {
310        // For now, we'll return a minimal subset
311        // In production, this would need proper tool cloning
312        // We use the tools from the main agent directly
313        // This is a workaround - actual implementation would need
314        // proper tool factory or clone mechanism
315        Box::new(SubagentToolWrapper(arc_tool))
316    }
317
318    /// Build system prompt based on agent type
319    fn build_system_prompt(&self, subagent_type: &str, description: &str) -> String {
320        let base_prompt = self.config.system_prompt_prefix.clone()
321            .unwrap_or_else(|| "You are a helpful AI assistant.".to_string());
322
323        match subagent_type {
324            "Explore" => {
325                format!(
326                    "{}\n\nYou are an Explore agent focused on fast, read-only search operations.\n\
327                    Your task: {}\n\n\
328                    Rules:\n\
329                    - Only use read-only tools (read, grep, glob, ls, search, code_search)\n\
330                    - Do not modify any files\n\
331                    - Provide concise summaries of findings\n\
332                    - Complete quickly and report results",
333                    base_prompt, description
334                )
335            }
336            "Plan" => {
337                format!(
338                    "{}\n\nYou are a Plan agent focused on architecture and planning.\n\
339                    Your task: {}\n\n\
340                    Rules:\n\
341                    - Analyze the codebase structure\n\
342                    - Create implementation plans\n\
343                    - Use todo_write to track plan steps\n\
344                    - Provide clear, actionable recommendations\n\
345                    - Do not modify files unless explicitly asked",
346                    base_prompt, description
347                )
348            }
349            _ => {
350                format!(
351                    "{}\n\nYou are a general-purpose agent handling a subtask.\n\
352                    Your task: {}\n\n\
353                    Complete the task efficiently and report your results.",
354                    base_prompt, description
355                )
356            }
357        }
358    }
359
360    /// Tag event with subagent context
361    fn tag_event(event: AgentEvent, task_id: &str) -> AgentEvent {
362        // Add subagent prefix to text events for identification
363        if let Some(ref data) = event.data {
364            if let crate::event::EventData::Text { delta } = data {
365                return AgentEvent::with_data(
366                    event.event_type,
367                    crate::event::EventData::Text {
368                        delta: format!("[Subagent {}] {}", task_id, delta),
369                    },
370                );
371            }
372        }
373        event
374    }
375
376    /// Extract final content from agent messages
377    fn extract_content(agent: &Agent) -> String {
378        // Get the last assistant message
379        let messages = agent.get_messages();
380        for msg in messages.iter().rev() {
381            if msg.role == crate::providers::Role::Assistant {
382                match &msg.content {
383                    crate::providers::MessageContent::Text(text) => {
384                        return text.clone();
385                    }
386                    crate::providers::MessageContent::Blocks(blocks) => {
387                        // Extract text from blocks
388                        let texts: Vec<String> = blocks
389                            .iter()
390                            .filter_map(|b| {
391                                if let crate::providers::ContentBlock::Text { text } = b {
392                                    Some(text.clone())
393                                } else {
394                                    None
395                                }
396                            })
397                            .collect();
398                        return texts.join("\n");
399                    }
400                }
401            }
402        }
403        "No output generated".to_string()
404    }
405}
406
407/// Wrapper to convert Arc<dyn Tool> to Box<dyn Tool>
408/// This is a workaround for the tool cloning issue
409struct SubagentToolWrapper(Arc<dyn Tool>);
410
411#[async_trait::async_trait]
412impl Tool for SubagentToolWrapper {
413    fn definition(&self) -> crate::tools::ToolDefinition {
414        self.0.definition()
415    }
416
417    async fn execute(&self, params: serde_json::Value) -> Result<String> {
418        self.0.execute(params).await
419    }
420
421    fn risk_level(&self) -> crate::approval::RiskLevel {
422        self.0.risk_level()
423    }
424}
425
426/// Create a SubagentTask from parameters
427pub fn create_task(
428    description: &str,
429    prompt: &str,
430    subagent_type: &str,
431    isolation: &str,
432) -> SubagentTask {
433    let id = Uuid::new_v4().to_string();
434
435    let work_path = if isolation == "worktree" {
436        // Create temporary worktree path
437        let temp_dir = std::env::temp_dir()
438            .join(format!("matrixcode-task-{}", id));
439        Some(temp_dir)
440    } else {
441        None
442    };
443
444    SubagentTask {
445        id,
446        description: description.to_string(),
447        prompt: prompt.to_string(),
448        subagent_type: subagent_type.to_string(),
449        isolation: isolation.to_string(),
450        work_path,
451    }
452}
453
454/// Setup worktree isolation for a task
455pub async fn setup_worktree(task: &SubagentTask) -> Result<PathBuf> {
456    if task.isolation != "worktree" {
457        return Ok(std::env::current_dir()?);
458    }
459
460    let work_path = task.work_path.clone().unwrap_or_else(|| {
461        std::env::temp_dir()
462            .join(format!("matrixcode-task-{}", task.id))
463    });
464
465    // Create the directory
466    std::fs::create_dir_all(&work_path)?;
467
468    // In a real implementation, this would:
469    // 1. Run `git worktree add <path> <branch>`
470    // 2. Set up the environment for isolated work
471    // 3. Track the worktree for cleanup
472
473    log::info!("Worktree created at {} for task {}", work_path.display(), task.id);
474
475    Ok(work_path)
476}
477
478/// Cleanup worktree after task completion
479pub async fn cleanup_worktree(task: &SubagentTask) -> Result<()> {
480    if task.isolation != "worktree" {
481        return Ok(());
482    }
483
484    if let Some(path) = &task.work_path {
485        // In a real implementation, this would:
486        // 1. Run `git worktree remove <path>`
487        // 2. Delete the directory
488
489        if path.exists() {
490            std::fs::remove_dir_all(path)?;
491            log::info!("Worktree cleaned up for task {}", task.id);
492        }
493    }
494
495    Ok(())
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    #[test]
503    fn test_create_task() {
504        let task = create_task(
505            "Search codebase",
506            "Find all occurrences of 'Agent'",
507            "Explore",
508            "none",
509        );
510
511        assert_eq!(task.description, "Search codebase");
512        assert_eq!(task.subagent_type, "Explore");
513        assert_eq!(task.isolation, "none");
514        assert!(task.work_path.is_none());
515    }
516
517    #[test]
518    fn test_create_worktree_task() {
519        let task = create_task(
520            "Refactor module",
521            "Refactor the agent module",
522            "general-purpose",
523            "worktree",
524        );
525
526        assert_eq!(task.isolation, "worktree");
527        assert!(task.work_path.is_some());
528    }
529
530    #[test]
531    fn test_subagent_config_default() {
532        let config = SubagentConfig::default();
533
534        assert_eq!(config.model_name, "claude-sonnet-4-20250514");
535        assert_eq!(config.max_tokens, 4096);
536        assert!(!config.think);
537    }
538
539    #[test]
540    fn test_build_system_prompt_explore() {
541        let executor = SubagentExecutor::new(
542            SubagentConfig::default(),
543            tokio::sync::mpsc::channel(1).0,
544            Vec::new(),
545        );
546
547        let prompt = executor.build_system_prompt("Explore", "Search for X");
548
549        assert!(prompt.contains("Explore agent"));
550        assert!(prompt.contains("read-only"));
551        assert!(prompt.contains("Search for X"));
552    }
553
554    #[test]
555    fn test_build_system_prompt_plan() {
556        let executor = SubagentExecutor::new(
557            SubagentConfig::default(),
558            tokio::sync::mpsc::channel(1).0,
559            Vec::new(),
560        );
561
562        let prompt = executor.build_system_prompt("Plan", "Create architecture");
563
564        assert!(prompt.contains("Plan agent"));
565        assert!(prompt.contains("architecture"));
566        assert!(prompt.contains("Create architecture"));
567    }
568}