Skip to main content

car_multi/patterns/
swarm.rs

1//! Swarm — N agents working on the same problem.
2//!
3//! Modes:
4//! - **Parallel**: all agents run concurrently, then a synthesizer combines results.
5//! - **Sequential**: agents run one after another, each seeing prior agents' outputs.
6//! - **Debate**: two rounds — initial answers, then critique, then a judge picks the best.
7
8use 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    /// When true, each agent gets an isolated state overlay.
38    /// Writes go to a per-agent local store; reads fall through to the shared parent.
39    /// On success, local state is merged back to the parent.
40    pub isolated: bool,
41    /// When set (parallel mode), each agent gets an isolated filesystem
42    /// workspace, advertised to the runner via `AgentSpec.metadata["workspace"]`.
43    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    /// Enable per-agent state isolation for this swarm.
63    pub fn with_isolation(mut self) -> Self {
64        self.isolated = true;
65        self
66    }
67
68    /// Provision an isolated filesystem workspace per agent (parallel mode). Each
69    /// agent's [`AgentSpec`] gets a `workspace` metadata entry with its directory;
70    /// the runner is expected to run its file tools there. Workspaces are removed
71    /// when the run completes. Prevents parallel file-mutating agents from
72    /// clobbering one another.
73    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        // Concurrency-anomaly gating (A5) applies only to the isolated path,
103        // where writes are deferred to a merge barrier we can inspect. In the
104        // non-isolated path writes land in shared state during the run — there
105        // is no barrier to gate. When enabled, snapshot the parent keys that
106        // exist *before* the batch: an agent that writes a key which already
107        // existed is doing a read-modify-write, which is what makes concurrent
108        // overwrites a lost-update (stale-generation) hazard rather than a fresh
109        // insert.
110        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        // Each agent slot is either spawned (index into `handles`) or pre-empted
122        // by the coordination budget. Keeping per-agent slots preserves output
123        // order even when some agents are skipped.
124        enum Slot {
125            Spawned(usize),
126            Skipped(AgentOutput),
127        }
128
129        // When isolated, each handle returns (Result, Option<AgentContext>) so we
130        // can merge state back on success.  When not isolated, the context is None.
131        // The trailing (read_at, commit_at) are the logical timestamps bounding
132        // the agent's generate window, stamped on the shared concurrency clock
133        // when gating is enabled (0/0 otherwise) — the schedule the gate reasons
134        // over.
135        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            // Provision an isolated filesystem workspace if configured, and
147            // advertise its path to the runner via the spec's metadata. Done
148            // BEFORE the budget reservation so a provisioning failure doesn't
149            // burn a (non-refundable) agent slot. On failure, fail this agent
150            // closed rather than running it unisolated and risk clobbering a
151            // sibling.
152            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            // Budget pre-flight: a crossed token/cost ceiling or the agent cap
174            // stops further spawns. The whole parallel batch is launched at once,
175            // so this gates the batch rather than metering mid-batch. On denial
176            // the just-provisioned `workspace` guard drops here and cleans up.
177            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                    // Hold the workspace guard for the agent's lifetime; dropped
201                    // (cleaned up) when the task finishes.
202                    let _workspace = workspace;
203                    // Stamp the generate window on the shared logical clock:
204                    // read_at before the agent runs, commit_at when it returns.
205                    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        // Move owned join results out by handle index as each slot is visited.
230        let mut results: Vec<Option<_>> = futures::future::join_all(handles)
231            .await
232            .into_iter()
233            .map(Some)
234            .collect();
235
236        // --- Phase 1: resolve every slot without committing. A successful
237        // isolated agent's writes stay pending in its `ctx` so the concurrency
238        // gate below can veto the merge; meanwhile build the `AgentOp` schedule
239        // the gate reasons over. Terminal outputs (skips, errors) pass through. ---
240        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                    // Instrument the agent as an AgentOp when gating is on. Its
260                    // write_set is the overlay it would merge; a write to a key
261                    // that already existed in the parent is a read-modify-write,
262                    // which is what turns a concurrent overwrite into a
263                    // lost-update (stale-generation) hazard rather than a fresh
264                    // insert.
265                    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                    // Note: an agent that spent tokens before returning Err has
290                    // that spend dropped — the error path carries no token
291                    // payload, so the budget can under-count failed work.
292                    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        // --- Phase 2: gate the schedule, then commit the survivors. The gate
334        // emits its audit event here. A causal-cascade aborts the whole batch
335        // (nothing merges); a stale generation rejects only the offending
336        // commit; a write reorder is auto-remediated by committing in a
337        // deterministic order (below). ---
338        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                // Meter the batch's REAL token spend before aborting (linus
345                // review): the agents ran and billed regardless of the merge
346                // outcome. Skipping this let a retrying budget-capped loop
347                // exceed its cap without bound.
348                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        // Outputs preserve agent-spec order, but the isolated-state *merges* are
358        // applied in a deterministic order (agent name ascending) so a reorder
359        // hazard resolves the same way every run instead of by nondeterministic
360        // completion order — the `SerializeWriters` remediation. Answer keys
361        // (`agent.<name>.answer`) are unique per agent and don't contend, so
362        // they're written inline.
363        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                        // Defer the isolated-state merge to the ordered pass below.
375                        if let Some(ctx) = ctx {
376                            to_merge.push(ctx);
377                        }
378                        // Record reported spend against the coordination budget.
379                        infra.record_output_metered(&output).await;
380                        // Write to shared state
381                        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                        // Concurrency gate rejected this commit (stale
389                        // generation): drop its writes, surface the reason.
390                        // Sibling commits still stand. The agent RAN and
391                        // billed, so its spend is metered and its real
392                        // turns/tokens are preserved on the output (linus
393                        // review) — only the answer/writes are withheld.
394                        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        // Apply the deferred merges. When gating is on, order them by agent name
415        // so contended keys land last-writer-wins by a stable rule; without
416        // gating, completion order is preserved (unchanged behavior).
417        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            // Budget gate before each agent. In a sequential chain this is real
444            // between-agent enforcement: once a prior agent's reported spend
445            // crosses a limit, the remaining agents are skipped.
446            if let Err(e) = infra.begin_agent() {
447                outputs.push(crate::budget::budget_skipped_output(&spec.name, &e));
448                continue;
449            }
450
451            // Enrich task with prior results
452            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        // Round 1: independent answers
516        let round1 = Swarm::new(self.agents.clone(), SwarmMode::Parallel)
517            .run(task, runner, infra)
518            .await?;
519
520        // Round 2: each agent critiques the others
521        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        // Combine both rounds
549        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            // Gate the synthesizer on the budget too; on denial fall through to
589            // the default concatenation rather than failing the whole run.
590            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        // Default: concatenate with headers
601        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        // Check shared state was written
682        assert!(infra.state.get("agent.alice.answer").is_some());
683        assert!(infra.state.get("agent.bob.answer").is_some());
684    }
685
686    /// G3: a multi-agent run attributes token/cost per agent via the metered
687    /// events emitted at each successful output.
688    #[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        // BTreeMap order: coordinator before researcher.
708        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        // Second agent should see first agent's output in enriched task
733        assert!(result.outputs[1].answer.contains("Prior agents"));
734    }
735
736    /// Reports a fixed token spend per call so a budget can meter it.
737    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        // Three agents, each reporting 100 tokens; a 150-token ceiling lets the
771        // first two run (cumulative 200 crosses 150 only after the second) and
772        // denies the third.
773        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    /// Records the `workspace` metadata each agent was handed.
800    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            // The directory must exist while the agent runs.
821            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        // Cleaned up after the run.
856        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        // Five agents, cap of 2: exactly two run, three are skipped.
868        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        // 2 agents x 2 rounds = 4 outputs
912        assert_eq!(result.outputs.len(), 4);
913    }
914
915    // --- Real tool-callback seam (ToolExecutor) ---
916    //
917    // Every other test here uses a MockRunner that ignores the Runtime it is
918    // handed and reports `tool_calls: 0`. This exercises the path that actually
919    // runs: the per-agent `Runtime` from `infra.make_runtime()` carries NO
920    // executor, so the runner installs one via `set_executor`, builds a
921    // `ToolCall` proposal, and drives it through `Runtime::execute`. The
922    // executor's hit counter proves the engine routed the action through the
923    // caller-provided callback rather than a stub.
924
925    /// A `ToolExecutor` that counts invocations and echoes back its params.
926    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    /// A runner that installs a `CountingExecutor` on the runtime it is handed,
946    /// runs a one-action `ToolCall` proposal, and surfaces the echoed payload.
947    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        // The `echo` tool must be registered for validation to admit the
1019        // ToolCall; the swarm pre-registers `spec.tools` on the per-agent
1020        // runtime, and the runner then installs the executor that handles it.
1021        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        // One tool dispatch per agent, all through the caller's executor.
1037        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        // Each output carries the payload echoed back by the executor.
1043        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    // --- A5: concurrency gate wired into the isolated parallel commit barrier ---
1049    //
1050    // A runner that writes one shared key via a `StateWrite` proposal on the
1051    // per-agent isolated runtime, synchronizing on a barrier so both agents'
1052    // generate windows provably overlap (otherwise fast mock agents could run
1053    // strictly sequentially and no anomaly would exist to detect).
1054
1055    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            // Both agents read (the swarm stamped read_at just before this)
1070            // before either commits (stamped just after) → overlapping windows.
1071            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    /// Two isolated agents both write a **fresh** shared key with overlapping
1117    /// windows: no read-modify-write, so it's a write reorder (L3) — the gate
1118    /// auto-remediates by committing in a deterministic order; both agents
1119    /// succeed and the winner is stable.
1120    #[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        // Reorder auto-remediates: both commit, nothing rejected.
1137        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        // Deterministic serialize order (name ascending) → bob merges last.
1144        assert_eq!(infra.state.get("fresh"), Some(serde_json::json!("bob")));
1145
1146        // The gate audited its decision.
1147        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    /// Two isolated agents both overwrite a **pre-existing** shared key with
1157    /// overlapping windows: a read-modify-write on both sides → stale generation
1158    /// (L1). The gate rejects the offending (later-committing) op; exactly one
1159    /// agent's write survives and the other is surfaced as errored.
1160    #[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        // Seed the key so both agents' writes are read-modify-writes.
1169        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        // The surviving write is one of the two agents (not the stale seed).
1196        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        // The gate escalated to needs_approval (fail-closed rejection).
1203        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    /// Gating is opt-in: without `with_concurrency_gating`, the isolated swarm
1216    /// behaves exactly as before — both contended writes merge, last-writer-wins
1217    /// by completion order, nothing rejected, no gate event.
1218    #[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(); // no gating
1226        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}