Skip to content

Advanced Orchestration

Beyond the basic Workflow Agents (Sequential, Parallel, Loop), Heartbit provides five advanced orchestration patterns for complex multi-agent coordination. Like the basic workflow agents, these are deterministic — no LLM cost for the orchestration layer itself.

Multi-round adversarial debate between N debater agents, judged by a separate agent.

use heartbit::{AgentRunner, DebateAgent};
let optimist = AgentRunner::builder(provider.clone())
.name("optimist").system_prompt("Argue the optimistic case.").build()?;
let pessimist = AgentRunner::builder(provider.clone())
.name("pessimist").system_prompt("Argue the pessimistic case.").build()?;
let judge = AgentRunner::builder(provider.clone())
.name("judge").system_prompt("Synthesize a balanced verdict.").build()?;
let debate = DebateAgent::builder()
.debater(optimist)
.debater(pessimist)
.judge(judge)
.max_rounds(3)
.should_stop(|text| text.contains("CONSENSUS"))
.build()?;
let output = debate.execute("Should we rewrite in Rust?").await?;

How it works:

  1. Each round, all debaters run in parallel via tokio::JoinSet, receiving the full transcript so far
  2. After all rounds complete (or should_stop triggers early exit), the judge synthesizes a final answer
  3. Returns AgentOutput with accumulated TokenUsage from all debaters and the judge

Requirements: at least 2 debaters, a judge, max_rounds >= 1.

Majority voting across N voter agents running in parallel.

use heartbit::{AgentRunner, VotingAgent};
let voter_a = AgentRunner::builder(provider.clone())
.name("voter_a").system_prompt("Classify sentiment. Reply POSITIVE or NEGATIVE.").build()?;
let voter_b = AgentRunner::builder(provider.clone())
.name("voter_b").system_prompt("Classify sentiment. Reply POSITIVE or NEGATIVE.").build()?;
let voter_c = AgentRunner::builder(provider.clone())
.name("voter_c").system_prompt("Classify sentiment. Reply POSITIVE or NEGATIVE.").build()?;
let voting = VotingAgent::builder()
.voter(voter_a)
.voter(voter_b)
.voter(voter_c)
.vote_extractor(|output| {
if output.result.contains("POSITIVE") { "POSITIVE".into() }
else { "NEGATIVE".into() }
})
.tie_breaker(|votes| votes.into_iter().next().unwrap())
.build()?;
let result = voting.execute("I love this product!").await?;
// result.winner == "POSITIVE"
// result.tally == {"POSITIVE": 3}

How it works:

  1. All voters run in parallel
  2. vote_extractor maps each voter’s output to a vote string
  3. Votes are tallied; the majority wins
  4. On tie, tie_breaker decides (default: first alphabetically)

Returns: VoteResult { winner, tally: HashMap<String, usize>, output: AgentOutput } where output contains the winning voter’s full response.

Requirements: at least 2 voters, a vote_extractor function.

N proposers generate ideas in parallel, then a synthesizer refines them into a final answer. Can be stacked into multiple layers.

use heartbit::{AgentRunner, MixtureOfAgentsAgent};
let proposer_a = AgentRunner::builder(provider.clone())
.name("proposer_a").system_prompt("Draft a creative approach.").build()?;
let proposer_b = AgentRunner::builder(provider.clone())
.name("proposer_b").system_prompt("Draft a technical approach.").build()?;
let synthesizer = AgentRunner::builder(provider.clone())
.name("synthesizer").system_prompt("Combine proposals into a cohesive plan.").build()?;
let moa = MixtureOfAgentsAgent::builder()
.proposer(proposer_a)
.proposer(proposer_b)
.synthesizer(synthesizer)
.layers(2)
.build()?;
let output = moa.execute("Design a notification system").await?;

How it works:

  1. All proposers run in parallel, each producing a proposal
  2. Proposals are sorted alphabetically by proposer name for deterministic output
  3. The synthesizer receives all proposals and produces a refined result
  4. With layers > 1, each layer’s synthesis feeds the next layer’s proposers

Requirements: at least 2 proposers, a synthesizer, layers >= 1 (default: 1).

Directed acyclic graph of agents with parallel dispatch at each tier. Supports conditional edges and text transforms.

use heartbit::{AgentRunner, DagAgent};
let fetcher = AgentRunner::builder(provider.clone())
.name("fetcher").system_prompt("Fetch and summarize data.").build()?;
let analyzer = AgentRunner::builder(provider.clone())
.name("analyzer").system_prompt("Analyze the data.").build()?;
let formatter = AgentRunner::builder(provider.clone())
.name("formatter").system_prompt("Format as a report.").build()?;
let alerter = AgentRunner::builder(provider.clone())
.name("alerter").system_prompt("Generate an alert summary.").build()?;
let dag = DagAgent::builder()
.node("fetcher", fetcher)
.node("analyzer", analyzer)
.node("formatter", formatter)
.node("alerter", alerter)
.edge("fetcher", "analyzer")
.edge("analyzer", "formatter")
.conditional_edge("analyzer", "alerter", |output| output.contains("CRITICAL"))
.edge_with_transform("fetcher", "formatter", |text| text.to_uppercase())
.build()?;
let output = dag.execute("Process today's metrics").await?;

How it works:

  1. Root nodes (in-degree 0) receive the task input
  2. BFS-style execution: each tier of independent nodes runs in parallel
  3. conditional_edge gates whether an edge is followed based on the source output
  4. edge_with_transform modifies text before passing to the target node
  5. Terminal nodes’ outputs are merged (sorted by name)

Validation: The builder validates that there are no cycles (using petgraph), all edge endpoints exist, and there are no duplicate node names.

Requirements: at least one node, valid DAG (no cycles).

Runs the same agent on many different inputs with controlled concurrency.

use heartbit::{AgentRunner, BatchExecutor};
let classifier = AgentRunner::builder(provider.clone())
.name("classifier").system_prompt("Classify the input.").build()?;
let batch = BatchExecutor::builder(classifier)
.max_concurrency(4)
.build()?;
let inputs = vec![
"Email about a meeting".into(),
"Spam advertisement".into(),
"Bug report from user".into(),
];
let results = batch.execute(inputs).await;
// results: Vec<BatchResult> sorted by input index
let total_usage = batch.aggregate_usage(&results);

How it works:

  1. Inputs are dispatched to the agent with concurrency limited by tokio::sync::Semaphore
  2. Returns Vec<BatchResult> sorted by input index, each containing Ok(AgentOutput) or Err
  3. aggregate_usage() sums token usage across successful results

Requirements: max_concurrency defaults to available_parallelism (0 is rejected at build time).

PatternUse case
DebateAdversarial reasoning, code review, decision analysis
VotingConsensus building, multiple-choice classification
MoAContent generation, research synthesis
DAGMulti-stage pipelines with branching, data processing
BatchBulk processing, parallel evaluations

All advanced orchestration patterns return AgentOutput (or VoteResult containing one), so they can be nested inside Sequential, Parallel, or Loop agents. For example, a SequentialAgent could run a DagAgent as its first stage and a VotingAgent as its second stage.