The Parallel Fan-Out / Fan-In Pattern, Implemented via the Reactive Reducer
A little background
Last article we did the sequential pipeline - the boring one, where the interesting question is what the implementation does to the handoffs. This one is its mirror image: the parallel fan-out / fan-in pattern, where the interesting question is where the fan-out and the fan-in actually live.
In most multi-agent frameworks, they live in code: a dispatcher that spawns N workers, a barrier or counter that waits for N-1 of them, an aggregator that stitches the results together. Three mechanisms, each with its own failure modes. In the Aegis FSM, there’s no fan-out primitive and no join primitive at all. Both emerge from two things you already have: parking and guards. That’s the whole article, really - let’s unpack it.
The pattern
Parallel Fan-Out / Fan-In. A coordinator decomposes a larger task into independent or mostly independent subtasks and dispatches them to several agents at the same time. Once enough workers finish, their outputs are collected by an aggregator, reducer, or synthesizer that combines them into a single result. The pattern trades coordination complexity for lower latency and broader coverage.
Good for:
- Research from multiple sources
- Independent analysis
- Generating alternatives
- Broad search or comparison tasks
- Large decomposable workloads
Common failure points:
- Bad decomposition creates overlapping or missing work
- Outputs can be inconsistent or hard to combine
- Aggregator becomes the bottleneck
- One slow worker can hold up the join
The machine: one state is the whole fan-out
Here’s the pattern as an FSM. A coordinator decomposes a question, three researchers run at the same time, and a synthesizer merges their findings. It’s the same shape as our real panel-review FSM, translated from reviews to research.
fsm:
name: research-fanout
version: 1
initialState: decompose
context:
decomposition: { type: string }
findings: { type: map, of: string } # one field; entries keyed by the researcher
synthesis: { type: string }
agents:
coordinator: { canTransition: [decompose, merge] }
web_researcher: { canTransition: [fanout] }
docs_researcher: { canTransition: [fanout] }
api_researcher: { canTransition: [fanout] }
states:
decompose:
description: >-
Decompose the question into three independent sub-queries (web, docs, API),
then stage: updateContext(field=decomposition, value=<sub-queries>).
requires:
- { name: decomposition, from: agent }
transitions:
- { toState: fanout }
fanout:
description: >-
Three researchers run concurrently on: "{{ context.decomposition }}".
Each stages its own findings: updateContext(field=findings, value=<what you found>).
requires:
# Optional on purpose - see "the load-bearing flag" below. The GUARD is the join.
- { name: findings, from: { agent: web_researcher }, required: false, prompt: "Stage your findings." }
- { name: findings, from: { agent: docs_researcher }, required: false, prompt: "Stage your findings." }
- { name: findings, from: { agent: api_researcher }, required: false, prompt: "Stage your findings." }
transitions:
- toState: merge
guards:
- { backend: cel, expression: "size(context.findings) >= 3" }
merge:
description: >-
Synthesize the findings into one answer. They are all in {{ context.findings }},
keyed by which researcher produced each - read them, reconcile conflicts, then stage:
updateContext(field=synthesis, value=<answer>).
requires:
- { name: synthesis, from: agent }
transitions:
- { toState: done }
done:
description: "Question answered from three independent sources."
terminal: true
policy:
maxExchangesPerStage: 30
maxExchangesPerAgentPerStage: 15
checkpointEvery: 5
Parallel Fan-Out / Fan-In work flow:
Two things to hold onto before we walk it:
- The fan-out is the
fanoutstate’srequireslist. Three requirements, three different delegate sources. There is no dispatch call, nospawnWorkers(3)- the declaration is the fan-out. - The fan-in is the transition guard. One edge out of
fanout, and its guard issize(context.findings) >= 3. No counter field, no join state, no N-of-M primitive. The guard is the join - and because the workers write into onemapkeyed by whoever wrote it, the guard counts rather than enumerating names. Three workers or thirty, the expression is the same shape and the only thing that changes is the number.
Walking the fan-out: parking is the dispatch
The machine rests in decompose. The coordinator does the decomposition, writes decomposition, and the edge fires into fanout. Now the reducer checks fanout’s edges: the single edge’s guard asks whether all three findings are present. They’re not - so the machine parks.
But parking here is not “wait for one thing.” fanout has three interactive requirements from three different agents, so the runtime raises all three elicitations at once: three A2A summons, fired concurrently, each with its own objective templated on the decomposition. The machine is parked, journaled, and not spinning - the three researchers are now working in parallel, and the machine is simply at the join.
Each worker’s write re-runs the reducer. The first two writes leave the guard false, so the machine parks again - the join hasn’t happened yet, and that’s not an error, it’s the steady state. The third write makes the guard true, the edge fires, and the machine moves to merge with all three findings in context. The order the workers finish in is irrelevant. The machine doesn’t care which one finished last; it only cares whether the guard passes.
The load-bearing flag: required: false
There’s one detail in that YAML that looks decorative and isn’t. Every fan-out requirement is marked required: false. Here’s why.
By default, a state’s requires are an all-required AND-gate on edge eligibility: the machine won’t even consider firing an edge until every required field is set. And on a map field that gate is sharper than it sounds: entries are keyed by whoever actually wrote them, and a requirement naming from: { agent: web_researcher } is satisfied only when that key is present. So three required requirements on one map field are, by themselves, an exact all-of join - which is a good default and the right one for a fixed panel.
But the moment your join is anything else - two-of-three, “all three or a fast-path override,” “any two of four” - that AND-gate would block the edge before your guard ever got to express the real rule. So when the join isn’t all-of, the authoring discipline is: mark the fan-out requirements required: false (they’re still elicited on entry - they just don’t block eligibility) and let the transition guard be the sole join. This article writes it that way even though its join happens to be all-of, because the guard is the general mechanism and the one you’ll reach for the first time the rule changes. That’s exactly how the real panel-review FSM does it, where the join is:
(security && perf && human) || legal_override
Three agent reviewers plus a human sign-off, all fanned out concurrently, and either all-clear or a legal fast-path alone can advance the machine. That’s “once enough workers finish” in one line of CEL, and it would be genuinely awkward to express as a barrier in code.
What the reducer does to the failure points
Bad decomposition creates overlapping or missing work. The decomposition is a staged, inspectable context field produced by a named state, not a runtime decision buried in a dispatcher. You can validate it, gate the fan-out on a review of it, or route a bad decomposition back to decompose with a back-edge. Overlap and gaps are properties of a document you can read, not of emergent behavior.
Outputs can be inconsistent or hard to combine. Every worker writes into the same typed field, so the outputs are coerced to one shape at the write rather than described alike in three prompts and hoped over. The synthesizer’s state gets the whole map, side by side in the CONTEXT block, keyed by which researcher produced each - so “the docs and the API disagree” is a comparison of two entries, not an act of reassembly. Inconsistency isn’t hidden in a merge step; it’s sitting in the store where the synthesizer’s job is explicitly to reconcile it. Big findings get declared blob and claim-checked, so the join carries references, not a context dump.
Aggregator becomes the bottleneck. True, and now it’s one state you can see. merge is where the machine rests while synthesis runs; the parked record shows you exactly where the wall-clock is going. If synthesis itself is parallelizable (multiple synthesis perspectives, then a final pick), that’s just… another fan-out state. The pattern composes with itself.
One slow worker can hold up the join. This is the one the reducer handles with a shrug. The machine doesn’t time out, poll, or busy-wait - it parks, and parking is durable (journaled to disk, restart-safe). A slow worker just means the machine stays at the join longer. And if you don’t want to wait for all of them, you don’t: the join is a guard, so you write the policy you actually want - size(context.findings) >= 2 for two-of-three, an || fast-path for an override, size(findings.filter(k, ...)) >= n for a conditional quorum. The “how many must finish” question stops being a framework feature and becomes a predicate, and with the results in one keyed map it’s usually arithmetic rather than a list of names.
The dynamic variant: fork when the count isn’t known
The static fan-out above works when the subtasks are known at authoring time - three researchers, three reviewers. When the work is large and decomposable and the count comes from the data (a shard of 400 records, a list of 12 failing tests), the real poi-enrich-parallel FSM shows the other shape: the coordinator doesn’t fan out to agents, it fans out to forks.
The lead agent enumerates the work in plan (staging a short list of stopIds, not prose - context stays small), then dispatch calls fork() once with one lightweight worker task per item. The workers run to a concurrency cap and their results return automatically; no collection code. gather reconciles and validates every output, and if any failed, it routes back to dispatch to re-fork only the failures - bounded by policy.maxIterations: 4 so the retry loop can’t spin. The join here is the allValid flag the gather agent sets after validating the whole shard, and the re-fork edge consumes the trigger with an assign so the loop re-parks cleanly.
Same pattern, different substrate: the fan-out is a single fork() call, the fan-in is a reconciliation state, and the slow/failing worker is a bounded re-dispatch instead of a barrier timeout.
The whole thing, one idea
| Moment | Who | What happened in context |
|---|---|---|
| decompose | coordinator | decomposition set |
| fanout | (park) | nothing - three summons raised concurrently |
| fanout | each researcher | three entries in findings, keyed by author, in any order |
| fanout → merge | guard | size(context.findings) >= 3 fired |
| merge | coordinator | synthesis set |
| done | - | terminal |
No dispatcher. No barrier. No counter field. The fan-out was a state that parks and gets elicited N ways at once. The fan-in was a pure guard that counted a map the workers had written themselves into. The “enough workers” policy was a predicate, not a feature. And when the worker count comes from the data instead of the design, the same shape runs on forks with a bounded re-dispatch loop.
Up next: 3. Supervisor / Router
Series:
- Intro
- 1. Sequential Pipeline
- 2. Parallel Fan-Out / Fan-In
- 3. Supervisor / Router
- 4. Hierarchical Manager / Worker
- 5. Peer Handoff / Swarm
- 6. Debate / Consensus
- 7. Blackboard / Shared-State
- 8. Planner / Executor
- 9. Generator / Critic / Refiner
- 10. Auction / Contract-Net
- 11. Event-Driven / Publish-Subscribe
- 12. Quorum / Voting
- 13. Dynamic Team Formation