The Dynamic Team Formation Pattern, Implemented via the Reactive Reducer
A little background
Twelve patterns in, and this is the last one - and the one that makes the whole series click. Every article so far has had a fixed team: the agents were declared in the FSM, and the states routed to them. The dynamic team formation pattern breaks that. The team isn’t wired in - it’s assembled at run time, from a registry, for the task at hand. Specialists are recruited for specific phases, replaced when they underperform, and released when their expertise is no longer required.
In most frameworks, dynamic team formation is a prompt: a builder agent reads the task, decides which specialists it needs, and tells the system to summon them. The team composition is a decision the model made, and you find out what it was by reading the trace. The reactive reducer makes the team data. The registry is the declared agent pool in the YAML. The roster is a context field the builder stages. And the guards react to the roster - the machine routes the mission to exactly the specialists the builder recruited, because the guards say so. The team composition is inspectable before the run, and the routing is exact.
The pattern
Dynamic Team Formation. Instead of assigning work to a fixed group of agents, the system assembles a temporary team based on the needs of the current task. Agents can be discovered from a registry, selected by capability, recruited for specific phases, replaced when they underperform, and released when their expertise is no longer required. Team composition can therefore evolve alongside the problem itself.
Good for:
- Large agent registries
- Heterogeneous capabilities
- Organizations with many specialists
- Tasks whose requirements emerge dynamically
- Mission-oriented agent systems
- Workloads that need different skills at different stages
Common failure points:
- Poor team selection
- Excessive discovery and negotiation overhead
- Redundant specialists
- Loss of context when team composition changes
- Team churn can undermine ownership and continuity
The machine: the roster is a context write, the guards react to it
A mission: research the market for a feature, prototype it, and test it. The registry holds five specialists - a researcher, a coder, a QA agent, a devops agent, and a writer - and it isn’t an agent block; it’s the element type of the roster field. The builder reads the mission, recruits from that registry, and the machine walks the roster it got, summoning one specialist at a time through a single state.
fsm:
name: mission-team
version: 1
initialState: mission
context:
mission: { type: string }
# THE REGISTRY. Every specialist the machine can recruit, declared once, as a type.
roster: { type: list, of: enum, values: [researcher, coder, qa, devops, writer] }
outputs: { type: map, of: string } # each recruit's contribution, keyed by its author
current: { type: string } # whose turn it is - derived, never authored
answer: { type: string }
agents:
# Only the builder drives this machine. The specialists are summoned to answer an ask;
# none of them appears here, and none of them needs to.
builder: { canTransition: [mission, overstaffed, release] }
states:
mission:
description: >-
Read the mission and assemble the team. The registry is: researcher, coder, qa, devops,
writer. Recruit only the specialists the mission needs - no more - and recruit them in the
order they should work: one updateContext(field=roster, value=<specialist>) call each.
requires:
- { name: mission, from: { agent: builder } }
- { name: roster, from: { agent: builder } }
transitions:
# A declared cap on over-recruitment, checked by the machine rather than hoped for in a
# prompt. First matching edge wins, so this is checked before the mission can start.
- toState: overstaffed
guards: [ { backend: cel, expression: "size(context.roster) > 4" } ]
- { toState: next }
overstaffed:
description: >-
{{ context.roster }} is too large for one mission. Cut it back to at most four specialists
and re-recruit.
requires:
- { name: roster, from: { agent: builder } }
transitions:
- toState: mission
onTransition: [ { remove: { roster: "*" } } ]
next:
# No agent, no LLM turn. A pure CEL expression over context picks the next unfilled slot:
# the number of contributions so far IS the index into the roster.
requires:
- name: current
from: system
extract: "context.roster[size(context.outputs)]"
transitions:
- { toState: phase }
phase:
description: >-
You were recruited for this mission: "{{ context.mission }}".
The team is {{ context.roster }} and the work so far is {{ context.outputs }}.
Do your part of it: updateContext(field=outputs, value=<your contribution>).
requires:
# ONE phase state for the whole team. The delegate is whoever `current` names, resolved
# when the state is entered - so the same state summons the researcher, then the coder,
# then whoever else the builder recruited.
- { name: outputs, from: { agent: "{{ context.current }}" } }
transitions:
- toState: next
guards: [ { backend: cel, expression: "size(context.outputs) < size(context.roster)" } ]
onTransition: [ { assign: { current: "" } } ] # re-arm the pick
- toState: release
guards: [ { backend: cel, expression: "size(context.outputs) >= size(context.roster)" } ]
release:
description: >-
Release the team. Compose the final deliverable from the phase outputs.
Stage: updateContext(field=answer, value=<deliverable>).
requires:
- { name: answer, from: { agent: builder } }
transitions:
- { toState: done }
done:
description: "Mission complete. Team released."
terminal: true
policy:
maxExchangesPerStage: 30
maxExchangesPerAgentPerStage: 15
checkpointEvery: 5
Dynamic Team Formation work flow:
Three things to hold onto:
- The registry is a type, not an agent block.
rosteris alistwhose element type is anenumover the five specialists. That single declaration is the registry: it says who exists, and it enforces it at the write - recruitingdatascientistis rejected with the legal values, not discovered later as a summons nobody answers. Theagents:block holds only the builder, becauseagents:confers authority to drive the machine, and a recruited specialist doesn’t drive anything; it answers an ask. This is the distinction the earlier version of this article collapsed, which is why it read as a five-specialist registry with three of them wired. - There is one phase state, and its delegate is chosen at run time.
from: { agent: "{{ context.current }}" }resolves against live context when the state is entered. Sophasesummons the researcher, then the coder, then whoever else was recruited - the same state, a different principal each pass. That’s what removes the per-specialist state and the skip-edge around each one, and it’s why a mission of two and a mission of four traverse the same graph. - The turn order is derived from the data, not tracked beside it.
outputsis a map keyed by contributor, sosize(context.outputs)is exactly how many recruits have delivered - which is also the index of the next one:context.roster[size(context.outputs)]. There’s no cursor field to advance, no counter to keep in sync with the roster it’s counting, and no way for the two to disagree. Thenextstate that computes it runsfrom: system- a CEL expression, no agent, no LLM turn.
Walking one mission
The builder reads the mission: research the market for a feature, prototype it, and test it. It recruits three specialists, one write each - roster = [researcher, coder, qa]. Devops and writer exist in the registry and aren’t recruited. Three is under the cap, so mission → next fires.
next takes no turn at all. outputs is empty, so size(context.outputs) is 0, so current becomes researcher - and the machine falls straight through to phase, where the template on from: resolves and the researcher is summoned. It writes its findings, which land under outputs['agent:researcher']. Now size(context.outputs) is 1, which is less than 3, so the loop-back edge fires and blanks current - re-arming the pick. next computes roster[1] = coder. The coder is summoned into the same phase state, sees the whole outputs map (including the research it needs), and contributes. Then qa, the same way. After the third contribution size(context.outputs) equals size(context.roster), the release edge fires instead, and the builder composes the deliverable from a map that holds every recruit’s work under its author’s name. done - the team is released.
What the reducer does to the failure points
Poor team selection. The builder’s selection is a judgment call - it reads the mission and decides who to recruit. What the machine can do is constrain the space of that judgment and make its result inspectable. The roster’s element type is an enum over the registry, so recruiting someone who doesn’t exist is rejected at the write with the legal values in the error, and the builder can correct it in the same turn. The roster is staged before any work begins, so you can read the team in the parked record and stop the run if it’s wrong. Beyond that the selection is a judgment; the routing is exact.
Excessive discovery and negotiation overhead. The discovery is the builder’s turn: it reads the mission, assembles the roster, and stages it. There’s no multi-round negotiation between the builder and the specialists - the specialists are summoned after the roster is set, and they don’t negotiate. The overhead is one builder turn plus the specialists’ own work, and it’s bounded and visible in the parked record. If the overhead isn’t worth it for a given mission, you don’t run dynamic team formation; you run a fixed team (any of the other twelve patterns). The dynamic team is a tool you reach for when the mission genuinely needs a different team each time, not a default you pay for on every mission.
Redundant specialists. Here the machine does more than make it visible. size(context.roster) > 4 is a real edge into a real state: over-recruit and the mission doesn’t start, the roster is cleared, and the builder is sent back to recruit again with the oversized roster quoted back at it. The cap is declared in the YAML where you can read it, and it’s enforced before any specialist is summoned, so an over-recruitment costs one builder turn rather than four specialist turns. What the cap can’t judge is whether a within-budget roster contains someone the mission didn’t need - recruiting a writer for a research-and-prototype job is legible in the store but not preventable, because “needed” isn’t a predicate. Size is; fit isn’t.
Loss of context when team composition changes. This is the one the design answers most directly. The context store is durable across the whole run, and every recruit writes into the same outputs map. When the researcher contributes, the coder reads it - not because an edge hands it over, but because {{ context.outputs }} is the whole map and the coder is looking at the same store. The context doesn’t live in the specialists; it lives in the store, keyed by which specialist produced it. A recruit that’s finished and gone hasn’t taken anything with it. “Loss of context when team composition changes” is a property of teams where the context lives in the members; here, the members are interchangeable and the store is not.
Team churn can undermine ownership and continuity. Churn is where this shape gives something up, and it’s worth being straight about it. The roster is consumed positionally - roster[size(outputs)] - so it’s an ordered plan, not a live subscription. Appending a specialist mid-mission works and extends the run; replacing one doesn’t, because the index of a delivered contribution is already spent. Expressing replacement means expressing it as data: a replacements map from original to substitute, with the pick reading through it, or a dropped list the pick skips over. That’s a real design, and it’s a couple more fields - but it isn’t free, and the version above doesn’t have it. What the shape does hold onto is the thing churn usually breaks: continuity is owned by the phase and the store, not by the specialist, so a substitute reads exactly what its predecessor would have read.
Two trade-offs to name. Collections are one level deep - of: names a scalar - so a roster entry is a name, not a record with a capability vector and a cost. A large registry doing weighted capability matching would want richer entries, and the honest answer today is a parallel map keyed the same way (the auction article does exactly that with bids and confidence) rather than a nested structure. And the relationship to the auction is worth naming, because the two patterns have converged on the same mechanism: the auction assigns one task to one winner, team formation assembles a team for a mission, and both come down to a typed write that a from: { agent: "{{ ... }}" } resolves against. The auction’s write is a scalar enum; the team’s is a list of them. Selection and composition turn out to be the same machinery with a different arity.
The whole thing, one idea
| Moment | Who | What happened in context |
|---|---|---|
| mission | builder | mission set; roster = [researcher, coder, qa] recruited |
| next | (no agent) | current = roster[0] - pure CEL, no turn |
| phase | researcher | outputs['agent:researcher'] set |
| next → phase | (no agent) | current re-armed → roster[1] = coder |
| phase | coder | outputs['agent:coder'] set |
| next → phase | (no agent) | current → roster[2] = qa |
| phase | qa | outputs['agent:qa'] set; count now equals the roster |
| release | builder | answer set (team released) |
| done | - | terminal |
No fixed team. No wired-in specialists - and no state per specialist either. The registry was a type; the roster was a context write; the turn order was derived from the count of contributions rather than tracked beside it; and one phase state summoned three different agents because its from: is a template. The team composition was data, not wiring, and so was the topology it ran through - the machine was the only thing that moved it.
That’s all thirteen patterns, and the same idea in every one of them: the agents do the judgment, the context store holds the state, the guards apply the rules, and the reducer is the only thing that moves the machine. The pattern was the shape of the coordination; the reactive reducer was the thing that made the shape exact.
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