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