1use 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
26const MAX_PARALLEL_TASKS: usize = 8;
29const MAX_CONCURRENCY: usize = 4;
30
31type ProgressFn = ProgressCallback;
34
35fn 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
43pub 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
57pub struct UsageStats {
59 pub input_tokens: u64,
61 pub output_tokens: u64,
63 pub cache_read: u64,
65 pub cache_write: u64,
67 pub cost: f64,
69 pub turns: u32,
71}
72
73#[derive(Debug, Clone)]
74pub struct SingleResult {
76 pub agent: String,
78 pub agent_source: String,
80 pub task: String,
82 pub exit_code: i32,
84 pub output: String,
86 pub stderr: String,
88 pub usage: UsageStats,
90 pub model: Option<String>,
92 pub stop_reason: Option<String>,
94 pub error_message: Option<String>,
96 pub step: Option<usize>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum SubagentMode {
104 Single,
106 Parallel,
108 Chain,
110}
111
112#[derive(Debug, Clone)]
113pub struct SubagentDetails {
115 pub mode: SubagentMode,
117 pub results: Vec<SingleResult>,
119}
120
121fn 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
160fn 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
194async 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 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 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#[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 if let Some(ref cb) = on_progress {
288 cb(format!("[{}] running...", agent_name));
289 }
290
291 let tmp_dir = match create_system_prompt_temp_dir("oxi-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 .env(
324 "OXI_SUBAGENT_DEPTH",
325 (current_subagent_depth() + 1).to_string(),
326 )
327 .env(
328 "OXI_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 let stdout = child.stdout.take().expect("stdout piped but missing");
343 let stderr = child.stderr.take().expect("stderr piped but missing");
344
345 let (line_tx, mut line_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
347 let _reader_handle = tokio::spawn(async move {
348 let reader = BufReader::new(stdout);
349 let mut lines = reader.lines();
350 while let Ok(Some(line)) = lines.next_line().await {
351 if line_tx.send(line).is_err() {
352 break;
353 }
354 }
355 });
356
357 let stderr_handle = tokio::spawn(async move {
359 let mut err = String::new();
360 let reader = BufReader::new(stderr);
361 let mut lines = reader.lines();
362 while let Ok(Some(line)) = lines.next_line().await {
363 err.push_str(&line);
364 err.push('\n');
365 }
366 err
367 });
368
369 let mut final_text = String::new();
371 let mut signal_rx = signal;
372 let mut aborted = false;
373
374 loop {
375 tokio::select! {
376 line = line_rx.recv() => {
377 match line {
378 Some(line) => {
379 process_json_line(&line, &mut result, &mut final_text, &on_progress);
380 }
381 None => break, }
383 }
384 _ = async {
385 match &mut signal_rx {
386 Some(rx) => { let _ = rx.await; }
387 None => std::future::pending::<()>().await,
388 }
389 } => {
390 aborted = true;
391 break;
392 }
393 }
394 }
395
396 if aborted {
397 result.stop_reason = Some("aborted".into());
398 result.error_message = Some("Aborted by user".into());
399 terminate_child(&mut child, stderr_handle, &mut result).await;
400 } else {
401 if let Ok(err_output) = stderr_handle.await {
403 result.stderr = err_output;
404 }
405 match child.wait().await {
406 Ok(status) => result.exit_code = status.code().unwrap_or(1),
407 Err(_) => result.exit_code = 1,
408 }
409 }
410
411 result.output = final_text;
412
413 if let Some(ref cb) = on_progress {
414 let status = if result.exit_code == 0 {
415 "done"
416 } else {
417 "failed"
418 };
419 cb(format!("[{}] {}", agent_name, status));
420 }
421
422 result
423}
424
425async fn run_parallel(
427 cwd: &Path,
428 agents: &[AgentDefinition],
429 tasks: Vec<ParallelTask>,
430 binary_path: PathBuf,
431 on_progress: Option<ProgressFn>,
432) -> Vec<SingleResult> {
433 let n = tasks.len();
434 if n == 0 {
435 return vec![];
436 }
437
438 let limit = MAX_CONCURRENCY.min(n);
439 let indexed_tasks: Vec<(usize, ParallelTask)> = tasks.into_iter().enumerate().collect();
440 let mut all_results: Vec<Option<SingleResult>> = vec![None; n];
441
442 let mut i = 0;
443 while i < indexed_tasks.len() {
444 let end = (i + limit).min(indexed_tasks.len());
445 let chunk: Vec<_> = indexed_tasks[i..end].to_vec();
446 let mut handles = Vec::new();
447
448 for (idx, task) in chunk {
449 let agents = agents.to_vec();
450 let cwd = cwd.to_path_buf();
451 let bp = binary_path.clone();
452 let progress = on_progress.clone();
453
454 handles.push((
455 idx,
456 tokio::spawn(async move {
457 run_single_agent(
458 &cwd,
459 &agents,
460 &task.agent,
461 &task.task,
462 task.cwd.as_deref(),
463 None,
464 None,
465 progress,
466 &bp,
467 )
468 .await
469 }),
470 ));
471 }
472
473 for (idx, handle) in handles {
474 if let Ok(r) = handle.await {
475 all_results[idx] = Some(r);
476 }
477 }
478
479 i = end;
480 }
481
482 all_results
483 .into_iter()
484 .map(|r| {
485 r.unwrap_or_else(|| SingleResult {
486 agent: "unknown".to_string(),
487 agent_source: "unknown".to_string(),
488 task: "unknown".to_string(),
489 exit_code: 1,
490 output: String::new(),
491 stderr: "Task did not complete".to_string(),
492 usage: UsageStats::default(),
493 model: None,
494 stop_reason: Some("error".to_string()),
495 error_message: Some("Task did not complete".to_string()),
496 step: None,
497 })
498 })
499 .collect()
500}
501
502#[derive(Debug, Deserialize, Clone)]
505struct ParallelTask {
506 agent: String,
507 task: String,
508 #[serde(default)]
509 cwd: Option<String>,
510}
511
512#[derive(Debug, Deserialize)]
513struct ChainStep {
514 agent: String,
515 task: String,
516 #[serde(default)]
517 cwd: Option<String>,
518}
519
520pub struct SubagentTool {
524 cwd: Option<PathBuf>,
526 binary_path: Option<PathBuf>,
527 progress_callback: parking_lot::Mutex<Option<ProgressFn>>,
528}
529
530impl Default for SubagentTool {
531 fn default() -> Self {
532 Self::new()
533 }
534}
535
536impl SubagentTool {
537 pub fn new() -> Self {
539 Self {
540 cwd: None,
541 binary_path: None,
542 progress_callback: parking_lot::Mutex::new(None),
543 }
544 }
545
546 pub fn with_cwd(cwd: impl Into<PathBuf>) -> Self {
548 Self {
549 cwd: Some(cwd.into()),
550 binary_path: None,
551 progress_callback: parking_lot::Mutex::new(None),
552 }
553 }
554
555 fn get_binary(&self) -> PathBuf {
556 self.binary_path
557 .clone()
558 .or_else(|| std::env::current_exe().ok())
559 .unwrap_or_else(|| PathBuf::from("oxi"))
560 }
561}
562
563#[async_trait]
564impl AgentTool for SubagentTool {
565 fn name(&self) -> &str {
566 "subagent"
567 }
568
569 fn label(&self) -> &str {
570 "Subagent"
571 }
572
573 fn description(&self) -> &str {
574 "Delegate tasks to specialized subagents with isolated context. \
575 Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder). \
576 Agents are discovered from ~/.oxi/agents/ (user) and .oxi/agents/ (project)."
577 }
578
579 fn parameters_schema(&self) -> Value {
580 json!({
581 "type": "object",
582 "properties": {
583 "agent": {
584 "type": "string",
585 "description": "Agent name for single mode"
586 },
587 "task": {
588 "type": "string",
589 "description": "Task to delegate (single mode)"
590 },
591 "tasks": {
592 "type": "array",
593 "description": "Array of {agent, task} for parallel execution (max 8)",
594 "items": {
595 "type": "object",
596 "properties": {
597 "agent": { "type": "string" },
598 "task": { "type": "string" },
599 "cwd": { "type": "string" }
600 },
601 "required": ["agent", "task"]
602 }
603 },
604 "chain": {
605 "type": "array",
606 "description": "Array of {agent, task} for sequential execution. Use {previous} in task for prior output.",
607 "items": {
608 "type": "object",
609 "properties": {
610 "agent": { "type": "string" },
611 "task": { "type": "string" },
612 "cwd": { "type": "string" }
613 },
614 "required": ["agent", "task"]
615 }
616 },
617 "agentScope": {
618 "type": "string",
619 "description": "Agent discovery scope: 'user' (default), 'project', or 'both'",
620 "enum": ["user", "project", "both"],
621 "default": "user"
622 },
623 "cwd": {
624 "type": "string",
625 "description": "Working directory for single mode"
626 }
627 }
628 })
629 }
630
631 fn on_progress(&self, callback: ProgressCallback) {
632 *self.progress_callback.lock() = Some(callback);
633 }
634
635 async fn execute(
636 &self,
637 _tool_call_id: &str,
638 params: Value,
639 signal: Option<oneshot::Receiver<()>>,
640 ctx: &ToolContext,
641 ) -> Result<AgentToolResult, ToolError> {
642 let runner = ctx.subagent_runner.clone();
649 let depth = if runner.is_some() {
650 ctx.subagent_depth
651 } else {
652 current_subagent_depth()
653 };
654 let max = if runner.is_some() {
655 3 } else {
657 max_subagent_depth()
658 };
659 if depth >= max {
660 return Ok(AgentToolResult::error(format!(
661 "Subagent depth limit reached ({}/{}). \
662 Increase max_subagent_depth in your agent definition.",
663 depth, max
664 )));
665 }
666
667 let effective_cwd = self.cwd.as_deref().unwrap_or(ctx.root());
669
670 let scope: AgentScope = params
671 .get("agentScope")
672 .and_then(|v| serde_json::from_value(v.clone()).ok())
673 .unwrap_or(AgentScope::User);
674
675 let agents = discover_agents(effective_cwd, scope);
676 let progress = self.progress_callback.lock().clone();
677
678 let has_chain = params["chain"]
679 .as_array()
680 .map(|a| !a.is_empty())
681 .unwrap_or(false);
682 let has_tasks = params["tasks"]
683 .as_array()
684 .map(|a| !a.is_empty())
685 .unwrap_or(false);
686 let has_single = params["agent"].is_string() && params["task"].is_string();
687
688 let mode_count = [has_chain, has_tasks, has_single]
689 .iter()
690 .filter(|&&x| x)
691 .count();
692
693 if mode_count != 1 {
694 let available = agents
695 .iter()
696 .map(|a| format!("{} ({})", a.name, a.source))
697 .collect::<Vec<_>>()
698 .join(", ");
699 return Ok(AgentToolResult::error(format!(
700 "Provide exactly one mode: agent+task, tasks, or chain.\nAvailable agents: {}",
701 if available.is_empty() {
702 "none".to_string()
703 } else {
704 available
705 }
706 )));
707 }
708
709 if let Some(runner) = &runner {
715 return execute_in_process(
716 effective_cwd,
717 &agents,
718 params,
719 runner,
720 depth,
721 progress,
722 signal,
723 )
724 .await;
725 }
726
727 let binary = self.get_binary();
729
730 if has_chain {
732 return execute_chain_mode(effective_cwd, &agents, params, &binary, progress, signal)
733 .await;
734 }
735
736 if has_tasks {
738 return execute_parallel_mode(effective_cwd, &agents, params, &binary, progress).await;
739 }
740
741 if has_single {
743 let desc = params["task"]
745 .as_str()
746 .map(|s| s.chars().take(80).collect::<String>())
747 .unwrap_or_else(|| "subagent task".into());
748 if let Some(todo) = &ctx.todo {
749 use crate::tools::todo::TodoOp;
750 let _ = todo
751 .apply_ops(vec![TodoOp::Start {
752 task: Some(desc.clone()),
753 phase: Some("Subagents".into()),
754 }])
755 .await;
756 }
757 let result =
758 execute_single_mode(effective_cwd, &agents, params, &binary, progress, signal)
759 .await;
760 if let Some(todo) = &ctx.todo {
762 use crate::tools::todo::TodoOp;
763 let _ = todo
764 .apply_ops(vec![TodoOp::Done {
765 task: Some(desc),
766 phase: None,
767 }])
768 .await;
769 }
770 return result;
771 }
772
773 Ok(AgentToolResult::error("Invalid parameters".to_string()))
774 }
775}
776
777fn fork_to_single(
788 fork: super::ForkResult,
789 agent_name: &str,
790 task: &str,
791 step: Option<usize>,
792) -> SingleResult {
793 let error = fork.error.clone();
794 SingleResult {
795 agent: agent_name.to_string(),
796 agent_source: "in-process".to_string(),
797 task: task.to_string(),
798 exit_code: if error.is_some() { 1 } else { 0 },
799 output: fork.text,
800 stderr: String::new(),
801 usage: UsageStats {
802 input_tokens: fork.input_tokens as u64,
803 output_tokens: fork.output_tokens as u64,
804 turns: fork.turns,
805 ..Default::default()
806 },
807 model: fork.model,
808 stop_reason: if error.is_some() {
809 Some("error".to_string())
810 } else {
811 Some("complete".to_string())
812 },
813 error_message: error,
814 step,
815 }
816}
817
818#[allow(clippy::too_many_arguments)]
820async fn execute_in_process(
821 cwd: &Path,
822 agents: &[AgentDefinition],
823 params: Value,
824 runner: &Arc<dyn super::SubagentRunner>,
825 depth: u8,
826 progress: Option<ProgressFn>,
827 _signal: Option<oneshot::Receiver<()>>,
828) -> Result<AgentToolResult, ToolError> {
829 let has_chain = params["chain"]
830 .as_array()
831 .map(|a| !a.is_empty())
832 .unwrap_or(false);
833 let has_tasks = params["tasks"]
834 .as_array()
835 .map(|a| !a.is_empty())
836 .unwrap_or(false);
837 let has_single = params["agent"].is_string() && params["task"].is_string();
838
839 if has_single {
841 let agent_name = params["agent"].as_str().unwrap_or("");
842 let task = params["task"].as_str().unwrap_or("");
843 let agent_def = agents.iter().find(|a| a.name == agent_name);
844
845 if let Some(ref cb) = progress {
846 cb(format!("[{}] running (in-process)...", agent_name));
847 }
848
849 let fork = runner
850 .run_isolated(
851 agent_name,
852 task,
853 agent_def.and_then(|d| d.system_prompt.as_deref()),
854 agent_def.and_then(|d| d.model.as_deref()),
855 agent_def.map(|d| d.tools.as_slice()).unwrap_or(&[]),
856 cwd,
857 depth,
858 )
859 .await
860 .map_err(|e| format!("In-process subagent failed: {e}"))?;
861
862 let result = fork_to_single(fork, agent_name, task, None);
863 let is_error = result.stop_reason.as_deref() == Some("error");
864
865 if is_error {
866 let error_msg = result.error_message.as_deref().unwrap_or("unknown error");
867 return Ok(AgentToolResult::error(format!("Agent failed: {error_msg}")));
868 }
869
870 return Ok(AgentToolResult::success(if result.output.is_empty() {
871 "(no output)".to_string()
872 } else {
873 result.output.clone()
874 })
875 .with_metadata(json!({
876 "mode": "single",
877 "agent": result.agent,
878 "source": result.agent_source,
879 "backend": "in-process",
880 "usage": {
881 "input_tokens": result.usage.input_tokens,
882 "output_tokens": result.usage.output_tokens,
883 "turns": result.usage.turns,
884 },
885 })));
886 }
887
888 if has_tasks {
890 let tasks: Vec<ParallelTask> = serde_json::from_value(params["tasks"].clone())
891 .map_err(|e| format!("Invalid tasks parameter: {e}"))?;
892 let total = tasks.len();
893 if total == 0 {
894 return Ok(AgentToolResult::error("No tasks provided".to_string()));
895 }
896
897 let limit = MAX_CONCURRENCY.min(total);
900 let mut all_results: Vec<SingleResult> = Vec::with_capacity(total);
901 let mut all_errors: Vec<String> = Vec::new();
902
903 for chunk in tasks.chunks(limit) {
904 let mut handles = Vec::new();
905 for task in chunk {
906 let agent_def = agents.iter().find(|a| a.name == task.agent);
907 let runner = Arc::clone(runner);
908 let agent_name = task.agent.clone();
909 let task_text = task.task.clone();
910 let system_prompt = agent_def.and_then(|d| d.system_prompt.clone());
911 let model = agent_def.and_then(|d| d.model.clone());
912 let tools: Vec<String> = agent_def.map(|d| d.tools.clone()).unwrap_or_default();
913 let cwd = cwd.to_path_buf();
914
915 handles.push(tokio::spawn(async move {
916 runner
917 .run_isolated(
918 &agent_name,
919 &task_text,
920 system_prompt.as_deref(),
921 model.as_deref(),
922 &tools,
923 &cwd,
924 depth,
925 )
926 .await
927 }));
928 }
929
930 for (i, handle) in handles.into_iter().enumerate() {
931 let task = &chunk[i];
932 match handle.await {
933 Ok(Ok(fork)) => {
934 all_results.push(fork_to_single(fork, &task.agent, &task.task, None))
935 }
936 Ok(Err(e)) => all_errors.push(format!("{}: {e}", task.agent)),
937 Err(e) => all_errors.push(format!("{}: join error: {e}", task.agent)),
938 }
939 }
940 }
941
942 if !all_errors.is_empty() {
943 return Ok(AgentToolResult::error(format!(
944 "Errors: {}",
945 all_errors.join("; ")
946 )));
947 }
948
949 let success_count = all_results.iter().filter(|r| r.exit_code == 0).count();
950 let summaries: Vec<String> = all_results
951 .iter()
952 .map(|r| format!("[{}] {}", r.agent, r.output))
953 .collect();
954
955 return Ok(AgentToolResult::success(format!(
956 "Parallel: {}/{} succeeded\n\n{}",
957 success_count,
958 all_results.len(),
959 summaries.join("\n\n---\n\n")
960 ))
961 .with_metadata(json!({
962 "mode": "parallel",
963 "backend": "in-process",
964 "results": all_results.iter().map(|r| json!({
965 "agent": r.agent,
966 "exit_code": r.exit_code,
967 })).collect::<Vec<_>>()
968 })));
969 }
970
971 if has_chain {
973 let steps: Vec<ChainStep> = serde_json::from_value(params["chain"].clone())
974 .map_err(|e| format!("Invalid chain parameter: {e}"))?;
975 let total = steps.len();
976 let mut previous_output = String::new();
977 let mut results: Vec<SingleResult> = Vec::new();
978
979 for (i, step) in steps.into_iter().enumerate() {
980 let task = step.task.replace("{previous}", &previous_output);
981 let agent_def = agents.iter().find(|a| a.name == step.agent);
982
983 if let Some(ref cb) = progress {
984 cb(format!("[{}] chain step {}/{}", step.agent, i + 1, total));
985 }
986
987 let fork = runner
988 .run_isolated(
989 &step.agent,
990 &task,
991 agent_def.and_then(|d| d.system_prompt.as_deref()),
992 agent_def.and_then(|d| d.model.as_deref()),
993 agent_def.map(|d| d.tools.as_slice()).unwrap_or(&[]),
994 cwd,
995 depth,
996 )
997 .await;
998
999 let fork = match fork {
1000 Ok(f) => f,
1001 Err(e) => {
1002 return Ok(AgentToolResult::error(format!(
1003 "Chain stopped at step {}/{} ({}): {e}",
1004 i + 1,
1005 total,
1006 step.agent
1007 )));
1008 }
1009 };
1010
1011 let is_error = fork.error.is_some();
1012 let result = fork_to_single(fork, &step.agent, &task, Some(i + 1));
1013
1014 if is_error {
1015 let error_msg = result.error_message.clone().unwrap_or_default();
1016 return Ok(AgentToolResult::error(format!(
1017 "Chain stopped at step {}/{} ({}): {error_msg}",
1018 i + 1,
1019 total,
1020 step.agent
1021 )));
1022 }
1023
1024 previous_output = result.output.clone();
1025 results.push(result);
1026 }
1027
1028 let output = results.last().map(|r| r.output.clone()).unwrap_or_default();
1029 return Ok(AgentToolResult::success(if output.is_empty() {
1030 "(no output)".to_string()
1031 } else {
1032 output
1033 })
1034 .with_metadata(json!({
1035 "mode": "chain",
1036 "backend": "in-process",
1037 "steps": results.len(),
1038 })));
1039 }
1040
1041 Ok(AgentToolResult::error("Invalid parameters".to_string()))
1042}
1043
1044async fn execute_chain_mode(
1046 cwd: &Path,
1047 agents: &[AgentDefinition],
1048 params: Value,
1049 binary: &Path,
1050 progress: Option<ProgressFn>,
1051 signal: Option<oneshot::Receiver<()>>,
1052) -> Result<AgentToolResult, ToolError> {
1053 let steps: Vec<ChainStep> = serde_json::from_value(params["chain"].clone())
1054 .map_err(|e| format!("Invalid chain parameter: {}", e))?;
1055 let total = steps.len();
1056 let mut results = Vec::new();
1057 let mut previous_output = String::new();
1058 let mut abort_signal = signal;
1059
1060 for (i, step) in steps.into_iter().enumerate() {
1061 let task = step.task.replace("{previous}", &previous_output);
1062 let step_signal = if i == total - 1 {
1063 abort_signal.take()
1064 } else {
1065 None
1066 };
1067
1068 let result = run_single_agent(
1069 cwd,
1070 agents,
1071 &step.agent,
1072 &task,
1073 step.cwd.as_deref(),
1074 Some(i + 1),
1075 step_signal,
1076 progress.clone(),
1077 binary,
1078 )
1079 .await;
1080
1081 let is_error = result.exit_code != 0
1082 || result.stop_reason.as_deref() == Some("error")
1083 || result.stop_reason.as_deref() == Some("aborted");
1084
1085 if is_error {
1086 let agent_name = result.agent.clone();
1087 let error_msg = result
1088 .error_message
1089 .clone()
1090 .unwrap_or_else(|| result.stderr.clone());
1091 results.push(result);
1092 return Ok(AgentToolResult::error(format!(
1093 "Chain stopped at step {}/{} ({}): {}",
1094 i + 1,
1095 total,
1096 agent_name,
1097 error_msg
1098 )));
1099 }
1100
1101 previous_output = result.output.clone();
1102 results.push(result);
1103 }
1104
1105 let output = results.last().map(|r| r.output.clone()).unwrap_or_default();
1106 Ok(AgentToolResult::success(if output.is_empty() {
1107 "(no output)".to_string()
1108 } else {
1109 output
1110 })
1111 .with_metadata(json!({
1112 "mode": "chain",
1113 "steps": results.len(),
1114 })))
1115}
1116
1117async fn execute_parallel_mode(
1119 cwd: &Path,
1120 agents: &[AgentDefinition],
1121 params: Value,
1122 binary: &Path,
1123 progress: Option<ProgressFn>,
1124) -> Result<AgentToolResult, ToolError> {
1125 let tasks: Vec<ParallelTask> = serde_json::from_value(params["tasks"].clone())
1126 .map_err(|e| format!("Invalid tasks parameter: {}", e))?;
1127
1128 if tasks.len() > MAX_PARALLEL_TASKS {
1129 return Ok(AgentToolResult::error(format!(
1130 "Too many parallel tasks ({}). Max is {}.",
1131 tasks.len(),
1132 MAX_PARALLEL_TASKS
1133 )));
1134 }
1135
1136 let results = run_parallel(cwd, agents, tasks, binary.to_path_buf(), progress).await;
1137
1138 let success_count = results.iter().filter(|r| r.exit_code == 0).count();
1139 let summaries: Vec<String> = results
1140 .iter()
1141 .map(|r| {
1142 let _preview = truncate_output(&r.output, 100);
1143 format!(
1144 "[{}]: {}",
1145 r.agent,
1146 if r.exit_code == 0 {
1147 "completed"
1148 } else {
1149 "failed"
1150 },
1151 )
1152 })
1153 .collect();
1154
1155 Ok(AgentToolResult::success(format!(
1156 "Parallel: {}/{} succeeded\n\n{}",
1157 success_count,
1158 results.len(),
1159 summaries.join("\n\n")
1160 ))
1161 .with_metadata(json!({
1162 "mode": "parallel",
1163 "results": results.iter().map(|r| json!({
1164 "agent": r.agent,
1165 "exit_code": r.exit_code,
1166 })).collect::<Vec<_>>()
1167 })))
1168}
1169
1170async fn execute_single_mode(
1172 cwd: &Path,
1173 agents: &[AgentDefinition],
1174 params: Value,
1175 binary: &Path,
1176 progress: Option<ProgressFn>,
1177 signal: Option<oneshot::Receiver<()>>,
1178) -> Result<AgentToolResult, ToolError> {
1179 let agent_name = params["agent"]
1180 .as_str()
1181 .ok_or("Missing required parameter: agent")?;
1182 let task = params["task"]
1183 .as_str()
1184 .ok_or("Missing required parameter: task")?;
1185 let agent_cwd = params["cwd"].as_str();
1186
1187 let result = run_single_agent(
1188 cwd, agents, agent_name, task, agent_cwd, None, signal, progress, binary,
1189 )
1190 .await;
1191
1192 let is_error = result.exit_code != 0
1193 || result.stop_reason.as_deref() == Some("error")
1194 || result.stop_reason.as_deref() == Some("aborted");
1195
1196 if is_error {
1197 let error_msg = result.error_message.as_deref().unwrap_or(&result.stderr);
1198 return Ok(AgentToolResult::error(format!(
1199 "Agent {}: {}",
1200 result.stop_reason.as_deref().unwrap_or("failed"),
1201 error_msg
1202 )));
1203 }
1204
1205 Ok(AgentToolResult::success(if result.output.is_empty() {
1206 "(no output)".to_string()
1207 } else {
1208 result.output.clone()
1209 })
1210 .with_metadata(json!({
1211 "mode": "single",
1212 "agent": result.agent,
1213 "source": result.agent_source,
1214 "usage": {
1215 "input_tokens": result.usage.input_tokens,
1216 "output_tokens": result.usage.output_tokens,
1217 "turns": result.usage.turns,
1218 },
1219 })))
1220}
1221
1222fn truncate_output(text: &str, max_chars: usize) -> String {
1225 if text.len() <= max_chars {
1226 text.to_string()
1227 } else {
1228 format!("{}...", &text[..max_chars])
1229 }
1230}
1231
1232#[cfg(test)]
1235mod tests {
1236 use super::*;
1237
1238 #[test]
1239 fn test_discover_agents_empty_dir() {
1240 let tmp = tempfile::tempdir().unwrap();
1241 let agents = discover_agents(tmp.path(), AgentScope::Project);
1242 assert!(agents.is_empty());
1243 }
1244
1245 #[test]
1246 fn test_discover_agents_with_flat_files() {
1247 let tmp = tempfile::tempdir().unwrap();
1248 let agents_dir = tmp.path().join(".oxi").join("agents");
1249 std::fs::create_dir_all(&agents_dir).unwrap();
1250 std::fs::write(
1251 agents_dir.join("scout.md"),
1252 "---\nname: scout\ndescription: Recon\n---\nBe a scout.",
1253 )
1254 .unwrap();
1255 std::fs::write(
1256 agents_dir.join("worker.md"),
1257 "---\nname: worker\n---\nBe a worker.",
1258 )
1259 .unwrap();
1260 std::fs::write(agents_dir.join("ignore.txt"), "ignore me").unwrap();
1261 let agents = discover_agents(tmp.path(), AgentScope::Project);
1262 assert_eq!(agents.len(), 2);
1263 assert!(agents.iter().any(|a| a.name == "scout"));
1264 assert!(agents.iter().any(|a| a.name == "worker"));
1265 }
1266
1267 #[test]
1268 fn test_schema_structure() {
1269 let tool = SubagentTool::new();
1270 let schema = tool.parameters_schema();
1271 assert_eq!(schema["type"], "object");
1272 assert!(schema["properties"]["agent"].is_object());
1273 assert!(schema["properties"]["tasks"].is_object());
1274 assert!(schema["properties"]["chain"].is_object());
1275 assert!(schema["properties"]["agentScope"].is_object());
1276 }
1277
1278 #[test]
1279 fn test_truncate_output() {
1280 assert_eq!(truncate_output("hello", 10), "hello");
1281 assert_eq!(truncate_output("hello world foo", 5), "hello...");
1282 }
1283
1284 #[test]
1285 fn test_process_json_line_text_delta() {
1286 let mut result = SingleResult {
1287 agent: "test".into(),
1288 agent_source: "user".into(),
1289 task: "t".into(),
1290 exit_code: 0,
1291 output: String::new(),
1292 stderr: String::new(),
1293 usage: UsageStats::default(),
1294 model: None,
1295 stop_reason: None,
1296 error_message: None,
1297 step: None,
1298 };
1299 let mut text = String::new();
1300 process_json_line(
1301 r#"{"type":"text_delta","text":"hello"}"#,
1302 &mut result,
1303 &mut text,
1304 &None,
1305 );
1306 assert_eq!(text, "hello");
1307 }
1308
1309 #[test]
1310 fn test_process_json_line_usage() {
1311 let mut result = SingleResult {
1312 agent: "test".into(),
1313 agent_source: "user".into(),
1314 task: "t".into(),
1315 exit_code: 0,
1316 output: String::new(),
1317 stderr: String::new(),
1318 usage: UsageStats::default(),
1319 model: None,
1320 stop_reason: None,
1321 error_message: None,
1322 step: None,
1323 };
1324 let mut text = String::new();
1325 process_json_line(
1326 r#"{"type":"usage","input_tokens":100,"output_tokens":50}"#,
1327 &mut result,
1328 &mut text,
1329 &None,
1330 );
1331 assert_eq!(result.usage.input_tokens, 100);
1332 assert_eq!(result.usage.output_tokens, 50);
1333 assert_eq!(result.usage.turns, 1);
1334 }
1335
1336 #[test]
1337 fn test_depth_limit_default() {
1338 unsafe {
1339 std::env::remove_var("OXI_SUBAGENT_DEPTH");
1340 std::env::remove_var("OXI_MAX_SUBAGENT_DEPTH");
1341 }
1342 assert_eq!(current_subagent_depth(), 0);
1343 assert_eq!(max_subagent_depth(), 3);
1344 }
1345}