The Hierarchical Manager / Worker Pattern, Implemented via the Reactive Reducer


Back to: Intro

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:

delivery-director

workstream-manager

both children briefed

both children terminal

synthesis staged

brief written by the director

route == implement

route == test

route == deliver

impl_note staged

test_note staged

result staged

decompose

await

integrate

done

intake

route

implement

test

deliver

Three things to hold onto:

  • spawn is 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’s results map 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. fsmCompleted is a bound map<string, bool> - fsmCompleted['api_mgr'] is true when that alias has reached a terminal state - and fsmState is the matching map<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.

data_mgr (childFSM)api_mgr (child FSM)Director (parentFSM)data_mgr (childFSM)api_mgr (child FSM)Director (parentFSM)entry actions spawnboth children atcreationown microstep loop,own context storeown microstep loop,own context storejoin guard still false- parkedupdateContext(fsmName=api_mgr,field=brief)updateContext(fsmName=data_mgr, field=brief)terminal → results['api_mgr'] publishedterminal → results['data_mgr'] publishedfsmCompleted['api_mg-r'] &&fsmCompleted['data_m-gr'] → integrate

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

MomentWhoWhat happened in context
(creation)entry actionstwo child FSMs spawned, aliased api_mgr / data_mgr, each parked at intake
decomposedirectorobjective set; brief written into each child by alias
decompose → awaitguardfsmState['api_mgr'] != 'intake' && fsmState['data_mgr'] != 'intake' fired
(child)api_mgr manager + specialistschild’s own store: route, notes, log, result
(child)data_mgr manager + specialistschild’s own store
(child terminal)runtimeeach child’s context published into results[alias]; the parent is woken
await → integrateguardfsmCompleted['api_mgr'] && fsmCompleted['data_mgr'] fired
integratedirectorsynthesis 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