Skip to main content

oxicode_agent/tools/
subagent.rs

1/// Subagent tool — delegate tasks to specialized agents
2///
3/// Spawns a separate `oxicode --mode json` process for each invocation,
4/// giving it an isolated context window.
5///
6/// Supports three modes:
7///   - Single: { agent: "name", task: "..." }
8///   - Parallel: { tasks: [{ agent, task }, ...] }
9///   - Chain: { chain: [{ agent, task: "... {previous} ..." }, ...] }
10///
11/// Agent definitions are markdown files with YAML frontmatter,
12/// discovered from `~/.oxicode/agents/` (user) and `.oxicode/agents/` (project).
13/// Discovery is delegated to [`crate::agent_definition::AgentDiscovery`].
14use super::{AgentTool, AgentToolResult, ProgressCallback, ToolContext, ToolError};
15use crate::agent_definition::{
16    AgentDefinition, AgentDiscovery, AgentScope, current_subagent_depth, max_subagent_depth,
17};
18use async_trait::async_trait;
19use serde::{Deserialize, Serialize};
20use serde_json::{Value, json};
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23use tokio::io::{AsyncBufReadExt, BufReader};
24use tokio::sync::oneshot;
25
26// ── Constants ──────────────────────────────────────────────────────────
27
28const MAX_PARALLEL_TASKS: usize = 8;
29const MAX_CONCURRENCY: usize = 4;
30
31// ── Progress callback type (reuse from tools.rs) ──────────────────────
32
33type ProgressFn = ProgressCallback;
34
35// ── Temp dir helper (no RAII — let OS clean up after subprocess exits) ──
36
37fn create_system_prompt_temp_dir(prefix: &str) -> Result<PathBuf, String> {
38    let path = std::env::temp_dir().join(format!("{}-{}", prefix, uuid::Uuid::new_v4()));
39    std::fs::create_dir_all(&path).map_err(|e| format!("Failed to create temp dir: {}", e))?;
40    Ok(path)
41}
42
43// ── Agent Discovery (delegates to SDK) ─────────────────────────────────
44
45/// Discover agents by delegating to [`AgentDiscovery`].
46pub fn discover_agents(cwd: &Path, scope: AgentScope) -> Vec<AgentDefinition> {
47    AgentDiscovery::discover(cwd, scope)
48        .unwrap_or_default()
49        .into_iter()
50        .map(|(_, def)| def)
51        .collect()
52}
53
54// ── Result Types ───────────────────────────────────────────────────────
55
56#[derive(Debug, Clone, Serialize, Deserialize, Default)]
57/// Usage statistics from a subagent run.
58pub struct UsageStats {
59    /// Input tokens consumed.
60    pub input_tokens: u64,
61    /// Output tokens consumed.
62    pub output_tokens: u64,
63    /// Cache read tokens (reserved for future use).
64    pub cache_read: u64,
65    /// Cache write tokens (reserved for future use).
66    pub cache_write: u64,
67    /// Cost in USD (reserved for future use).
68    pub cost: f64,
69    /// Number of agent turns.
70    pub turns: u32,
71}
72
73#[derive(Debug, Clone)]
74/// Result from a single subagent execution.
75pub struct SingleResult {
76    /// Agent name.
77    pub agent: String,
78    /// Discovery source ("user" or "project").
79    pub agent_source: String,
80    /// Task that was executed.
81    pub task: String,
82    /// Process exit code.
83    pub exit_code: i32,
84    /// Captured stdout text.
85    pub output: String,
86    /// Captured stderr text.
87    pub stderr: String,
88    /// Token usage.
89    pub usage: UsageStats,
90    /// Model used by the agent.
91    pub model: Option<String>,
92    /// Stop reason.
93    pub stop_reason: Option<String>,
94    /// Error message if the agent failed.
95    pub error_message: Option<String>,
96    /// Step index in chain mode.
97    pub step: Option<usize>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102/// Execution mode used for a subagent invocation.
103pub enum SubagentMode {
104    /// Single agent, single task.
105    Single,
106    /// Multiple agents/tasks running concurrently.
107    Parallel,
108    /// Sequential agents, passing {previous} output forward.
109    Chain,
110}
111
112#[derive(Debug, Clone)]
113/// Detailed results from a subagent invocation.
114pub struct SubagentDetails {
115    /// Which mode was used.
116    pub mode: SubagentMode,
117    /// Per-agent results.
118    pub results: Vec<SingleResult>,
119}
120
121// ── JSON line processing ───────────────────────────────────────────────
122
123fn process_json_line(
124    line: &str,
125    result: &mut SingleResult,
126    text: &mut String,
127    _on_progress: &Option<ProgressFn>,
128) {
129    let event: Value = match serde_json::from_str(line) {
130        Ok(v) => v,
131        Err(_) => return,
132    };
133    match event["type"].as_str().unwrap_or("") {
134        "text_delta" => {
135            if let Some(t) = event["text"].as_str() {
136                text.push_str(t);
137            }
138        }
139        "usage" => {
140            result.usage.input_tokens += event["input_tokens"].as_u64().unwrap_or(0);
141            result.usage.output_tokens += event["output_tokens"].as_u64().unwrap_or(0);
142            result.usage.turns += 1;
143        }
144        "complete" => {
145            result.stop_reason = Some("complete".to_string());
146        }
147        "error" => {
148            result.error_message = Some(
149                event["message"]
150                    .as_str()
151                    .unwrap_or("Unknown error")
152                    .to_string(),
153            );
154            result.stop_reason = Some("error".to_string());
155        }
156        _ => {}
157    }
158}
159
160// ── Process Execution ──────────────────────────────────────────────────
161
162/// Build command-line arguments for launching a subagent process.
163fn build_agent_args(agent: &AgentDefinition, tmp_dir: &Path, task: &str) -> Vec<String> {
164    let mut args = vec!["--mode".to_string(), "json".to_string(), "-p".to_string()];
165
166    if let Some(ref model) = agent.model {
167        args.push("--model".to_string());
168        args.push(model.clone());
169    }
170
171    if !agent.tools.is_empty() {
172        args.push("--tools".to_string());
173        args.push(agent.tools.join(","));
174    }
175
176    if let Some(ref prompt) = agent.system_prompt
177        && !prompt.is_empty()
178        && std::fs::write(tmp_dir.join("system_prompt.md"), prompt).is_ok()
179    {
180        args.push("--append-system-prompt".to_string());
181        args.push(
182            tmp_dir
183                .join("system_prompt.md")
184                .to_str()
185                .unwrap_or_default()
186                .to_string(),
187        );
188    }
189
190    args.push(format!("Task: {}", task));
191    args
192}
193
194/// Gracefully terminate a child process (SIGTERM → wait → SIGKILL).
195async fn terminate_child(
196    child: &mut tokio::process::Child,
197    stderr_handle: tokio::task::JoinHandle<String>,
198    result: &mut SingleResult,
199) {
200    #[cfg(unix)]
201    {
202        if let Some(pid) = child.id() {
203            // SAFETY: libc::kill sends SIGTERM to the child process. PID comes from
204            // child.id() which is a valid running process. Used for graceful shutdown
205            // before force-killing. Race (process exited) returns ESRCH harmlessly.
206            unsafe {
207                libc::kill(pid as i32, libc::SIGTERM);
208            }
209        }
210        let deadline = tokio::time::sleep(std::time::Duration::from_secs(5));
211        tokio::pin!(deadline);
212        tokio::select! {
213            _ = &mut deadline => { let _ = child.start_kill(); }
214            _ = child.wait() => {}
215        }
216    }
217    #[cfg(not(unix))]
218    {
219        let _ = child.start_kill();
220        let _ = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait()).await;
221    }
222
223    // Collect stderr with short timeout
224    let _ = tokio::time::timeout(std::time::Duration::from_secs(1), async {
225        if let Ok(err) = stderr_handle.await {
226            result.stderr = err;
227        }
228    })
229    .await;
230}
231
232/// Run a single agent process with abort support.
233#[allow(clippy::too_many_arguments)]
234async fn run_single_agent(
235    cwd: &Path,
236    agents: &[AgentDefinition],
237    agent_name: &str,
238    task: &str,
239    agent_cwd: Option<&str>,
240    step: Option<usize>,
241    signal: Option<oneshot::Receiver<()>>,
242    on_progress: Option<ProgressFn>,
243    binary_path: &Path,
244) -> SingleResult {
245    let agent = match agents.iter().find(|a| a.name == agent_name) {
246        Some(a) => a,
247        None => {
248            let available = agents
249                .iter()
250                .map(|a| format!("\"{}\"", a.name))
251                .collect::<Vec<_>>()
252                .join(", ");
253            return SingleResult {
254                agent: agent_name.to_string(),
255                agent_source: "unknown".to_string(),
256                task: task.to_string(),
257                exit_code: 1,
258                output: String::new(),
259                stderr: format!(
260                    "Unknown agent: \"{}\". Available: {}",
261                    agent_name, available
262                ),
263                usage: UsageStats::default(),
264                model: None,
265                stop_reason: None,
266                error_message: Some(format!("Unknown agent: {}", agent_name)),
267                step,
268            };
269        }
270    };
271
272    let mut result = SingleResult {
273        agent: agent_name.to_string(),
274        agent_source: agent.source.clone(),
275        task: task.to_string(),
276        exit_code: 0,
277        output: String::new(),
278        stderr: String::new(),
279        usage: UsageStats::default(),
280        model: agent.model.clone(),
281        stop_reason: None,
282        error_message: None,
283        step,
284    };
285
286    // Notify progress
287    if let Some(ref cb) = on_progress {
288        cb(format!("[{}] running...", agent_name));
289    }
290
291    // Build command args
292    let tmp_dir = match create_system_prompt_temp_dir("oxicode-subagent") {
293        Ok(tmp) => Some(tmp),
294        Err(e) => {
295            result.exit_code = 1;
296            result.stderr = e.clone();
297            result.error_message = Some(e);
298            return result;
299        }
300    };
301
302    let args = match tmp_dir {
303        Some(ref tmp) => build_agent_args(agent, tmp, task),
304        None => vec![
305            "--mode".to_string(),
306            "json".to_string(),
307            "-p".to_string(),
308            format!("Task: {}", task),
309        ],
310    };
311
312    let working_dir = agent_cwd
313        .map(PathBuf::from)
314        .unwrap_or_else(|| cwd.to_path_buf());
315
316    let mut cmd = tokio::process::Command::new(binary_path);
317    cmd.args(&args)
318        .current_dir(&working_dir)
319        .stdout(std::process::Stdio::piped())
320        .stderr(std::process::Stdio::piped())
321        .stdin(std::process::Stdio::null())
322        // Depth tracking for subagent nesting limits
323        .env(
324            "OXICODE_SUBAGENT_DEPTH",
325            (current_subagent_depth() + 1).to_string(),
326        )
327        .env(
328            "OXICODE_MAX_SUBAGENT_DEPTH",
329            agent.max_subagent_depth.to_string(),
330        );
331
332    let mut child = match cmd.spawn() {
333        Ok(c) => c,
334        Err(e) => {
335            result.exit_code = 1;
336            result.stderr = format!("Failed to spawn: {}", e);
337            result.error_message = Some(format!("Failed to spawn: {}", e));
338            return result;
339        }
340    };
341
342    // SAFETY: the command was spawned with `Stdio::piped()` for stdout/stderr
343    // and the spawn succeeded (we returned early on error), so both `take()`
344    // calls cannot return None.
345    #[allow(clippy::expect_used)]
346    let stdout = child.stdout.take().expect("stdout piped but missing");
347    #[allow(clippy::expect_used)]
348    let stderr = child.stderr.take().expect("stderr piped but missing");
349
350    // Spawn stdout reader → channel
351    let (line_tx, mut line_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
352    let _reader_handle = tokio::spawn(async move {
353        let reader = BufReader::new(stdout);
354        let mut lines = reader.lines();
355        while let Ok(Some(line)) = lines.next_line().await {
356            if line_tx.send(line).is_err() {
357                break;
358            }
359        }
360    });
361
362    // Spawn stderr reader
363    let stderr_handle = tokio::spawn(async move {
364        let mut err = String::new();
365        let reader = BufReader::new(stderr);
366        let mut lines = reader.lines();
367        while let Ok(Some(line)) = lines.next_line().await {
368            err.push_str(&line);
369            err.push('\n');
370        }
371        err
372    });
373
374    // Main loop: select between stdout lines and abort signal
375    let mut final_text = String::new();
376    let mut signal_rx = signal;
377    let mut aborted = false;
378
379    loop {
380        tokio::select! {
381            line = line_rx.recv() => {
382                match line {
383                    Some(line) => {
384                        process_json_line(&line, &mut result, &mut final_text, &on_progress);
385                    }
386                    None => break, // stdout EOF
387                }
388            }
389            _ = async {
390                match &mut signal_rx {
391                    Some(rx) => { let _ = rx.await; }
392                    None => std::future::pending::<()>().await,
393                }
394            } => {
395                aborted = true;
396                break;
397            }
398        }
399    }
400
401    if aborted {
402        result.stop_reason = Some("aborted".into());
403        result.error_message = Some("Aborted by user".into());
404        terminate_child(&mut child, stderr_handle, &mut result).await;
405    } else {
406        // Normal completion
407        if let Ok(err_output) = stderr_handle.await {
408            result.stderr = err_output;
409        }
410        match child.wait().await {
411            Ok(status) => result.exit_code = status.code().unwrap_or(1),
412            Err(_) => result.exit_code = 1,
413        }
414    }
415
416    result.output = final_text;
417
418    if let Some(ref cb) = on_progress {
419        let status = if result.exit_code == 0 {
420            "done"
421        } else {
422            "failed"
423        };
424        cb(format!("[{}] {}", agent_name, status));
425    }
426
427    result
428}
429
430/// Run multiple tasks with concurrency limit.
431async fn run_parallel(
432    cwd: &Path,
433    agents: &[AgentDefinition],
434    tasks: Vec<ParallelTask>,
435    binary_path: PathBuf,
436    on_progress: Option<ProgressFn>,
437) -> Vec<SingleResult> {
438    let n = tasks.len();
439    if n == 0 {
440        return vec![];
441    }
442
443    let limit = MAX_CONCURRENCY.min(n);
444    let indexed_tasks: Vec<(usize, ParallelTask)> = tasks.into_iter().enumerate().collect();
445    let mut all_results: Vec<Option<SingleResult>> = vec![None; n];
446
447    let mut i = 0;
448    while i < indexed_tasks.len() {
449        let end = (i + limit).min(indexed_tasks.len());
450        let chunk: Vec<_> = indexed_tasks[i..end].to_vec();
451        let mut handles = Vec::new();
452
453        for (idx, task) in chunk {
454            let agents = agents.to_vec();
455            let cwd = cwd.to_path_buf();
456            let bp = binary_path.clone();
457            let progress = on_progress.clone();
458
459            handles.push((
460                idx,
461                tokio::spawn(async move {
462                    run_single_agent(
463                        &cwd,
464                        &agents,
465                        &task.agent,
466                        &task.task,
467                        task.cwd.as_deref(),
468                        None,
469                        None,
470                        progress,
471                        &bp,
472                    )
473                    .await
474                }),
475            ));
476        }
477
478        for (idx, handle) in handles {
479            if let Ok(r) = handle.await {
480                all_results[idx] = Some(r);
481            }
482        }
483
484        i = end;
485    }
486
487    all_results
488        .into_iter()
489        .map(|r| {
490            r.unwrap_or_else(|| SingleResult {
491                agent: "unknown".to_string(),
492                agent_source: "unknown".to_string(),
493                task: "unknown".to_string(),
494                exit_code: 1,
495                output: String::new(),
496                stderr: "Task did not complete".to_string(),
497                usage: UsageStats::default(),
498                model: None,
499                stop_reason: Some("error".to_string()),
500                error_message: Some("Task did not complete".to_string()),
501                step: None,
502            })
503        })
504        .collect()
505}
506
507// ── Parameter Types ────────────────────────────────────────────────────
508
509#[derive(Debug, Deserialize, Clone)]
510struct ParallelTask {
511    agent: String,
512    task: String,
513    #[serde(default)]
514    cwd: Option<String>,
515}
516
517#[derive(Debug, Deserialize)]
518struct ChainStep {
519    agent: String,
520    task: String,
521    #[serde(default)]
522    cwd: Option<String>,
523}
524
525// ── Tool Implementation ────────────────────────────────────────────────
526
527/// Subagent tool for delegating tasks to specialized agents.
528pub struct SubagentTool {
529    /// Explicit working directory override. If None, uses ToolContext.root() at runtime.
530    cwd: Option<PathBuf>,
531    binary_path: Option<PathBuf>,
532    progress_callback: parking_lot::Mutex<Option<ProgressFn>>,
533}
534
535impl Default for SubagentTool {
536    fn default() -> Self {
537        Self::new()
538    }
539}
540
541impl SubagentTool {
542    /// Create with no explicit root (uses ToolContext.root() at runtime).
543    pub fn new() -> Self {
544        Self {
545            cwd: None,
546            binary_path: None,
547            progress_callback: parking_lot::Mutex::new(None),
548        }
549    }
550
551    /// Create with an explicit working directory (overrides ToolContext).
552    pub fn with_cwd(cwd: impl Into<PathBuf>) -> Self {
553        Self {
554            cwd: Some(cwd.into()),
555            binary_path: None,
556            progress_callback: parking_lot::Mutex::new(None),
557        }
558    }
559
560    fn get_binary(&self) -> PathBuf {
561        self.binary_path
562            .clone()
563            .or_else(|| std::env::current_exe().ok())
564            .unwrap_or_else(|| PathBuf::from("oxicode"))
565    }
566}
567
568#[async_trait]
569impl AgentTool for SubagentTool {
570    fn name(&self) -> &str {
571        "subagent"
572    }
573
574    fn label(&self) -> &str {
575        "Subagent"
576    }
577
578    fn description(&self) -> &str {
579        "Delegate tasks to specialized subagents with isolated context. \
580         Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder). \
581         Agents are discovered from ~/.oxicode/agents/ (user) and .oxicode/agents/ (project)."
582    }
583
584    fn parameters_schema(&self) -> Value {
585        json!({
586            "type": "object",
587            "properties": {
588                "agent": {
589                    "type": "string",
590                    "description": "Agent name for single mode"
591                },
592                "task": {
593                    "type": "string",
594                    "description": "Task to delegate (single mode)"
595                },
596                "tasks": {
597                    "type": "array",
598                    "description": "Array of {agent, task} for parallel execution (max 8)",
599                    "items": {
600                        "type": "object",
601                        "properties": {
602                            "agent": { "type": "string" },
603                            "task": { "type": "string" },
604                            "cwd": { "type": "string" }
605                        },
606                        "required": ["agent", "task"]
607                    }
608                },
609                "chain": {
610                    "type": "array",
611                    "description": "Array of {agent, task} for sequential execution. Use {previous} in task for prior output.",
612                    "items": {
613                        "type": "object",
614                        "properties": {
615                            "agent": { "type": "string" },
616                            "task": { "type": "string" },
617                            "cwd": { "type": "string" }
618                        },
619                        "required": ["agent", "task"]
620                    }
621                },
622                "agentScope": {
623                    "type": "string",
624                    "description": "Agent discovery scope: 'user' (default), 'project', or 'both'",
625                    "enum": ["user", "project", "both"],
626                    "default": "user"
627                },
628                "cwd": {
629                    "type": "string",
630                    "description": "Working directory for single mode"
631                }
632            }
633        })
634    }
635
636    fn on_progress(&self, callback: ProgressCallback) {
637        *self.progress_callback.lock() = Some(callback);
638    }
639
640    async fn execute(
641        &self,
642        _tool_call_id: &str,
643        params: Value,
644        signal: Option<oneshot::Receiver<()>>,
645        ctx: &ToolContext,
646    ) -> Result<AgentToolResult, ToolError> {
647        // ── Depth check ──
648        // For the in-process path, depth is tracked via ToolContext
649        // (NOT env vars — concurrent set_var is UB, and env state
650        // leaks between forks). For the CLI path, depth is tracked
651        // via OXICODE_SUBAGENT_DEPTH env var (safe: each subprocess has
652        // its own env).
653        let runner = ctx.subagent_runner.clone();
654        let depth = if runner.is_some() {
655            ctx.subagent_depth
656        } else {
657            current_subagent_depth()
658        };
659        let max = if runner.is_some() {
660            3 // default; overridden per-agent below
661        } else {
662            max_subagent_depth()
663        };
664        if depth >= max {
665            return Ok(AgentToolResult::error(format!(
666                "Subagent depth limit reached ({}/{}). \
667                 Increase max_subagent_depth in your agent definition.",
668                depth, max
669            )));
670        }
671
672        // Use explicit cwd if set, else ctx.root()
673        let effective_cwd = self.cwd.as_deref().unwrap_or(ctx.root());
674
675        let scope: AgentScope = params
676            .get("agentScope")
677            .and_then(|v| serde_json::from_value(v.clone()).ok())
678            .unwrap_or(AgentScope::User);
679
680        let agents = discover_agents(effective_cwd, scope);
681        let progress = self.progress_callback.lock().clone();
682
683        let has_chain = params["chain"]
684            .as_array()
685            .map(|a| !a.is_empty())
686            .unwrap_or(false);
687        let has_tasks = params["tasks"]
688            .as_array()
689            .map(|a| !a.is_empty())
690            .unwrap_or(false);
691        let has_single = params["agent"].is_string() && params["task"].is_string();
692
693        let mode_count = [has_chain, has_tasks, has_single]
694            .iter()
695            .filter(|&&x| x)
696            .count();
697
698        if mode_count != 1 {
699            let available = agents
700                .iter()
701                .map(|a| format!("{} ({})", a.name, a.source))
702                .collect::<Vec<_>>()
703                .join(", ");
704            return Ok(AgentToolResult::error(format!(
705                "Provide exactly one mode: agent+task, tasks, or chain.\nAvailable agents: {}",
706                if available.is_empty() {
707                    "none".to_string()
708                } else {
709                    available
710                }
711            )));
712        }
713
714        // ── In-process path (library-native delegation) ──
715        // When a SubagentRunner is wired, prefer it over shelling out.
716        // This is the path library consumers (Oxios) use — they have
717        // no `oxicode` subprocess. The CLI fallback below is the default
718        // for oxicode-cli.
719        if let Some(runner) = &runner {
720            return execute_in_process(
721                effective_cwd,
722                &agents,
723                params,
724                runner,
725                depth,
726                progress,
727                signal,
728            )
729            .await;
730        }
731
732        // ── CLI fallback (existing path) ──
733        let binary = self.get_binary();
734
735        // ── Chain mode ──
736        if has_chain {
737            return execute_chain_mode(effective_cwd, &agents, params, &binary, progress, signal)
738                .await;
739        }
740
741        // ── Parallel mode ──
742        if has_tasks {
743            return execute_parallel_mode(effective_cwd, &agents, params, &binary, progress).await;
744        }
745
746        // ── Single mode ──
747        if has_single {
748            // Auto-register a todo item for the subagent task
749            let desc = params["task"]
750                .as_str()
751                .map(|s| s.chars().take(80).collect::<String>())
752                .unwrap_or_else(|| "subagent task".into());
753            if let Some(todo) = &ctx.todo {
754                use crate::tools::todo::TodoOp;
755                let _ = todo
756                    .apply_ops(vec![TodoOp::Start {
757                        task: Some(desc.clone()),
758                        phase: Some("Subagents".into()),
759                    }])
760                    .await;
761            }
762            let result =
763                execute_single_mode(effective_cwd, &agents, params, &binary, progress, signal)
764                    .await;
765            // Mark done on completion (even on error — task was attempted)
766            if let Some(todo) = &ctx.todo {
767                use crate::tools::todo::TodoOp;
768                let _ = todo
769                    .apply_ops(vec![TodoOp::Done {
770                        task: Some(desc),
771                        phase: None,
772                    }])
773                    .await;
774            }
775            return result;
776        }
777
778        Ok(AgentToolResult::error("Invalid parameters".to_string()))
779    }
780}
781
782// ── In-process execution (issue #28 gap 3) ────────────────────────────
783//
784// When a SubagentRunner is wired into ToolContext, the subagent tool
785// delegates to it instead of spawning CLI subprocesses. This is the
786// library-native path — essential for consumers (Oxios) that embed
787// oxicode-agent without an `oxicode` binary. Depth tracking uses the
788// ToolContext.subagent_depth field (NOT env vars — concurrent set_var
789// is UB and state leaks between sequential forks).
790
791/// Convert a ForkResult to a SingleResult for output formatting.
792fn fork_to_single(
793    fork: super::ForkResult,
794    agent_name: &str,
795    task: &str,
796    step: Option<usize>,
797) -> SingleResult {
798    let error = fork.error.clone();
799    SingleResult {
800        agent: agent_name.to_string(),
801        agent_source: "in-process".to_string(),
802        task: task.to_string(),
803        exit_code: if error.is_some() { 1 } else { 0 },
804        output: fork.text,
805        stderr: String::new(),
806        usage: UsageStats {
807            input_tokens: fork.input_tokens as u64,
808            output_tokens: fork.output_tokens as u64,
809            turns: fork.turns,
810            ..Default::default()
811        },
812        model: fork.model,
813        stop_reason: if error.is_some() {
814            Some("error".to_string())
815        } else {
816            Some("complete".to_string())
817        },
818        error_message: error,
819        step,
820    }
821}
822
823/// Execute via in-process SubagentRunner — single, parallel, and chain modes.
824#[allow(clippy::too_many_arguments)]
825async fn execute_in_process(
826    cwd: &Path,
827    agents: &[AgentDefinition],
828    params: Value,
829    runner: &Arc<dyn super::SubagentRunner>,
830    depth: u8,
831    progress: Option<ProgressFn>,
832    _signal: Option<oneshot::Receiver<()>>,
833) -> Result<AgentToolResult, ToolError> {
834    let has_chain = params["chain"]
835        .as_array()
836        .map(|a| !a.is_empty())
837        .unwrap_or(false);
838    let has_tasks = params["tasks"]
839        .as_array()
840        .map(|a| !a.is_empty())
841        .unwrap_or(false);
842    let has_single = params["agent"].is_string() && params["task"].is_string();
843
844    // ── Single mode ──
845    if has_single {
846        let agent_name = params["agent"].as_str().unwrap_or("");
847        let task = params["task"].as_str().unwrap_or("");
848        let agent_def = agents.iter().find(|a| a.name == agent_name);
849
850        if let Some(ref cb) = progress {
851            cb(format!("[{}] running (in-process)...", agent_name));
852        }
853
854        let fork = runner
855            .run_isolated(
856                agent_name,
857                task,
858                agent_def.and_then(|d| d.system_prompt.as_deref()),
859                agent_def.and_then(|d| d.model.as_deref()),
860                agent_def.map(|d| d.tools.as_slice()).unwrap_or(&[]),
861                cwd,
862                depth,
863            )
864            .await
865            .map_err(|e| format!("In-process subagent failed: {e}"))?;
866
867        let result = fork_to_single(fork, agent_name, task, None);
868        let is_error = result.stop_reason.as_deref() == Some("error");
869
870        if is_error {
871            let error_msg = result.error_message.as_deref().unwrap_or("unknown error");
872            return Ok(AgentToolResult::error(format!("Agent failed: {error_msg}")));
873        }
874
875        return Ok(AgentToolResult::success(if result.output.is_empty() {
876            "(no output)".to_string()
877        } else {
878            result.output.clone()
879        })
880        .with_metadata(json!({
881            "mode": "single",
882            "agent": result.agent,
883            "source": result.agent_source,
884            "backend": "in-process",
885            "usage": {
886                "input_tokens": result.usage.input_tokens,
887                "output_tokens": result.usage.output_tokens,
888                "turns": result.usage.turns,
889            },
890        })));
891    }
892
893    // ── Parallel mode ──
894    if has_tasks {
895        let tasks: Vec<ParallelTask> = serde_json::from_value(params["tasks"].clone())
896            .map_err(|e| format!("Invalid tasks parameter: {e}"))?;
897        let total = tasks.len();
898        if total == 0 {
899            return Ok(AgentToolResult::error("No tasks provided".to_string()));
900        }
901
902        // Run concurrently — safe because SubagentRunner is Send+Sync
903        // and there are no env-var mutations (unlike the CLI path).
904        let limit = MAX_CONCURRENCY.min(total);
905        let mut all_results: Vec<SingleResult> = Vec::with_capacity(total);
906        let mut all_errors: Vec<String> = Vec::new();
907
908        for chunk in tasks.chunks(limit) {
909            let mut handles = Vec::new();
910            for task in chunk {
911                let agent_def = agents.iter().find(|a| a.name == task.agent);
912                let runner = Arc::clone(runner);
913                let agent_name = task.agent.clone();
914                let task_text = task.task.clone();
915                let system_prompt = agent_def.and_then(|d| d.system_prompt.clone());
916                let model = agent_def.and_then(|d| d.model.clone());
917                let tools: Vec<String> = agent_def.map(|d| d.tools.clone()).unwrap_or_default();
918                let cwd = cwd.to_path_buf();
919
920                handles.push(tokio::spawn(async move {
921                    runner
922                        .run_isolated(
923                            &agent_name,
924                            &task_text,
925                            system_prompt.as_deref(),
926                            model.as_deref(),
927                            &tools,
928                            &cwd,
929                            depth,
930                        )
931                        .await
932                }));
933            }
934
935            for (i, handle) in handles.into_iter().enumerate() {
936                let task = &chunk[i];
937                match handle.await {
938                    Ok(Ok(fork)) => {
939                        all_results.push(fork_to_single(fork, &task.agent, &task.task, None))
940                    }
941                    Ok(Err(e)) => all_errors.push(format!("{}: {e}", task.agent)),
942                    Err(e) => all_errors.push(format!("{}: join error: {e}", task.agent)),
943                }
944            }
945        }
946
947        if !all_errors.is_empty() {
948            return Ok(AgentToolResult::error(format!(
949                "Errors: {}",
950                all_errors.join("; ")
951            )));
952        }
953
954        let success_count = all_results.iter().filter(|r| r.exit_code == 0).count();
955        let summaries: Vec<String> = all_results
956            .iter()
957            .map(|r| format!("[{}] {}", r.agent, r.output))
958            .collect();
959
960        return Ok(AgentToolResult::success(format!(
961            "Parallel: {}/{} succeeded\n\n{}",
962            success_count,
963            all_results.len(),
964            summaries.join("\n\n---\n\n")
965        ))
966        .with_metadata(json!({
967            "mode": "parallel",
968            "backend": "in-process",
969            "results": all_results.iter().map(|r| json!({
970                "agent": r.agent,
971                "exit_code": r.exit_code,
972            })).collect::<Vec<_>>()
973        })));
974    }
975
976    // ── Chain mode ──
977    if has_chain {
978        let steps: Vec<ChainStep> = serde_json::from_value(params["chain"].clone())
979            .map_err(|e| format!("Invalid chain parameter: {e}"))?;
980        let total = steps.len();
981        let mut previous_output = String::new();
982        let mut results: Vec<SingleResult> = Vec::new();
983
984        for (i, step) in steps.into_iter().enumerate() {
985            let task = step.task.replace("{previous}", &previous_output);
986            let agent_def = agents.iter().find(|a| a.name == step.agent);
987
988            if let Some(ref cb) = progress {
989                cb(format!("[{}] chain step {}/{}", step.agent, i + 1, total));
990            }
991
992            let fork = runner
993                .run_isolated(
994                    &step.agent,
995                    &task,
996                    agent_def.and_then(|d| d.system_prompt.as_deref()),
997                    agent_def.and_then(|d| d.model.as_deref()),
998                    agent_def.map(|d| d.tools.as_slice()).unwrap_or(&[]),
999                    cwd,
1000                    depth,
1001                )
1002                .await;
1003
1004            let fork = match fork {
1005                Ok(f) => f,
1006                Err(e) => {
1007                    return Ok(AgentToolResult::error(format!(
1008                        "Chain stopped at step {}/{} ({}): {e}",
1009                        i + 1,
1010                        total,
1011                        step.agent
1012                    )));
1013                }
1014            };
1015
1016            let is_error = fork.error.is_some();
1017            let result = fork_to_single(fork, &step.agent, &task, Some(i + 1));
1018
1019            if is_error {
1020                let error_msg = result.error_message.clone().unwrap_or_default();
1021                return Ok(AgentToolResult::error(format!(
1022                    "Chain stopped at step {}/{} ({}): {error_msg}",
1023                    i + 1,
1024                    total,
1025                    step.agent
1026                )));
1027            }
1028
1029            previous_output = result.output.clone();
1030            results.push(result);
1031        }
1032
1033        let output = results.last().map(|r| r.output.clone()).unwrap_or_default();
1034        return Ok(AgentToolResult::success(if output.is_empty() {
1035            "(no output)".to_string()
1036        } else {
1037            output
1038        })
1039        .with_metadata(json!({
1040            "mode": "chain",
1041            "backend": "in-process",
1042            "steps": results.len(),
1043        })));
1044    }
1045
1046    Ok(AgentToolResult::error("Invalid parameters".to_string()))
1047}
1048
1049/// Execute chain mode: sequential agents where each step can reference {previous} output.
1050async fn execute_chain_mode(
1051    cwd: &Path,
1052    agents: &[AgentDefinition],
1053    params: Value,
1054    binary: &Path,
1055    progress: Option<ProgressFn>,
1056    signal: Option<oneshot::Receiver<()>>,
1057) -> Result<AgentToolResult, ToolError> {
1058    let steps: Vec<ChainStep> = serde_json::from_value(params["chain"].clone())
1059        .map_err(|e| format!("Invalid chain parameter: {}", e))?;
1060    let total = steps.len();
1061    let mut results = Vec::new();
1062    let mut previous_output = String::new();
1063    let mut abort_signal = signal;
1064
1065    for (i, step) in steps.into_iter().enumerate() {
1066        let task = step.task.replace("{previous}", &previous_output);
1067        let step_signal = if i == total - 1 {
1068            abort_signal.take()
1069        } else {
1070            None
1071        };
1072
1073        let result = run_single_agent(
1074            cwd,
1075            agents,
1076            &step.agent,
1077            &task,
1078            step.cwd.as_deref(),
1079            Some(i + 1),
1080            step_signal,
1081            progress.clone(),
1082            binary,
1083        )
1084        .await;
1085
1086        let is_error = result.exit_code != 0
1087            || result.stop_reason.as_deref() == Some("error")
1088            || result.stop_reason.as_deref() == Some("aborted");
1089
1090        if is_error {
1091            let agent_name = result.agent.clone();
1092            let error_msg = result
1093                .error_message
1094                .clone()
1095                .unwrap_or_else(|| result.stderr.clone());
1096            results.push(result);
1097            return Ok(AgentToolResult::error(format!(
1098                "Chain stopped at step {}/{} ({}): {}",
1099                i + 1,
1100                total,
1101                agent_name,
1102                error_msg
1103            )));
1104        }
1105
1106        previous_output = result.output.clone();
1107        results.push(result);
1108    }
1109
1110    let output = results.last().map(|r| r.output.clone()).unwrap_or_default();
1111    Ok(AgentToolResult::success(if output.is_empty() {
1112        "(no output)".to_string()
1113    } else {
1114        output
1115    })
1116    .with_metadata(json!({
1117        "mode": "chain",
1118        "steps": results.len(),
1119    })))
1120}
1121
1122/// Execute parallel mode: multiple agents running concurrently.
1123async fn execute_parallel_mode(
1124    cwd: &Path,
1125    agents: &[AgentDefinition],
1126    params: Value,
1127    binary: &Path,
1128    progress: Option<ProgressFn>,
1129) -> Result<AgentToolResult, ToolError> {
1130    let tasks: Vec<ParallelTask> = serde_json::from_value(params["tasks"].clone())
1131        .map_err(|e| format!("Invalid tasks parameter: {}", e))?;
1132
1133    if tasks.len() > MAX_PARALLEL_TASKS {
1134        return Ok(AgentToolResult::error(format!(
1135            "Too many parallel tasks ({}). Max is {}.",
1136            tasks.len(),
1137            MAX_PARALLEL_TASKS
1138        )));
1139    }
1140
1141    let results = run_parallel(cwd, agents, tasks, binary.to_path_buf(), progress).await;
1142
1143    let success_count = results.iter().filter(|r| r.exit_code == 0).count();
1144    let summaries: Vec<String> = results
1145        .iter()
1146        .map(|r| {
1147            let _preview = truncate_output(&r.output, 100);
1148            format!(
1149                "[{}]: {}",
1150                r.agent,
1151                if r.exit_code == 0 {
1152                    "completed"
1153                } else {
1154                    "failed"
1155                },
1156            )
1157        })
1158        .collect();
1159
1160    Ok(AgentToolResult::success(format!(
1161        "Parallel: {}/{} succeeded\n\n{}",
1162        success_count,
1163        results.len(),
1164        summaries.join("\n\n")
1165    ))
1166    .with_metadata(json!({
1167        "mode": "parallel",
1168        "results": results.iter().map(|r| json!({
1169            "agent": r.agent,
1170            "exit_code": r.exit_code,
1171        })).collect::<Vec<_>>()
1172    })))
1173}
1174
1175/// Execute single mode: one agent, one task.
1176async fn execute_single_mode(
1177    cwd: &Path,
1178    agents: &[AgentDefinition],
1179    params: Value,
1180    binary: &Path,
1181    progress: Option<ProgressFn>,
1182    signal: Option<oneshot::Receiver<()>>,
1183) -> Result<AgentToolResult, ToolError> {
1184    let agent_name = params["agent"]
1185        .as_str()
1186        .ok_or("Missing required parameter: agent")?;
1187    let task = params["task"]
1188        .as_str()
1189        .ok_or("Missing required parameter: task")?;
1190    let agent_cwd = params["cwd"].as_str();
1191
1192    let result = run_single_agent(
1193        cwd, agents, agent_name, task, agent_cwd, None, signal, progress, binary,
1194    )
1195    .await;
1196
1197    let is_error = result.exit_code != 0
1198        || result.stop_reason.as_deref() == Some("error")
1199        || result.stop_reason.as_deref() == Some("aborted");
1200
1201    if is_error {
1202        let error_msg = result.error_message.as_deref().unwrap_or(&result.stderr);
1203        return Ok(AgentToolResult::error(format!(
1204            "Agent {}: {}",
1205            result.stop_reason.as_deref().unwrap_or("failed"),
1206            error_msg
1207        )));
1208    }
1209
1210    Ok(AgentToolResult::success(if result.output.is_empty() {
1211        "(no output)".to_string()
1212    } else {
1213        result.output.clone()
1214    })
1215    .with_metadata(json!({
1216        "mode": "single",
1217        "agent": result.agent,
1218        "source": result.agent_source,
1219        "usage": {
1220            "input_tokens": result.usage.input_tokens,
1221            "output_tokens": result.usage.output_tokens,
1222            "turns": result.usage.turns,
1223        },
1224    })))
1225}
1226
1227// ── Helpers ────────────────────────────────────────────────────────────
1228
1229fn truncate_output(text: &str, max_chars: usize) -> String {
1230    if text.len() <= max_chars {
1231        text.to_string()
1232    } else {
1233        format!("{}...", &text[..max_chars])
1234    }
1235}
1236
1237// ── Tests ──────────────────────────────────────────────────────────────
1238
1239#[cfg(test)]
1240mod tests {
1241    use super::*;
1242
1243    #[test]
1244    fn test_discover_agents_empty_dir() {
1245        let tmp = tempfile::tempdir().unwrap();
1246        let agents = discover_agents(tmp.path(), AgentScope::Project);
1247        assert!(agents.is_empty());
1248    }
1249
1250    #[test]
1251    fn test_discover_agents_with_flat_files() {
1252        let tmp = tempfile::tempdir().unwrap();
1253        let agents_dir = tmp.path().join(".oxicode").join("agents");
1254        std::fs::create_dir_all(&agents_dir).unwrap();
1255        std::fs::write(
1256            agents_dir.join("scout.md"),
1257            "---\nname: scout\ndescription: Recon\n---\nBe a scout.",
1258        )
1259        .unwrap();
1260        std::fs::write(
1261            agents_dir.join("worker.md"),
1262            "---\nname: worker\n---\nBe a worker.",
1263        )
1264        .unwrap();
1265        std::fs::write(agents_dir.join("ignore.txt"), "ignore me").unwrap();
1266        let agents = discover_agents(tmp.path(), AgentScope::Project);
1267        assert_eq!(agents.len(), 2);
1268        assert!(agents.iter().any(|a| a.name == "scout"));
1269        assert!(agents.iter().any(|a| a.name == "worker"));
1270    }
1271
1272    #[test]
1273    fn test_schema_structure() {
1274        let tool = SubagentTool::new();
1275        let schema = tool.parameters_schema();
1276        assert_eq!(schema["type"], "object");
1277        assert!(schema["properties"]["agent"].is_object());
1278        assert!(schema["properties"]["tasks"].is_object());
1279        assert!(schema["properties"]["chain"].is_object());
1280        assert!(schema["properties"]["agentScope"].is_object());
1281    }
1282
1283    #[test]
1284    fn test_truncate_output() {
1285        assert_eq!(truncate_output("hello", 10), "hello");
1286        assert_eq!(truncate_output("hello world foo", 5), "hello...");
1287    }
1288
1289    #[test]
1290    fn test_process_json_line_text_delta() {
1291        let mut result = SingleResult {
1292            agent: "test".into(),
1293            agent_source: "user".into(),
1294            task: "t".into(),
1295            exit_code: 0,
1296            output: String::new(),
1297            stderr: String::new(),
1298            usage: UsageStats::default(),
1299            model: None,
1300            stop_reason: None,
1301            error_message: None,
1302            step: None,
1303        };
1304        let mut text = String::new();
1305        process_json_line(
1306            r#"{"type":"text_delta","text":"hello"}"#,
1307            &mut result,
1308            &mut text,
1309            &None,
1310        );
1311        assert_eq!(text, "hello");
1312    }
1313
1314    #[test]
1315    fn test_process_json_line_usage() {
1316        let mut result = SingleResult {
1317            agent: "test".into(),
1318            agent_source: "user".into(),
1319            task: "t".into(),
1320            exit_code: 0,
1321            output: String::new(),
1322            stderr: String::new(),
1323            usage: UsageStats::default(),
1324            model: None,
1325            stop_reason: None,
1326            error_message: None,
1327            step: None,
1328        };
1329        let mut text = String::new();
1330        process_json_line(
1331            r#"{"type":"usage","input_tokens":100,"output_tokens":50}"#,
1332            &mut result,
1333            &mut text,
1334            &None,
1335        );
1336        assert_eq!(result.usage.input_tokens, 100);
1337        assert_eq!(result.usage.output_tokens, 50);
1338        assert_eq!(result.usage.turns, 1);
1339    }
1340
1341    #[test]
1342    fn test_depth_limit_default() {
1343        unsafe {
1344            std::env::remove_var("OXICODE_SUBAGENT_DEPTH");
1345            std::env::remove_var("OXICODE_MAX_SUBAGENT_DEPTH");
1346        }
1347        assert_eq!(current_subagent_depth(), 0);
1348        assert_eq!(max_subagent_depth(), 3);
1349    }
1350}