The Planner / Executor Pattern, Implemented via the Reactive Reducer


Back to: Intro

A little background

Seven patterns in. We’ve separated flow from authority, delegation from reasoning, and coordination from the agents doing the coordinating. This one is the pattern that most “agentic coding” systems actually are, whether they know it or not: planner / executor. One agent turns an objective into a plan - a sequence of steps, a task graph, a set of directives. One or more executor agents do the actual work. And the results come back to the planner, which revises the remaining plan as new information is discovered.

The interesting part isn’t the planning. It’s the feedback: execution reveals assumptions that invalidate the plan, and the system has to re-plan without losing the work it’s already done. In most frameworks, that feedback is a prompt: “here’s what happened, now update your plan.” Whether the plan actually gets revised - and whether the revision is grounded in the actual result or the planner’s memory of it - is left to the model.

The reactive reducer makes the feedback structural: the result is a staged context field, the re-plan is a declared edge, and the planner’s revised directive is a typed write the executor’s objective is templated on. The loop is the machine; the planning is the agent.


The pattern

Planner / Executor. One agent is responsible for turning an objective into a plan, sequence of steps, or task graph, while one or more executor agents perform the actual work. Results from execution can be returned to the planner so that the remaining plan can be revised as new information is discovered. This separates strategic decomposition from tactical execution.

Good for:

  • Open-ended objectives
  • Coding and implementation tasks
  • Research workflows
  • Tasks whose solution path cannot be predetermined
  • Long-running work that benefits from re-planning

Common failure points:

  • Bad plans cascade into bad execution
  • Planner over-decomposes simple work
  • Execution reveals assumptions that invalidate the plan
  • Re-planning can become an endless loop
  • Planner and executor can disagree about task completion

The machine: a directive goes out, a result comes back

An open-ended objective: add rate limiting to the public API. The planner breaks it into a first directive and a remaining plan. The executor does the work and reports back - done, needs a re-plan, or abort. If it needs a re-plan, the planner sees the actual result and revises.

fsm:
  name: rate-limit-build
  version: 1
  initialState: plan

  context:
    objective: { type: string }
    plan:      { type: string }                 # the remaining roadmap (revised on re-plan)
    directive: { type: string }                 # the next step - consumed each cycle
    result:    { type: string }                 # THIS cycle's finding - consumed each cycle
    history:   { type: list, of: string }       # every directive and what came back - kept
    outcome:   { type: enum, values: [done, replan, abort] }
    summary:   { type: string }                 # final deliverable summary

  agents:
    planner:  { canTransition: [plan, replan, finish] }
    executor: { canTransition: [execute] }

  states:
    plan:
      description: >-
        Break the objective "{{ context.objective }}" into a plan: a remaining roadmap (plan)
        and a first concrete directive (directive) the executor can walk end to end.
        Stage: updateContext(field=plan, value=<roadmap>) and
        updateContext(field=directive, value=<first step>).
      requires:
        - { name: plan, from: { agent: planner } }
        - { name: directive, from: { agent: planner } }
      transitions:
        - { toState: execute }

    execute:
      description: >-
        Walk the directive: "{{ context.directive }}" (roadmap: "{{ context.plan }}").
        Do the work. Then stage what you actually found/did: updateContext(field=result,
        value=<findings + what changed>), and updateContext(field=outcome, value=<done|replan|abort>).
        Use replan if the directive's assumptions were wrong or the remaining plan needs revision.
      requires:
        - { name: result,  from: { agent: executor } }
        - { name: outcome, from: { agent: executor } }
      # Keep the evidence before the trigger fields are consumed. `exit` runs after the edge is
      # selected and before it fires, so `directive` and `result` are both still set here.
      exit:
        - { append: { history: "${ context.directive + ' → ' + context.result }" } }
      transitions:
        - { toState: finish, guards: [ { backend: cel, expression: "context.outcome == 'done'" } ] }
        - toState: replan
          guards: [ { backend: cel, expression: "context.outcome == 'replan'" } ]
          onTransition:
            # ALL THREE are consumed. Leaving `outcome` set would re-fire this edge the moment the
            # machine re-entered `execute` - bouncing straight back to `replan` without the executor
            # ever being summoned. Leaving `result` set would satisfy the executor's requirement
            # with the previous cycle's finding. The evidence survives in `history`.
            - { assign: { directive: "", outcome: "", result: "" } }
        - { toState: abort, guards: [ { backend: cel, expression: "context.outcome == 'abort'" } ] }

    replan:
      description: >-
        What has actually happened so far: {{ context.history }}. The last cycle came back as
        `replan`. Revise the remaining roadmap against the evidence and issue the next directive.
        Stage: updateContext(field=plan, value=<revised roadmap>) and
        updateContext(field=directive, value=<next step>).
      requires:
        - { name: plan, from: { agent: planner } }
        - { name: directive, from: { agent: planner } }
      transitions:
        - { toState: execute }

    finish:
      description: "Compose the final summary from the full record: {{ context.history }}. Stage: updateContext(field=summary, value=<summary>)."
      requires:
        - { name: summary, from: { agent: planner } }
      transitions:
        - { toState: done }

    abort:
      description: "The executor aborted. Stage a summary of where things stand and why, then end: updateContext(field=summary, value=<abort summary>)."
      requires:
        - { name: summary, from: { agent: planner } }
      transitions:
        - { toState: done }

    done:
      description: "Delivered (or aborted with a summary)."
      terminal: true

  policy:
    maxIterations: 8             # bounds the execute/replan loop (headless)
    maxExchangesPerStage: 30
    maxExchangesPerAgentPerStage: 15
    checkpointEvery: 5

Planner / Executor work flow:

plan + directive staged

outcome == done

outcome == replan (all triggers consumed)

outcome == abort

revised plan + new directive

summary staged

abort summary staged

plan

execute

finish

replan

abort

done

Three things to hold onto:

  • The plan and the directive are separate fields, and that separation is the pattern. plan is the remaining roadmap - strategic, revised on re-plan. directive is the next step - tactical, what the executor actually walks. The executor’s objective is templated on the directive (with the plan as context), so it does one concrete thing, not the whole roadmap. This is the “separates strategic decomposition from tactical execution” requirement, made into two typed fields.
  • The replan edge consumes all three triggers and keeps the plan. directive, outcome and result are cleared; plan survives, because it’s the roadmap the planner is about to revise. All three have to go, and it’s worth seeing why each one would break the loop on its own. A surviving outcome = replan re-fires this very edge the instant the machine re-enters execute, bouncing it back to replan without the executor being summoned at all. A surviving result satisfies the executor’s requirement with the previous cycle’s finding, so the state falls through without a turn. A surviving directive means the planner’s new instruction is never waited for. Three fields, three different ways to get a loop that runs and does nothing.
  • The evidence survives in a list, not in the trigger. execute’s exit action appends "<directive> → <result>" to history before the edge clears anything, so the planner revises from everything that happened, not just the last thing. That split - a consumed scalar for the trigger, an accumulated list for the record - is the general answer to “I need to clear this to re-arm the loop, but I also need to keep it.” A scalar version of this article kept only the most recent result, which is exactly the memory a planner shouldn’t be working from on cycle four.

Walking a re-plan

The planner breaks the objective into a roadmap (“1. middleware scaffold, 2. config + limits, 3. 429 responses + docs, 4. load test”) and a first directive (“scaffold the rate-limit middleware and wire it into the request pipeline”). The executor walks it - and discovers the pipeline is already using a different middleware framework than the plan assumed. It stages result = "pipeline uses X, not Y; scaffold built against X; config step needs to target X's plugin API" and outcome = replan.

The replan edge is selected. exit appends the pair to history; the edge then blanks directive, outcome and result; the machine lands in replan with nothing set but the roadmap and the record, and parks. The planner is summoned, reads the history, revises the roadmap (“config + limits now target X’s plugin API; the rest unchanged”) and issues a new directive. Back at execute, all three trigger fields are empty, so the state genuinely waits for the executor rather than falling through on last cycle’s answer. This time the assumptions hold; the executor stages outcome = done, its cycle is appended too, and finish composes the summary from a record that has both cycles in it. done.

executorplannerMachine (reducer)executorplannerMachine (reducer)exit appends to historydirective + outcome + result all consumed → replanplan + directive (scaffold)A2A summon (objective templated on directive)result (wrong framework) + outcome=replanA2A summon (objective templated onhistory)plan (revised) + directive (config+limits)A2A summon (triggers empty - genuinely waits)result + outcome=donesummary (finish)done (terminal)

What the reducer does to the failure points

Bad plans cascade into bad execution. A bad plan still cascades - the executor walks what the planner wrote. But the cascade is now interruptible. The executor’s outcome enum is the tripwire: the moment execution reveals the plan was wrong, the executor writes replan, and the machine routes back to the planner with the actual result in hand. The cascade doesn’t run to the end of the plan; it stops at the first step where reality disagreed with the assumption. And because the result is a staged field, the planner revises from evidence, not from a hazy memory of what the executor “probably” found.

Planner over-decomposes simple work. Over-decomposition is now visible and cheap to fix. The plan is a staged, inspectable field - you can read the roadmap before the executor walks a single step, and if it’s forty steps for a two-line change, you see it in the YAML’s first state, not in a trace after the fact. And the fix is structural: the planner can issue a single directive that walks the whole thing and report done in one execute. The machine doesn’t care how granular the plan is; it cares that the directive is walkable.

Execution reveals assumptions that invalidate the plan. This is the pattern’s core case, and it’s the replan edge doing exactly what it was drawn for. The assumption (“the pipeline uses framework Y”) is invalidated by execution (“it uses X”); the executor reports it in result; the planner revises plan and issues a new directive. The invalidation is a first-class event - a context write that fires a declared edge - not a side effect of the executor mentioning it in a comment. The plan and the reality are reconciled by the machine, at a specific state, with the evidence in the store.

Re-planning can become an endless loop. Bounded two ways, and worth being precise since the third is easy to over-credit. The outcome enum has exactly three values - done and abort both exit, only replan continues - and policy.maxIterations caps the total exchange count, so a pair that keeps re-planning hits the cap. The reducer’s no-progress guard is not a third backstop here: it stops oscillation within a single advance() call, which keeps the microstep loop hazard-free, but it doesn’t span turns, so it won’t notice a planner and executor going around four times with slightly different text each time. What you do get for free is a legible loop: history is every cycle in order, so “why did this re-plan five times” is a field you read. And size(context.history) is the cycle count if you want a harder in-machine cap than the policy’s - a guard, not a new field.

Planner and executor can disagree about task completion. The executor is the one who did the work, and the executor is the one who writes outcome. The planner can’t declare the task done - it can only react to the executor’s outcome. If the executor says done and the planner thinks it’s not, the planner’s move is to issue another directive (a verification step, a re-check), not to override the executor’s report. Completion is the executor’s typed claim, and the planner’s disagreement is expressed as more work, not as a second opinion in a prompt. That keeps the “who’s actually done?” question grounded in the agent that touched the code, not in the agent that wrote the roadmap.

One honest trade-off: the plan is text in a context field. The reducer doesn’t parse it into a task graph - it carries it as a value and templates it into objectives. Collections help at the edges (history is a real list the machine can count, and a plan could be a list of step descriptions the executor walks by index, the way the dynamic-team pattern walks its roster) but they’re one level deep by design, so a step with dependencies and per-step state isn’t a context type. If you need a genuine task graph, either encode the structure inside the field’s content and have the agents operate on it as data, or reach for the hierarchical pattern, where each step is a child FSM with its own store and its results are published back into the parent’s map. The reactive reducer gives you the loop; the plan’s internal structure is up to the agents or the next machine down.


The whole thing, one idea

MomentWhoWhat happened in context
planplannerplan + directive set
executeexecutorresult + outcome=replan set
execute → replan-cycle appended to history; directive, outcome, result all consumed
replanplannerplan revised + new directive, read from history
executeexecutorresult + outcome=done set; appended too
finishplannersummary set, composed from the whole record
done-terminal

No “update your plan” prompt. No hope that the plan held. The planner wrote a directive; the executor walked it; the result came back as a staged field; the re-plan was a declared edge that consumed every trigger and folded the evidence into a record that outlives them. The loop was bounded by an enum and a cap - and legible because the record is a list, not the last thing anybody said. The planning was the agent’s job. The loop was the machine’s.

Up next: 9. Generator / Critic / Refiner