Skip to main content

oxi_agent/tools/
subagent.rs

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