The Hierarchical Manager / Worker Pattern, Implemented via the Reactive Reducer
A little background
The supervisor pattern centralized control in one agent. That works until the task is big - a software-delivery org, a multi-domain problem, a team of teams - and one global supervisor becomes a context black hole: every workstream’s detail lands in one prompt, and the routing table grows with the number of specialists. The fix the pattern literature converges on is the same fix organizations use: split coordination across levels. That’s the hierarchical manager / worker pattern, and in the reactive reducer it has a first-class mechanism most frameworks don’t: child FSMs.
The big idea, up front: each level of the hierarchy is its own machine, with its own context store. The director doesn’t see the managers’ working context - it sees a completion signal and a staged result. The hierarchy isn’t simulated with prompt discipline; it’s structural.
The pattern
Hierarchical Manager / Worker. Coordination is split across multiple levels rather than handled by one global supervisor. A top-level manager decomposes broad objectives into workstreams and delegates them to lower-level managers, which in turn coordinate their own specialist agents. This mirrors organizational hierarchies and allows orchestration responsibilities, context, and decision-making to be scoped to smaller subproblems.
Good for:
- Large tasks
- Software-delivery organizations
- Multi-domain problems
- Systems with many specialized agents
- Large agent organizations with scoped responsibility
Common failure points:
- Too many management layers
- Context gets distorted as it moves through the hierarchy
- Coordination becomes expensive
- Recursive delegation can explode agent count
The machine: a director with two child FSMs
A director takes a broad objective and splits it into two workstreams - API and data. Each workstream is delegated to a manager, and each manager is a child FSM: its own definition, its own context, its own states. The director’s machine only knows the children by alias.
The director, first:
fsm:
name: delivery-director
version: 1
initialState: decompose
context:
objective: { type: string }
results: { type: map, of: string } # each child publishes into this, keyed by alias
synthesis: { type: string }
agents:
director: { canTransition: [decompose, await, integrate] }
states:
decompose:
# Entry actions on the INITIAL state run at instance creation, so both children exist
# before the director's first turn - it can address them by alias straight away.
entry:
- { spawn: workstream-manager, alias: api_mgr, into: results }
- { spawn: workstream-manager, alias: data_mgr, into: results }
description: >-
Split the objective "{{ context.objective }}" into two workstream briefs and hand each one
down to the child that will run it, addressing the child by its alias:
updateContext(fsmName=api_mgr, field=brief, value=<the API brief>), then
updateContext(fsmName=data_mgr, field=brief, value=<the data brief>).
requires:
- { name: objective, from: agent }
transitions:
# Both children have left `intake`, which they can only do once they hold a brief.
# The parent observes the children's STATE, never their values.
- toState: await
guards:
- backend: cel
expression: "fsmState['api_mgr'] != 'intake' && fsmState['data_mgr'] != 'intake'"
await:
description: >-
Both workstream managers are running their own machines. Nothing to do here - the director
is parked until each one publishes its result into {{ context.results }}.
transitions:
- toState: integrate
guards:
- { backend: cel, expression: "fsmCompleted['api_mgr'] && fsmCompleted['data_mgr']" }
integrate:
description: >-
Both workstreams delivered. Their results are readable directly - {{ context.results }} is
keyed by alias. Stage: updateContext(field=synthesis, value=<final deliverable summary>).
requires:
- { name: synthesis, from: agent }
transitions:
- { toState: done }
done:
description: "Both workstreams delivered and integrated."
terminal: true
policy:
maxExchangesPerStage: 30
maxExchangesPerAgentPerStage: 15
checkpointEvery: 5
And the child - the manager - which is itself a small supervisor (pattern #3, recursed):
fsm:
name: workstream-manager
version: 1
initialState: intake
context:
brief: { type: string } # written by the director, by alias
route: { type: enum, values: [implement, test, deliver] } # a consumed trigger
impl_note: { type: string }
test_note: { type: string }
log: { type: list, of: string } # accumulated, never cleared
result: { type: string }
agents:
manager: { canTransition: [route, deliver] }
implementer: { canTransition: [implement] }
tester: { canTransition: [test] }
states:
intake:
description: "Parked until the director hands this workstream its brief."
requires:
- { name: brief, from: agent }
transitions:
- { toState: route }
route:
description: >-
Brief: {{ context.brief }}. Done so far: {{ context.log }}.
Decide the next step for this workstream - implement, test, or deliver - and stage `route`.
requires:
- { name: route, from: agent }
transitions:
- { toState: implement, guards: [ { backend: cel, expression: "context.route == 'implement'" } ] }
- { toState: test, guards: [ { backend: cel, expression: "context.route == 'test'" } ] }
- { toState: deliver, guards: [ { backend: cel, expression: "context.route == 'deliver'" } ] }
implement:
description: "Implement per the brief. Stage impl_note."
requires:
- { name: impl_note, from: { agent: implementer } }
# Keep the work, consume the trigger. `exit` runs before the edge fires, so impl_note is
# still set when it is appended to the history.
exit:
- { append: { log: "${ 'implement: ' + context.impl_note }" } }
transitions:
- toState: route
onTransition: [ { assign: { route: "", impl_note: "" } } ]
test:
description: "Verify the implementation. Stage test_note."
requires:
- { name: test_note, from: { agent: tester } }
exit:
- { append: { log: "${ 'test: ' + context.test_note }" } }
transitions:
- toState: route
onTransition: [ { assign: { route: "", test_note: "" } } ]
deliver:
description: "Stage the workstream result for the director: updateContext(field=result, value=<result>)."
requires:
- { name: result, from: agent }
transitions:
- { toState: done }
done:
description: "Workstream complete - this machine's context is published to the director."
terminal: true
Hierarchical Manager / Worker work flow:
Three things to hold onto:
spawnis the delegation, and it reads as a command.{ spawn: workstream-manager, alias: api_mgr, into: results }- the verb takes the child definition as its object, and the modifiers sit beside it. Each spawn creates an independent instance of the registered definition with a stable alias. The child runs its own microstep loop with its own context store; the director’s machine never steps into the child’s states.into:is the up-channel, and it’s declarative. When a child reaches a terminal state its context is published into the parent’sresultsmap under its alias, and that write wakes the parent - the join guard is re-evaluated at the moment the child finishes, not whenever the director next happens to act.results['api_mgr']is the child’s whole result, as data. Internal bookkeeping (anything_-prefixed) is stripped on the way out.- The join across levels is a plain map lookup.
fsmCompletedis a boundmap<string, bool>-fsmCompleted['api_mgr']is true when that alias has reached a terminal state - andfsmStateis the matchingmap<string, string>of current state names. These are lookups into tables the runtime precomputes before evaluation, not queries against a live registry, which is what keeps guards pure. Every alias the definition declares is seeded, so a negated lookup reads correctly even before the child has spawned.
The down-channel is the asymmetric half, and worth naming: there is no declarative way to seed a child’s context at spawn. The brief goes down as an ordinary addressed write - updateContext(fsmName=api_mgr, field=brief, value=...) - from the director’s own turn. Aliases resolve globally, so any agent holding one can write into that instance. It’s a typed write into a typed field either way; it just isn’t declared on the edge the way into: is.
Walking one level down
Creating the director instance runs decompose’s entry actions immediately, so both children exist before anyone has taken a turn - each parked at its own intake, waiting for a brief. The director’s first turn states the objective and writes a brief into each child by alias. Each of those writes lands in a different machine and advances that machine: intake → route. Once neither child is at intake any more, the director’s own guard passes and it moves to await.
From there the director does nothing. The children run concurrently, each a self-contained machine: manager routes to implement, the implementer writes impl_note, the exit action folds it into log, the trigger is consumed, the manager re-parks at route, routes to test, and eventually to deliver, staging result. Terminal - and terminal is when the up-channel fires.
The director’s integrate state then reads results - a map keyed by alias, each value the child’s own context - and synthesizes. The director never saw a single impl_note or test_note, and never saw the children’s logs. What crossed the boundary was the brief going down and the finished context coming up. That’s the scoping the pattern is asking for, and it isn’t enforced by prompt hygiene. It’s enforced by the fact that the child’s context is a different store, reachable only at the two moments the definition says so.
What the reducer does to the failure points
Too many management layers. The hierarchy is declared, so depth is a design decision you make and can count, not an emergent property of agents deciding to delegate. Each level is a separate YAML definition; adding a layer is a code review, not a runtime surprise. And because a child is a full machine, you can make a level shallower by inlining its states into the parent when the workstream is small - the DSL doesn’t force a level to exist.
Context gets distorted as it moves through the hierarchy. This is the failure the design attacks hardest. In a prompt-based hierarchy, each level re-summarizes the level above’s intent, and the distortion compounds with depth. Here, the handoff between levels is a typed context field - the brief goes down as a write into the child’s brief (templated in full into the child’s own guidance via {{ context.brief }}), and the child’s context comes up into results[alias] when it finishes. No level re-summarizes another’s working context; the parent reads the child’s output, not the child’s transcript. Distortion is bounded to the two explicit boundaries: brief in, context out.
Worth being precise about what “reads the child’s output” means now, because it used to mean less. The parent could previously see only a child’s current state name and its terminal flag - never any of its values - so “the director reads the workstream’s result” had to happen through an agent turn re-stating it, which is exactly the re-summarization the pattern is trying to avoid. into: removes that turn: the values arrive as data.
Coordination becomes expensive. The director’s coordination cost is constant in the size of a workstream. It parks at await and evaluates two map lookups; it does not poll, message, or track the managers’ internal progress. It isn’t woken by their progress either - only by their completion, because publishing the result is what re-evaluates the join. The expensive work - routing implement/test/deliver - happens inside the child, in the child’s context, at the child’s cost. That’s the “scoped responsibility” the pattern describes, made literal: the cost of a workstream is paid by the workstream.
Recursive delegation can explode agent count. Two structural brakes. First, a child is a registered definition - delegation is to a known machine, not to “spawn whatever,” so the agent-count growth is bounded by the definitions you’ve written. Second, the same policy tools apply at every level: maxIterations bounds any loop inside a child, and the parent’s join is a pure guard, so a stuck child doesn’t spin the parent - it just keeps the parent parked, visibly, at await. You can see exactly which workstream hasn’t finished, because the join predicate names the alias.
One more property: the hierarchy is inspectable per level. Each machine’s parked record is journaled independently. To debug a delivery, you read the director’s store (briefs, results, synthesis) or a manager’s store (route decisions, notes) - you don’t untangle one giant transcript. The org chart is a set of small, readable machines.
The whole thing, one idea
| Moment | Who | What happened in context |
|---|---|---|
| (creation) | entry actions | two child FSMs spawned, aliased api_mgr / data_mgr, each parked at intake |
| decompose | director | objective set; brief written into each child by alias |
| decompose → await | guard | fsmState['api_mgr'] != 'intake' && fsmState['data_mgr'] != 'intake' fired |
| (child) | api_mgr manager + specialists | child’s own store: route, notes, log, result |
| (child) | data_mgr manager + specialists | child’s own store |
| (child terminal) | runtime | each child’s context published into results[alias]; the parent is woken |
| await → integrate | guard | fsmCompleted['api_mgr'] && fsmCompleted['data_mgr'] fired |
| integrate | director | synthesis set, read from results |
| done | - | terminal |
No global supervisor drowning in every workstream’s detail. No re-summarizing up the chain. Each level is its own reactive reducer with its own store; delegation is a spawn of a registered definition; the cross-level join is a pure lookup in fsmCompleted. The hierarchy is data - a set of small machines with typed boundaries - and context crosses between levels only at the two moments the definition declares.
Up next: 5. Peer Handoff / Swarm
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