1use 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
28const MAX_PARALLEL_TASKS: usize = 8;
31const MAX_CONCURRENCY: usize = 4;
32
33type ProgressFn = ProgressCallback;
36
37fn 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
45pub 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
59pub struct UsageStats {
61 pub input_tokens: u64,
63 pub output_tokens: u64,
65 pub cache_read: u64,
67 pub cache_write: u64,
69 pub cost: f64,
71 pub turns: u32,
73}
74
75#[derive(Debug, Clone)]
76pub struct SingleResult {
78 pub agent: String,
80 pub agent_source: String,
82 pub task: String,
84 pub exit_code: i32,
86 pub output: String,
88 pub stderr: String,
90 pub usage: UsageStats,
92 pub model: Option<String>,
94 pub stop_reason: Option<String>,
96 pub error_message: Option<String>,
98 pub step: Option<usize>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104pub enum SubagentMode {
106 Single,
108 Parallel,
110 Chain,
112}
113
114#[derive(Debug, Clone)]
115pub struct SubagentDetails {
117 pub mode: SubagentMode,
119 pub results: Vec<SingleResult>,
121}
122
123fn 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
162fn 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
196async 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 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 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#[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 if let Some(ref cb) = on_progress {
290 cb(format!("[{}] running...", agent_name));
291 }
292
293 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 .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 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 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 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, }
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 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
427async 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#[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#[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#[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
549pub struct SubagentTool {
551 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 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 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 async fn _execute_with_params(
694 &self,
695 params: Value,
696 signal: Option<oneshot::Receiver<()>>,
697 ctx: &ToolContext,
698 ) -> Result<AgentToolResult, ToolError> {
699 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 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 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
793fn 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#[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 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 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 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 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
1060async 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
1133async 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
1186async 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
1238fn 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#[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}