The Generator / Critic / Refiner Pattern, Implemented via the Reactive Reducer


Back to: Intro

A little background

Eight patterns in. The last few have been loops - debate, blackboard, planner/executor - and the thing they all share is a judge that decides whether the loop continues. This one is the purest version of that idea: generator / critic / refiner. One agent produces an artifact, another evaluates it against criteria, and the producer (or a separate refiner) improves it using the critique. The cycle repeats until a quality threshold, an acceptance condition, or an iteration budget is reached.

The roles are intentionally asymmetric: one produces, one evaluates, one improves. That asymmetry is the whole point - a generator that grades its own homework tends to grade it generously. In most frameworks, the quality gate is a prompt: is this good enough? and the answer is whatever the model feels like. The reactive reducer makes the gate a typed decision with a declared continuation, and the loop is bounded by construction.


The pattern

Generator / Critic / Refiner. One agent produces an artifact or proposed answer, another explicitly evaluates it against criteria, and the original agent or a separate refiner improves it using the critique. The cycle can repeat until a quality threshold, acceptance condition, or iteration budget is reached. Roles are intentionally asymmetric: one produces, one evaluates, and one improves.

Good for:

  • Code generation
  • Writing and document creation
  • Architecture reviews
  • Compliance checks
  • Structured-output quality
  • Tasks with explicit acceptance criteria

Common failure points:

  • Critic invents problems that are not material
  • Refinement loops can continue indefinitely
  • Quality can plateau despite additional iterations
  • Generator may optimize for the critic rather than the real objective
  • Weak criteria produce weak criticism

The machine: a quality gate with a typed verdict

The artifact: a new webhook retry module. The generator produces a draft, the critic evaluates it against the acceptance criteria, and the generator refines it using the critique. Nothing is thrown away: every draft and every critique accumulates in a list, and the loop reads its own history to decide what to do next.

fsm:
  name: webhook-retry
  version: 1
  initialState: draft

  context:
    spec:      { type: string }                             # acceptance criteria, staged once
    drafts:    { type: list, of: string }                   # every attempt, oldest first
    critiques: { type: list, of: string }                   # one per draft
    verdicts:  { type: list, of: enum, values: [accept, revise] }
    final:     { type: string }

  agents:
    author:  { canTransition: [draft] }     # generator AND refiner - the same state, re-entered
    critic:  { canTransition: [critique] }
    shipper: { canTransition: [ship, stalled] }

  states:
    draft:
      description: >-
        Spec: "{{ context.spec }}". Attempts so far: {{ context.drafts }}.
        Prior critiques: {{ context.critiques }} - if this is a revision, address the most recent
        one rather than starting over. Append your attempt:
        updateContext(field=drafts, value=<the artifact>).
      requires:
        - { name: drafts, from: { agent: author } }
      transitions:
        # NOT "is drafts set" - it is set from pass 1 onward and never unset. "Is there a draft
        # nobody has judged yet", which only a size comparison can answer.
        - toState: critique
          guards:
            - { backend: cel, expression: "size(context.drafts) > size(context.verdicts)" }

    critique:
      description: >-
        Evaluate the newest draft against the spec: "{{ context.spec }}". Be specific and material
        only - cite the criterion each finding violates. Append your findings:
        updateContext(field=critiques, value=<findings>), then append your call:
        updateContext(field=verdicts, value=accept|revise).
      requires:
        - { name: critiques, from: { agent: critic } }
        - { name: verdicts,  from: { agent: critic } }
      transitions:
        # EVERY edge out of `critique` is gated on "the newest draft has been judged". This is the
        # half that is easy to miss: nothing is cleared here, so on re-entry `verdicts` is still
        # non-empty and its requirement is instantly satisfied by the PREVIOUS pass's verdict.
        # Without the comparison the machine would run draft -> critique -> draft inside a single
        # advance, having silently reused a stale judgement.
        - toState: ship
          guards:
            - backend: cel
              expression: "size(context.verdicts) == size(context.drafts)
                           && context.verdicts[size(context.verdicts) - 1] == 'accept'"
        # The retry budget reads straight off the accumulated data. No `attempts` counter to
        # maintain, and no way for a counter to disagree with the history it is counting.
        - toState: draft
          guards:
            - backend: cel
              expression: "size(context.verdicts) == size(context.drafts)
                           && context.verdicts[size(context.verdicts) - 1] == 'revise'
                           && size(context.drafts) < 4"
        - toState: stalled
          guards:
            - backend: cel
              expression: "size(context.verdicts) == size(context.drafts)
                           && size(context.drafts) >= 4"

    ship:
      description: "The critic accepted the draft. Stage the final artifact: updateContext(field=final, value=<the accepted artifact>)."
      requires:
        - { name: final, from: { agent: shipper } }
      transitions:
        - { toState: done }

    stalled:
      description: >-
        Quality has plateaued - the critic found no material improvement across iterations.
        Stage the best draft with a note on what's unresolved: updateContext(field=final, value=<best draft + open items>).
      requires:
        - { name: final, from: { agent: shipper } }
      transitions:
        - { toState: done }

    done:
      description: "Shipped (accepted) or stalled (best-effort, with open items)."
      terminal: true

  policy:
    maxIterations: 6             # hard cap on the draft/critique/refine cycle (headless)
    maxExchangesPerStage: 30
    maxExchangesPerAgentPerStage: 15
    checkpointEvery: 5

Generator / Critic / Refiner work flow:

an unjudged draft exists

newest verdict == accept

newest verdict == revise, under budget

four drafts, still not accepted

final staged

best-effort staged

draft

critique

ship

stalled

done

Three things to hold onto:

  • There is no refine state. There is draft, entered again. The generator and the refiner are the same agent doing the same thing - produce the next attempt - and the only difference between pass 1 and pass 3 is what’s already in drafts and critiques. Collapsing them removes a state, an edge, and the duplicated objective that always drifted apart from its twin. author acts in draft on every pass, so canTransition: [draft] is the whole of its authority.
  • Nothing is cleared. Not one assign: { x: "" } in the file. The consume-the-trigger idiom exists because a scalar holds one value, so re-arming a loop means destroying the old one - and in a refinement loop the thing you destroy is the revision history, which is the interesting part. With list context there’s nothing to clear: each pass appends, and the loop reads its own past. {{ context.drafts }} in the objective is every attempt, and the critic can see whether its last three findings were addressed.
  • Freshness becomes a size comparison, and that’s the load-bearing detail. size(drafts) > size(verdicts) means “a draft is waiting to be judged”; size(verdicts) == size(drafts) means “the newest draft has been judged.” Both sides of the loop compare, and both have to. Presence stops being a signal the moment nothing is destroyed: on re-entry to critique, verdicts is still non-empty and its requirement is instantly satisfied by the previous pass’s verdict. Without the equality guard the machine would run draft → critique → draft inside a single advance and park in the wrong state, having silently reused a stale judgement. When you stop destroying state, every test becomes a comparison.

Walking two refinement cycles

The author appends the first draft. drafts has one entry, verdicts has none, so 1 > 0 - the edge fires and critique parks, summoning the critic. It appends "no backoff on retry; missing idempotency key; 429 handling incomplete" and a verdict of revise. Now 1 == 1 and the newest verdict is revise and size(drafts) < 4, so the loop-back fires and the machine re-parks at draft.

The author is re-summoned into the state it started in - but the objective it reads is different, because the objective is templated on the history: one prior attempt, one critique to address. It appends v2. 2 > 1, so critique is entered again, and here’s the thing to watch: critiques and verdicts are both still non-empty, so both requirements look satisfied. Every edge out of the state is gated on size(verdicts) == size(drafts) - 1 == 2 is false - so nothing fires, the machine parks, and the critic is summoned properly. It appends its second critique and this time a verdict of accept. 2 == 2 and the newest verdict is accept, so ship stages the final artifact against a store that still holds both drafts and both critiques. done.

shippercriticauthor(generator/refiner)Machine (reducer)shippercriticauthor(generator/refiner)Machine (reducer)1 > 0 → critique1 == 1, newest =revise, 1 < 4 →draft2 > 1 → critique. Requirements LOOK satisfied(both lists non-empty) - 1 != 2 holds every edgedrafts += v1A2A summon (objective templated on spec + history)critiques += 3 findings, verdicts += reviseA2A summon (same state, richerhistory)drafts += v2A2A summon (evaluates v2)critiques += findings, verdicts += acceptfinal (ship)done (terminal)

What the reducer does to the failure points

Critic invents problems that are not material. The machine can’t stop a critic from nitpicking - that’s a quality-of-the-critic problem. But it can make the nitpicks legible and bounded. The critic’s objective says material only, and cite the criterion each finding violates; every critique it has ever written is in critiques, so a post-hoc read shows whether the findings were grounded and whether they kept moving the goalposts. That’s a stronger audit than a scalar version could give you, where each critique overwrote the last and only the final one survived. And the loop is bounded: the retry budget caps the cycles regardless of how much the critic finds.

Refinement loops can continue indefinitely. Bounded, and the interesting part is where the bound lives. size(context.drafts) < 4 is the retry budget, and it reads straight off the accumulated data - there is no attempts counter to increment, and therefore no way for the counter to disagree with the history it’s counting. That’s the quiet win of accumulating instead of clearing: the loop’s own record is its odometer. On top of that, verdicts is an enum where accept exits, and policy.maxIterations caps the total exchange count for headless runs. The loop converges, hits the budget and lands in stalled, or trips the policy.

Quality can plateau despite additional iterations. This is the one the pattern names explicitly, and the design gives it a first-class exit - a declared stalled state the budget edge routes into, which ships the best draft with an explicit note on what’s unresolved rather than burning the remaining cycles. The plateau is a declared outcome, not a silent waste.

It also gets something a scalar version couldn’t offer: the critic can see the plateau. Its objective carries {{ context.critiques }} - its own prior findings - so “I said this two rounds ago and it still isn’t fixed” is a fact on the page rather than something the critic would have to be trusted to remember across turns it doesn’t share. An earlier version of this machine offered a stalled verdict the critic had no evidence to justify.

Generator may optimize for the critic rather than the real objective. The generator’s objective is templated on the spec (”{{ context.spec }}”), not just on the critiques. The critiques are the how (what to fix); the spec is the what (what good means). By keeping the spec in the objective on every pass, the author is steered toward the real objective, with the critiques as the specific deltas. And because the critic evaluates against the spec too (its objective is templated on it), the critic and the generator are both anchored to the same criteria - the generator can’t drift toward whatever the critic likes if the critic is grading against the spec. The shared anchor is the spec field.

Weak criteria produce weak criticism. The spec is a staged, inspectable field - the acceptance criteria are a document you can read before the loop starts. Weak criteria are visible in the first state’s context, not discovered after a loop of weak criticism. And the fix is structural: strengthen the spec field, and both the critic and the generator are re-anchored. The criteria aren’t buried in a prompt; they’re a typed field the whole machine reasons over.

One honest trade-off, and it’s a DSL one: the refiner re-produces the draft; it doesn’t edit it in place. There’s no apply-this-diff action - the refiner reads the prior attempts from context, produces the improved version, and appends it. For a code artifact that means re-emitting the whole file each cycle (the old ones are in context, so it’s a grounded rewrite, not a blind one), and it means the store grows by a full artifact per pass. If your artifacts are large, declare the element type blob so each version is claim-checked and the list carries references rather than megabytes.

Worth correcting one thing an earlier version of this article said, because it’s load-bearing elsewhere: assign is not literal-only. A value wrapped in ${ ... } is an evaluator expression - { assign: { attempts: "${ context.attempts + 1 }" } } works, and so does { append: { log: "${ 'v' + string(size(context.drafts)) }" } }. What’s still true is the narrower thing: there’s no diff-apply primitive, so an expression can compute a new value but not patch an old one in place.


The whole thing, one idea

MomentWhoWhat happened in context
draftauthordrafts += v1
draft → critiqueguard1 > 0 - an unjudged draft exists
critiquecriticcritiques += findings, verdicts += revise
critique → draftguard1 == 1, newest revise, 1 < 4 - budget remains
draftauthordrafts += v2, objective templated on the full history
critiquecriticcritiques += findings, verdicts += accept
shipshipperfinal set - with both drafts and both critiques still in the store
done-terminal

No is-this-good-enough prompt. No unbounded polish. And no refine state: the generator and the refiner are the same agent re-entering the same state, differing only in the history the objective hands them. The critic graded against the spec, the retry budget was a size() on the data rather than a counter beside it, the plateau had a declared exit - and nothing that happened along the way was thrown away to make the loop work. The quality gate was a typed decision with a declared continuation, and the machine was the only thing that moved it.

Up next: 10. Auction / Contract-Net