1use crate::error::MultiError;
9use crate::mailbox::Mailbox;
10use crate::runner::AgentRunner;
11use crate::shared::SharedInfra;
12use crate::types::{AgentOutput, AgentSpec};
13use serde::{Deserialize, Serialize};
14use std::sync::Arc;
15use std::time::Instant;
16use tracing::instrument;
17
18#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "snake_case")]
20pub enum SwarmMode {
21 Parallel,
22 Sequential,
23 Debate,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SwarmResult {
28 pub task: String,
29 pub outputs: Vec<AgentOutput>,
30 pub final_summary: String,
31}
32
33pub struct Swarm {
34 pub agents: Vec<AgentSpec>,
35 pub mode: SwarmMode,
36 pub synthesizer: Option<AgentSpec>,
37 pub isolated: bool,
41 pub workspaces: Option<crate::workspace::WorkspaceConfig>,
44}
45
46impl Swarm {
47 pub fn new(agents: Vec<AgentSpec>, mode: SwarmMode) -> Self {
48 Self {
49 agents,
50 mode,
51 synthesizer: None,
52 isolated: false,
53 workspaces: None,
54 }
55 }
56
57 pub fn with_synthesizer(mut self, spec: AgentSpec) -> Self {
58 self.synthesizer = Some(spec);
59 self
60 }
61
62 pub fn with_isolation(mut self) -> Self {
64 self.isolated = true;
65 self
66 }
67
68 pub fn with_workspaces(mut self, config: crate::workspace::WorkspaceConfig) -> Self {
74 self.workspaces = Some(config);
75 self
76 }
77
78 #[instrument(name = "multi.swarm", skip_all)]
79 pub fn run<'a>(
80 &'a self,
81 task: &'a str,
82 runner: &'a Arc<dyn AgentRunner>,
83 infra: &'a SharedInfra,
84 ) -> futures::future::BoxFuture<'a, Result<SwarmResult, MultiError>> {
85 Box::pin(async move {
86 match self.mode {
87 SwarmMode::Parallel => self.run_parallel(task, runner, infra).await,
88 SwarmMode::Sequential => self.run_sequential(task, runner, infra).await,
89 SwarmMode::Debate => self.run_debate(task, runner, infra).await,
90 }
91 })
92 }
93
94 async fn run_parallel(
95 &self,
96 task: &str,
97 runner: &Arc<dyn AgentRunner>,
98 infra: &SharedInfra,
99 ) -> Result<SwarmResult, MultiError> {
100 let mailbox = Arc::new(Mailbox::default());
101
102 let cc = if self.isolated {
111 infra.concurrency.clone()
112 } else {
113 None
114 };
115 let parent_keys_before: std::collections::HashSet<String> = if cc.is_some() {
116 infra.state.keys().into_iter().collect()
117 } else {
118 std::collections::HashSet::new()
119 };
120
121 enum Slot {
125 Spawned(usize),
126 Skipped(AgentOutput),
127 }
128
129 let mut handles: Vec<
136 tokio::task::JoinHandle<(
137 Result<AgentOutput, MultiError>,
138 Option<crate::task_context::AgentContext>,
139 u64,
140 u64,
141 )>,
142 > = Vec::new();
143 let mut slots: Vec<Slot> = Vec::new();
144
145 for spec in &self.agents {
146 let workspace = match &self.workspaces {
153 Some(cfg) => match crate::workspace::AgentWorkspace::provision(cfg, &spec.name) {
154 Ok(ws) => Some(ws),
155 Err(e) => {
156 slots.push(Slot::Skipped(AgentOutput {
157 name: spec.name.clone(),
158 answer: String::new(),
159 turns: 0,
160 tool_calls: 0,
161 duration_ms: 0.0,
162 error: Some(format!("workspace provisioning failed: {e}")),
163 outcome: None,
164 tokens: None,
165 tools_used: Vec::new(),
166 }));
167 continue;
168 }
169 },
170 None => None,
171 };
172
173 if let Err(e) = infra.begin_agent() {
178 slots.push(Slot::Skipped(crate::budget::budget_skipped_output(
179 &spec.name, &e,
180 )));
181 continue;
182 }
183
184 let runner = Arc::clone(runner);
185 let mut spec = spec.clone();
186 if let Some(ws) = &workspace {
187 spec = ws.inject(spec);
188 }
189 let task = task.to_string();
190 let mailbox = Arc::clone(&mailbox);
191
192 let cc = cc.clone();
193 if self.isolated {
194 let (rt, ctx) = infra.make_isolated_runtime(&spec.name);
195 for tool in &spec.tools {
196 rt.register_tool(tool).await;
197 }
198 let ctx_clone = ctx.clone();
199 handles.push(tokio::spawn(async move {
200 let _workspace = workspace;
203 let read_at = cc.as_ref().map(|c| c.tick()).unwrap_or(0);
206 let result = crate::task_context::TaskScope::run(ctx_clone, async {
207 runner.run(&spec, &task, &rt, &mailbox).await
208 })
209 .await;
210 let commit_at = cc.as_ref().map(|c| c.tick()).unwrap_or(0);
211 (result, Some(ctx), read_at, commit_at)
212 }));
213 } else {
214 let rt = infra.make_runtime();
215 for tool in &spec.tools {
216 rt.register_tool(tool).await;
217 }
218 handles.push(tokio::spawn(async move {
219 let _workspace = workspace;
220 let read_at = cc.as_ref().map(|c| c.tick()).unwrap_or(0);
221 let result = runner.run(&spec, &task, &rt, &mailbox).await;
222 let commit_at = cc.as_ref().map(|c| c.tick()).unwrap_or(0);
223 (result, None, read_at, commit_at)
224 }));
225 }
226 slots.push(Slot::Spawned(handles.len() - 1));
227 }
228
229 let mut results: Vec<Option<_>> = futures::future::join_all(handles)
231 .await
232 .into_iter()
233 .map(Some)
234 .collect();
235
236 enum Resolved {
241 Pending {
242 output: AgentOutput,
243 ctx: Option<crate::task_context::AgentContext>,
244 },
245 Terminal(AgentOutput),
246 }
247 let mut resolved: Vec<Resolved> = Vec::new();
248 let mut ops: Vec<car_verify::concurrency::AgentOp> = Vec::new();
249 for (i, slot) in slots.into_iter().enumerate() {
250 let handle_idx = match slot {
251 Slot::Skipped(output) => {
252 resolved.push(Resolved::Terminal(output));
253 continue;
254 }
255 Slot::Spawned(idx) => idx,
256 };
257 match results.get_mut(handle_idx).and_then(Option::take) {
258 Some(Ok((Ok(output), ctx, read_at, commit_at))) => {
259 if cc.is_some() {
266 if let Some(ctx) = &ctx {
267 let write_set = ctx.local_state.keys();
268 let read_set: Vec<String> = write_set
269 .iter()
270 .filter(|k| parent_keys_before.contains(*k))
271 .cloned()
272 .collect();
273 ops.push(car_verify::concurrency::AgentOp {
274 id: output.name.clone(),
275 agent: output.name.clone(),
276 read_set,
277 write_set,
278 tools_read: output.tools_used.clone(),
279 tools_written: Vec::new(),
280 depends_on: Vec::new(),
281 read_at,
282 commit_at,
283 });
284 }
285 }
286 resolved.push(Resolved::Pending { output, ctx });
287 }
288 Some(Ok((Err(e), _ctx, _r, _c))) => {
289 resolved.push(Resolved::Terminal(AgentOutput {
293 name: self.agents[i].name.clone(),
294 answer: String::new(),
295 turns: 0,
296 tool_calls: 0,
297 duration_ms: 0.0,
298 error: Some(e.to_string()),
299 outcome: None,
300 tokens: None,
301 tools_used: Vec::new(),
302 }));
303 }
304 Some(Err(e)) => {
305 resolved.push(Resolved::Terminal(AgentOutput {
306 name: self.agents[i].name.clone(),
307 answer: String::new(),
308 turns: 0,
309 tool_calls: 0,
310 duration_ms: 0.0,
311 error: Some(format!("join error: {}", e)),
312 outcome: None,
313 tokens: None,
314 tools_used: Vec::new(),
315 }));
316 }
317 None => {
318 resolved.push(Resolved::Terminal(AgentOutput {
319 name: self.agents[i].name.clone(),
320 answer: String::new(),
321 turns: 0,
322 tool_calls: 0,
323 duration_ms: 0.0,
324 error: Some("internal: missing join result".to_string()),
325 outcome: None,
326 tokens: None,
327 tools_used: Vec::new(),
328 }));
329 }
330 }
331 }
332
333 let guard = match &cc {
339 Some(control) => Some(control.guard(&ops, &infra.log).await),
340 None => None,
341 };
342 if let Some(g) = &guard {
343 if g.abort {
344 for entry in &resolved {
349 if let Resolved::Pending { output, .. } = entry {
350 infra.record_output_metered(output).await;
351 }
352 }
353 return Err(MultiError::ConcurrencyAbort(g.anomaly_summary()));
354 }
355 }
356
357 let mut outputs = Vec::new();
364 let mut to_merge: Vec<crate::task_context::AgentContext> = Vec::new();
365 for entry in resolved {
366 match entry {
367 Resolved::Terminal(o) => outputs.push(o),
368 Resolved::Pending { output, ctx } => {
369 let committable = guard
370 .as_ref()
371 .map(|g| g.may_commit(&output.name))
372 .unwrap_or(true);
373 if committable {
374 if let Some(ctx) = ctx {
376 to_merge.push(ctx);
377 }
378 infra.record_output_metered(&output).await;
380 infra.state.set(
382 &format!("agent.{}.answer", output.name),
383 serde_json::Value::String(output.answer.clone()),
384 &format!("swarm.{}", output.name),
385 );
386 outputs.push(output);
387 } else {
388 infra.record_output_metered(&output).await;
395 let reason = guard
396 .as_ref()
397 .and_then(|g| g.rejection_reason(&output.name))
398 .unwrap_or_else(|| "concurrency gate rejected commit".to_string());
399 outputs.push(AgentOutput {
400 name: output.name.clone(),
401 answer: String::new(),
402 turns: output.turns,
403 tool_calls: output.tool_calls,
404 duration_ms: output.duration_ms,
405 error: Some(reason),
406 outcome: None,
407 tokens: output.tokens.clone(),
408 tools_used: output.tools_used.clone(),
409 });
410 }
411 }
412 }
413 }
414 if guard.is_some() {
418 to_merge.sort_by(|a, b| a.agent_name.cmp(&b.agent_name));
419 }
420 for ctx in &to_merge {
421 ctx.merge_to_parent();
422 }
423
424 let summary = self.synthesize(task, &outputs, runner, infra).await;
425
426 Ok(SwarmResult {
427 task: task.to_string(),
428 outputs,
429 final_summary: summary,
430 })
431 }
432
433 async fn run_sequential(
434 &self,
435 task: &str,
436 runner: &Arc<dyn AgentRunner>,
437 infra: &SharedInfra,
438 ) -> Result<SwarmResult, MultiError> {
439 let mailbox = Arc::new(Mailbox::default());
440 let mut outputs = Vec::new();
441
442 for spec in &self.agents {
443 if let Err(e) = infra.begin_agent() {
447 outputs.push(crate::budget::budget_skipped_output(&spec.name, &e));
448 continue;
449 }
450
451 let enriched = if outputs.is_empty() {
453 task.to_string()
454 } else {
455 let prior: Vec<String> = outputs
456 .iter()
457 .filter_map(|o: &AgentOutput| {
458 if o.succeeded() {
459 Some(format!("- {}: {}", o.name, truncate(&o.answer, 300)))
460 } else {
461 None
462 }
463 })
464 .collect();
465 format!("{}\n\nPrior agents' findings:\n{}", task, prior.join("\n"))
466 };
467
468 let rt = infra.make_runtime();
469 for tool in &spec.tools {
470 rt.register_tool(tool).await;
471 }
472
473 let start = Instant::now();
474 match runner.run(spec, &enriched, &rt, &mailbox).await {
475 Ok(output) => {
476 infra.record_output_metered(&output).await;
477 infra.state.set(
478 &format!("agent.{}.answer", output.name),
479 serde_json::Value::String(output.answer.clone()),
480 &format!("swarm.{}", output.name),
481 );
482 outputs.push(output);
483 }
484 Err(e) => {
485 outputs.push(AgentOutput {
486 name: spec.name.clone(),
487 answer: String::new(),
488 turns: 0,
489 tool_calls: 0,
490 duration_ms: start.elapsed().as_secs_f64() * 1000.0,
491 error: Some(e.to_string()),
492 outcome: None,
493 tokens: None,
494 tools_used: Vec::new(),
495 });
496 }
497 }
498 }
499
500 let summary = self.synthesize(task, &outputs, runner, infra).await;
501
502 Ok(SwarmResult {
503 task: task.to_string(),
504 outputs,
505 final_summary: summary,
506 })
507 }
508
509 async fn run_debate(
510 &self,
511 task: &str,
512 runner: &Arc<dyn AgentRunner>,
513 infra: &SharedInfra,
514 ) -> Result<SwarmResult, MultiError> {
515 let round1 = Swarm::new(self.agents.clone(), SwarmMode::Parallel)
517 .run(task, runner, infra)
518 .await?;
519
520 let mut critique_specs = Vec::new();
522 for spec in &self.agents {
523 let others: Vec<String> = round1
524 .outputs
525 .iter()
526 .filter(|o| o.name != spec.name && o.succeeded())
527 .map(|o| format!("- {}: {}", o.name, truncate(&o.answer, 300)))
528 .collect();
529
530 let critique_prompt = format!(
531 "{}\n\nOriginal task: {}\n\nOther agents' answers:\n{}\n\n\
532 Critique these answers and provide your improved response.",
533 spec.system_prompt,
534 task,
535 others.join("\n")
536 );
537
538 let mut critique_spec = spec.clone();
539 critique_spec.name = format!("{}_critique", spec.name);
540 critique_spec.system_prompt = critique_prompt;
541 critique_specs.push(critique_spec);
542 }
543
544 let round2 = Swarm::new(critique_specs, SwarmMode::Parallel)
545 .run(task, runner, infra)
546 .await?;
547
548 let mut all_outputs = round1.outputs;
550 all_outputs.extend(round2.outputs);
551
552 let summary = self.synthesize(task, &all_outputs, runner, infra).await;
553
554 Ok(SwarmResult {
555 task: task.to_string(),
556 outputs: all_outputs,
557 final_summary: summary,
558 })
559 }
560
561 async fn synthesize(
562 &self,
563 task: &str,
564 outputs: &[AgentOutput],
565 runner: &Arc<dyn AgentRunner>,
566 infra: &SharedInfra,
567 ) -> String {
568 let answers: Vec<&AgentOutput> = outputs.iter().filter(|o| o.succeeded()).collect();
569 if answers.is_empty() {
570 return "[no agent produced an answer]".to_string();
571 }
572 if answers.len() == 1 {
573 return answers[0].answer.clone();
574 }
575
576 if let Some(synth_spec) = &self.synthesizer {
577 let summaries: Vec<String> = answers
578 .iter()
579 .map(|o| format!("- {}: {}", o.name, truncate(&o.answer, 500)))
580 .collect();
581
582 let synth_task = format!(
583 "Original task: {}\n\nAgent outputs:\n{}\n\nSynthesize these into a single coherent answer.",
584 task,
585 summaries.join("\n")
586 );
587
588 if infra.begin_agent().is_ok() {
591 let mailbox = Mailbox::default();
592 let rt = infra.make_runtime();
593 if let Ok(output) = runner.run(synth_spec, &synth_task, &rt, &mailbox).await {
594 infra.record_output_metered(&output).await;
595 return output.answer;
596 }
597 }
598 }
599
600 answers
602 .iter()
603 .map(|o| format!("## {}\n{}", o.name, o.answer))
604 .collect::<Vec<_>>()
605 .join("\n\n")
606 }
607}
608
609fn truncate(s: &str, max_len: usize) -> &str {
610 if s.len() <= max_len {
611 return s;
612 }
613 let mut end = max_len;
614 while end > 0 && !s.is_char_boundary(end) {
615 end -= 1;
616 }
617 &s[..end]
618}
619
620#[cfg(test)]
621mod tests {
622 use super::*;
623 use crate::error::MultiError;
624 use crate::mailbox::Mailbox;
625 use crate::runner::AgentRunner;
626 use crate::types::{AgentOutput, AgentSpec};
627 use car_engine::Runtime;
628 use std::sync::atomic::{AtomicU32, Ordering};
629
630 struct MockRunner {
631 call_count: AtomicU32,
632 }
633
634 #[async_trait::async_trait]
635 impl AgentRunner for MockRunner {
636 async fn run(
637 &self,
638 spec: &AgentSpec,
639 task: &str,
640 _runtime: &Runtime,
641 _mailbox: &Mailbox,
642 ) -> Result<AgentOutput, MultiError> {
643 let _n = self.call_count.fetch_add(1, Ordering::SeqCst);
644 Ok(AgentOutput {
645 name: spec.name.clone(),
646 answer: format!(
647 "answer from {} for: {}",
648 spec.name,
649 &task[..task.len().min(50)]
650 ),
651 turns: 1,
652 tool_calls: 0,
653 duration_ms: 10.0,
654 error: None,
655 outcome: None,
656 tokens: None,
657 tools_used: Vec::new(),
658 })
659 }
660 }
661
662 #[tokio::test]
663 async fn test_parallel_swarm() {
664 let agents = vec![
665 AgentSpec::new("alice", "You are Alice"),
666 AgentSpec::new("bob", "You are Bob"),
667 ];
668 let runner: Arc<dyn AgentRunner> = Arc::new(MockRunner {
669 call_count: AtomicU32::new(0),
670 });
671 let infra = SharedInfra::new();
672
673 let result = Swarm::new(agents, SwarmMode::Parallel)
674 .run("test task", &runner, &infra)
675 .await
676 .unwrap();
677
678 assert_eq!(result.outputs.len(), 2);
679 assert!(result.outputs.iter().all(|o| o.succeeded()));
680
681 assert!(infra.state.get("agent.alice.answer").is_some());
683 assert!(infra.state.get("agent.bob.answer").is_some());
684 }
685
686 #[tokio::test]
689 async fn per_agent_cost_is_attributed() {
690 let agents = vec![
691 AgentSpec::new("researcher", ""),
692 AgentSpec::new("coordinator", ""),
693 ];
694 let runner: Arc<dyn AgentRunner> = Arc::new(TokenRunner {
695 per_call_total: 100,
696 });
697 let infra = SharedInfra::new();
698
699 Swarm::new(agents, SwarmMode::Sequential)
700 .run("task", &runner, &infra)
701 .await
702 .unwrap();
703
704 let log = infra.log.lock().await;
705 let report = log.cost_by_agent();
706 assert_eq!(report.len(), 2, "one cost row per agent: {report:?}");
707 assert_eq!(report[0].agent, "coordinator");
709 assert_eq!(report[0].calls, 1);
710 assert_eq!(report[0].tokens_in, 100);
711 assert_eq!(report[1].agent, "researcher");
712 assert_eq!(report[1].tokens_in, 100);
713 }
714
715 #[tokio::test]
716 async fn test_sequential_swarm() {
717 let agents = vec![
718 AgentSpec::new("first", "Go first"),
719 AgentSpec::new("second", "Go second"),
720 ];
721 let runner: Arc<dyn AgentRunner> = Arc::new(MockRunner {
722 call_count: AtomicU32::new(0),
723 });
724 let infra = SharedInfra::new();
725
726 let result = Swarm::new(agents, SwarmMode::Sequential)
727 .run("sequential task", &runner, &infra)
728 .await
729 .unwrap();
730
731 assert_eq!(result.outputs.len(), 2);
732 assert!(result.outputs[1].answer.contains("Prior agents"));
734 }
735
736 struct TokenRunner {
738 per_call_total: u64,
739 }
740
741 #[async_trait::async_trait]
742 impl AgentRunner for TokenRunner {
743 async fn run(
744 &self,
745 spec: &AgentSpec,
746 _task: &str,
747 _runtime: &Runtime,
748 _mailbox: &Mailbox,
749 ) -> Result<AgentOutput, MultiError> {
750 Ok(AgentOutput {
751 name: spec.name.clone(),
752 answer: format!("answer from {}", spec.name),
753 turns: 1,
754 tool_calls: 0,
755 duration_ms: 1.0,
756 error: None,
757 outcome: None,
758 tools_used: Vec::new(),
759 tokens: Some(crate::types::TokenAccounting::new(
760 self.per_call_total,
761 0,
762 0.0,
763 )),
764 })
765 }
766 }
767
768 #[tokio::test]
769 async fn sequential_budget_stops_chain_when_tokens_exhausted() {
770 let agents = vec![
774 AgentSpec::new("a", ""),
775 AgentSpec::new("b", ""),
776 AgentSpec::new("c", ""),
777 ];
778 let runner: Arc<dyn AgentRunner> = Arc::new(TokenRunner {
779 per_call_total: 100,
780 });
781 let infra = SharedInfra::new().with_budget(crate::BudgetLimits {
782 max_total_tokens: Some(150),
783 ..Default::default()
784 });
785
786 let result = Swarm::new(agents, SwarmMode::Sequential)
787 .run("task", &runner, &infra)
788 .await
789 .unwrap();
790
791 assert_eq!(result.outputs.len(), 3);
792 assert!(result.outputs[0].succeeded());
793 assert!(result.outputs[1].succeeded());
794 assert!(!result.outputs[2].succeeded());
795 assert!(crate::is_budget_skipped(&result.outputs[2]));
796 assert_eq!(infra.budget.snapshot().total_tokens, 200);
797 }
798
799 struct WorkspaceProbeRunner {
801 seen: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
802 }
803
804 #[async_trait::async_trait]
805 impl AgentRunner for WorkspaceProbeRunner {
806 async fn run(
807 &self,
808 spec: &AgentSpec,
809 _task: &str,
810 _runtime: &Runtime,
811 _mailbox: &Mailbox,
812 ) -> Result<AgentOutput, MultiError> {
813 let ws = spec
814 .metadata
815 .get(crate::workspace::WORKSPACE_METADATA_KEY)
816 .and_then(|v| v.as_str())
817 .unwrap_or("")
818 .to_string();
819 self.seen.lock().unwrap().push(ws.clone());
820 assert!(!ws.is_empty() && std::path::Path::new(&ws).is_dir());
822 Ok(AgentOutput {
823 name: spec.name.clone(),
824 answer: "ok".into(),
825 turns: 1,
826 tool_calls: 0,
827 duration_ms: 1.0,
828 error: None,
829 outcome: None,
830 tokens: None,
831 tools_used: Vec::new(),
832 })
833 }
834 }
835
836 #[tokio::test]
837 async fn parallel_workspaces_are_provisioned_and_distinct() {
838 let base = std::env::temp_dir().join(format!("car-swarm-ws-{}", std::process::id()));
839 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
840 let runner: Arc<dyn AgentRunner> = Arc::new(WorkspaceProbeRunner { seen: seen.clone() });
841 let infra = SharedInfra::new();
842
843 let agents = vec![AgentSpec::new("alice", ""), AgentSpec::new("bob", "")];
844 let result = Swarm::new(agents, SwarmMode::Parallel)
845 .with_workspaces(crate::workspace::WorkspaceConfig::directory(&base))
846 .run("task", &runner, &infra)
847 .await
848 .unwrap();
849
850 assert_eq!(result.outputs.len(), 2);
851 assert!(result.outputs.iter().all(|o| o.succeeded()));
852 let paths = seen.lock().unwrap().clone();
853 assert_eq!(paths.len(), 2);
854 assert_ne!(paths[0], paths[1], "each agent gets a distinct workspace");
855 for p in &paths {
857 assert!(
858 !std::path::Path::new(p).exists(),
859 "workspace removed on drop"
860 );
861 }
862 let _ = std::fs::remove_dir_all(&base);
863 }
864
865 #[tokio::test]
866 async fn parallel_budget_agent_cap_skips_excess() {
867 let agents: Vec<AgentSpec> = (0..5)
869 .map(|i| AgentSpec::new(&format!("a{}", i), ""))
870 .collect();
871 let runner: Arc<dyn AgentRunner> = Arc::new(MockRunner {
872 call_count: AtomicU32::new(0),
873 });
874 let infra = SharedInfra::new().with_budget(crate::BudgetLimits {
875 max_agents: Some(2),
876 ..Default::default()
877 });
878
879 let result = Swarm::new(agents, SwarmMode::Parallel)
880 .run("task", &runner, &infra)
881 .await
882 .unwrap();
883
884 assert_eq!(result.outputs.len(), 5);
885 let ran = result.outputs.iter().filter(|o| o.succeeded()).count();
886 let skipped = result
887 .outputs
888 .iter()
889 .filter(|o| crate::is_budget_skipped(o))
890 .count();
891 assert_eq!(ran, 2);
892 assert_eq!(skipped, 3);
893 }
894
895 #[tokio::test]
896 async fn test_debate_swarm() {
897 let agents = vec![
898 AgentSpec::new("debater_a", "Argue for"),
899 AgentSpec::new("debater_b", "Argue against"),
900 ];
901 let runner: Arc<dyn AgentRunner> = Arc::new(MockRunner {
902 call_count: AtomicU32::new(0),
903 });
904 let infra = SharedInfra::new();
905
906 let result = Swarm::new(agents, SwarmMode::Debate)
907 .run("debate topic", &runner, &infra)
908 .await
909 .unwrap();
910
911 assert_eq!(result.outputs.len(), 4);
913 }
914
915 struct CountingExecutor {
927 hits: Arc<AtomicU32>,
928 }
929
930 #[async_trait::async_trait]
931 impl car_engine::ToolExecutor for CountingExecutor {
932 async fn execute(
933 &self,
934 tool: &str,
935 params: &serde_json::Value,
936 ) -> Result<serde_json::Value, String> {
937 self.hits.fetch_add(1, Ordering::SeqCst);
938 Ok(serde_json::json!({
939 "tool": tool,
940 "echo": params.get("payload").cloned().unwrap_or(serde_json::Value::Null),
941 }))
942 }
943 }
944
945 struct ToolRunner {
948 hits: Arc<AtomicU32>,
949 }
950
951 #[async_trait::async_trait]
952 impl AgentRunner for ToolRunner {
953 async fn run(
954 &self,
955 spec: &AgentSpec,
956 _task: &str,
957 runtime: &Runtime,
958 _mailbox: &Mailbox,
959 ) -> Result<AgentOutput, MultiError> {
960 runtime
961 .set_executor(Arc::new(CountingExecutor {
962 hits: Arc::clone(&self.hits),
963 }))
964 .await;
965
966 let action = {
967 let mut a = car_ir::Action::new(car_ir::ActionType::ToolCall);
968 a.id = format!("act-{}", spec.name);
969 a.tool = Some("echo".into());
970 a.parameters = [(
971 "payload".to_string(),
972 serde_json::Value::from(format!("ping-{}", spec.name)),
973 )]
974 .into();
975 a.expected_effects = std::collections::HashMap::new();
976 a.max_retries = 0;
977 a.failure_behavior = car_ir::FailureBehavior::Abort;
978 a.metadata = std::collections::HashMap::new();
979 a
980 };
981 let proposal = car_ir::ActionProposal {
982 id: format!("p-{}", spec.name),
983 source: "test".into(),
984 actions: vec![action],
985 timestamp: chrono::Utc::now(),
986 context: std::collections::HashMap::new(),
987 };
988
989 let result = runtime.execute(&proposal).await;
990 assert!(
991 result.all_succeeded(),
992 "tool-call proposal must succeed via the installed executor"
993 );
994 let echoed = result.results[0]
995 .output
996 .as_ref()
997 .and_then(|v| v.get("echo"))
998 .and_then(|v| v.as_str())
999 .unwrap_or_default()
1000 .to_string();
1001
1002 Ok(AgentOutput {
1003 name: spec.name.clone(),
1004 answer: echoed,
1005 turns: 1,
1006 tool_calls: 1,
1007 duration_ms: 1.0,
1008 error: None,
1009 outcome: None,
1010 tokens: None,
1011 tools_used: vec!["echo".into()],
1012 })
1013 }
1014 }
1015
1016 #[tokio::test]
1017 async fn parallel_swarm_routes_through_tool_executor() {
1018 let agents = vec![
1022 AgentSpec::new("alice", "You are Alice").with_tools(vec!["echo".into()]),
1023 AgentSpec::new("bob", "You are Bob").with_tools(vec!["echo".into()]),
1024 ];
1025 let hits = Arc::new(AtomicU32::new(0));
1026 let runner: Arc<dyn AgentRunner> = Arc::new(ToolRunner {
1027 hits: Arc::clone(&hits),
1028 });
1029 let infra = SharedInfra::new();
1030
1031 let result = Swarm::new(agents, SwarmMode::Parallel)
1032 .run("tool task", &runner, &infra)
1033 .await
1034 .unwrap();
1035
1036 assert_eq!(hits.load(Ordering::SeqCst), 2);
1038 assert_eq!(result.outputs.len(), 2);
1039 assert!(result.outputs.iter().all(|o| o.succeeded()));
1040 assert!(result.outputs.iter().all(|o| o.tool_calls == 1));
1041
1042 let mut answers: Vec<&str> = result.outputs.iter().map(|o| o.answer.as_str()).collect();
1044 answers.sort();
1045 assert_eq!(answers, vec!["ping-alice", "ping-bob"]);
1046 }
1047
1048 struct ContendedWriter {
1056 barrier: Arc<tokio::sync::Barrier>,
1057 key: String,
1058 }
1059
1060 #[async_trait::async_trait]
1061 impl AgentRunner for ContendedWriter {
1062 async fn run(
1063 &self,
1064 spec: &AgentSpec,
1065 _task: &str,
1066 runtime: &Runtime,
1067 _mailbox: &Mailbox,
1068 ) -> Result<AgentOutput, MultiError> {
1069 self.barrier.wait().await;
1072
1073 let action = {
1074 let mut a = car_ir::Action::new(car_ir::ActionType::StateWrite);
1075 a.id = format!("w-{}", spec.name);
1076 a.parameters = [
1077 ("key".to_string(), serde_json::Value::from(self.key.clone())),
1078 (
1079 "value".to_string(),
1080 serde_json::Value::from(spec.name.clone()),
1081 ),
1082 ]
1083 .into();
1084 a.expected_effects = std::collections::HashMap::new();
1085 a.read_set = vec![self.key.clone()];
1086 a.write_set = vec![self.key.clone()];
1087 a.max_retries = 0;
1088 a.failure_behavior = car_ir::FailureBehavior::Abort;
1089 a.metadata = std::collections::HashMap::new();
1090 a
1091 };
1092 let proposal = car_ir::ActionProposal {
1093 id: format!("p-{}", spec.name),
1094 source: "test".into(),
1095 actions: vec![action],
1096 timestamp: chrono::Utc::now(),
1097 context: std::collections::HashMap::new(),
1098 };
1099 let result = runtime.execute(&proposal).await;
1100 assert!(result.all_succeeded(), "state write must succeed");
1101
1102 Ok(AgentOutput {
1103 name: spec.name.clone(),
1104 answer: format!("wrote {}", self.key),
1105 turns: 1,
1106 tool_calls: 0,
1107 duration_ms: 1.0,
1108 error: None,
1109 outcome: None,
1110 tokens: None,
1111 tools_used: Vec::new(),
1112 })
1113 }
1114 }
1115
1116 #[tokio::test]
1121 async fn isolated_parallel_reorder_is_auto_remediated() {
1122 let barrier = Arc::new(tokio::sync::Barrier::new(2));
1123 let runner: Arc<dyn AgentRunner> = Arc::new(ContendedWriter {
1124 barrier,
1125 key: "fresh".into(),
1126 });
1127 let infra = SharedInfra::new().with_concurrency_gating();
1128 let agents = vec![AgentSpec::new("alice", ""), AgentSpec::new("bob", "")];
1129
1130 let result = Swarm::new(agents, SwarmMode::Parallel)
1131 .with_isolation()
1132 .run("task", &runner, &infra)
1133 .await
1134 .unwrap();
1135
1136 assert_eq!(result.outputs.len(), 2);
1138 assert!(
1139 result.outputs.iter().all(|o| o.succeeded()),
1140 "reorder is auto-remediated, so both agents commit: {:?}",
1141 result.outputs
1142 );
1143 assert_eq!(infra.state.get("fresh"), Some(serde_json::json!("bob")));
1145
1146 let log = infra.log.lock().await;
1148 let ev = log
1149 .events()
1150 .iter()
1151 .find(|e| e.data.get("gate").and_then(|v| v.as_str()) == Some("concurrency"))
1152 .expect("a concurrency gate event was emitted");
1153 assert_eq!(ev.kind, car_eventlog::EventKind::AdmissionGateDecision);
1154 }
1155
1156 #[tokio::test]
1161 async fn isolated_parallel_stale_generation_rejects_one_commit() {
1162 let barrier = Arc::new(tokio::sync::Barrier::new(2));
1163 let runner: Arc<dyn AgentRunner> = Arc::new(ContendedWriter {
1164 barrier,
1165 key: "counter".into(),
1166 });
1167 let infra = SharedInfra::new().with_concurrency_gating();
1168 infra
1170 .state
1171 .set("counter", serde_json::json!("seed"), "test");
1172 let agents = vec![AgentSpec::new("alice", ""), AgentSpec::new("bob", "")];
1173
1174 let result = Swarm::new(agents, SwarmMode::Parallel)
1175 .with_isolation()
1176 .run("task", &runner, &infra)
1177 .await
1178 .unwrap();
1179
1180 assert_eq!(result.outputs.len(), 2);
1181 let succeeded = result.outputs.iter().filter(|o| o.succeeded()).count();
1182 let rejected = result
1183 .outputs
1184 .iter()
1185 .filter(|o| {
1186 o.error
1187 .as_deref()
1188 .map(|e| e.contains("concurrency gate"))
1189 .unwrap_or(false)
1190 })
1191 .count();
1192 assert_eq!(succeeded, 1, "exactly one commit survives a lost update");
1193 assert_eq!(rejected, 1, "the stale writer is rejected");
1194
1195 let final_val = infra.state.get("counter").unwrap();
1197 assert!(
1198 final_val == serde_json::json!("alice") || final_val == serde_json::json!("bob"),
1199 "the committed value is the surviving agent's write, got {final_val:?}"
1200 );
1201
1202 let log = infra.log.lock().await;
1204 let ev = log
1205 .events()
1206 .iter()
1207 .find(|e| e.data.get("gate").and_then(|v| v.as_str()) == Some("concurrency"))
1208 .expect("a concurrency gate event was emitted");
1209 assert_eq!(
1210 ev.data.get("decision").and_then(|v| v.as_str()),
1211 Some("needs_approval")
1212 );
1213 }
1214
1215 #[tokio::test]
1219 async fn gating_is_opt_in() {
1220 let barrier = Arc::new(tokio::sync::Barrier::new(2));
1221 let runner: Arc<dyn AgentRunner> = Arc::new(ContendedWriter {
1222 barrier,
1223 key: "counter".into(),
1224 });
1225 let infra = SharedInfra::new(); infra
1227 .state
1228 .set("counter", serde_json::json!("seed"), "test");
1229 let agents = vec![AgentSpec::new("alice", ""), AgentSpec::new("bob", "")];
1230
1231 let result = Swarm::new(agents, SwarmMode::Parallel)
1232 .with_isolation()
1233 .run("task", &runner, &infra)
1234 .await
1235 .unwrap();
1236
1237 assert!(
1238 result.outputs.iter().all(|o| o.succeeded()),
1239 "no gate → both commit"
1240 );
1241 let log = infra.log.lock().await;
1242 assert!(
1243 !log.events()
1244 .iter()
1245 .any(|e| e.data.get("gate").and_then(|v| v.as_str()) == Some("concurrency")),
1246 "no concurrency gate event when gating is off"
1247 );
1248 }
1249}