Walking a Reentrant Human-in-the-Loop FSM, Step by Step


A little background

The last article covered the reactive reducer pattern at a high level - context as the single source of truth, agents contributing values, the machine advancing itself. This one is the payoff: we take a real FSM and walk it tick by tick.

It’s a human-in-the-loop review workflow with a reentrant revision cycle, and it shows everything at once - how a guard allows a transition, how a human gets elicited, and how the goal is reached. All of it, through updating context.


The machine

Here’s the whole thing. It’s short on purpose - six states, one agent, two humans.

fsm:
  name: hil-review-reentrant
  version: 1
  initialState: draft

  context:
    drafts:   { type: list, of: string }                                # every attempt, oldest first
    verdicts: { type: list, of: enum, values: [approved, changes, rejected] }
    reasons:  { type: list, of: string }                                # one per verdict, index-aligned
    reviewer: { type: enum, values: [brad, dana] }                      # who signs off THIS round
    ack:      { type: enum, values: [acknowledged] }

  agents:
    envoy:
      canTransition: [draft, abort]
      guidance: full

  states:
    draft:
      description: >-
        Attempts so far: {{ context.drafts }}. Feedback so far: {{ context.reasons }}.
        Produce the next draft - if this is a revision, address the most recent note rather than
        starting over - and append it: updateContext(field=drafts, value=<the draft>).
      requires:
        - { name: drafts, from: agent }
      transitions:
        # NOT "is drafts set" - it is set from pass 1 onward and never unset. "Is there a draft
        # nobody has reviewed yet", which only a size comparison can answer.
        - toState: assign-reviewer
          guards:
            - { backend: cel, expression: "size(context.drafts) > size(context.verdicts)" }

    assign-reviewer:
      # No agent, no LLM turn, no modal. A pure CEL expression over context decides who reviews:
      # after two rounds with brad, the third goes to someone else.
      requires:
        - name: reviewer
          from: system
          extract: "size(context.verdicts) >= 2 ? 'dana' : 'brad'"
      transitions:
        - { toState: review }

    review:
      description: "Awaiting sign-off on the latest draft. Feedback so far: {{ context.reasons }}."
      requires:
        # The human is whoever `assign-reviewer` just chose - resolved when the state is entered.
        - name: verdicts
          from: { user: "{{ context.reviewer }}" }
          prompt: "Review the latest draft and choose an outcome."
          blocking: true                       # pop the modal now, don't queue it
        - name: reasons
          from: { user: "{{ context.reviewer }}" }
          label: "Why? (one line - required, even for an approval)"
      transitions:
        # EVERY edge out of `review` is gated on "the newest draft has been judged". Nothing is
        # cleared here, so on re-entry `verdicts` is still non-empty and its requirement is
        # satisfied by the PREVIOUS round's answer. Without the comparison the machine would
        # route on a stale verdict without ever raising the modal.
        - toState: done
          guards:
            - backend: cel
              expression: "size(context.verdicts) == size(context.drafts)
                           && context.verdicts[size(context.verdicts) - 1] == 'approved'"
        # The revision budget reads straight off the accumulated data. No counter to maintain.
        - toState: draft
          guards:
            - backend: cel
              expression: "size(context.verdicts) == size(context.drafts)
                           && context.verdicts[size(context.verdicts) - 1] == 'changes'
                           && size(context.drafts) < 3"
          onTransition:
            - { assign: { reviewer: "" } }     # re-arm the assignment for the next round
        - toState: abort
          guards:
            - backend: cel
              expression: "size(context.verdicts) == size(context.drafts)
                           && (context.verdicts[size(context.verdicts) - 1] == 'rejected'
                               || size(context.drafts) >= 3)"

    done:
      description: "Approved after {{ context.verdicts }}. Every draft is still here: {{ context.drafts }}."
      terminal: true

    abort:
      description: >-
        We need to pump the brakes - this workflow is over. The full history is
        {{ context.verdicts }}, with feedback {{ context.reasons }}. No further work is needed.
        Acknowledge by calling updateContext(field=ack, value=acknowledged), then end your turn.
      requires:
        - { name: ack, from: agent }
      transitions:
        - { toState: aborted }

    aborted:
      description: "Aborted - work will not proceed."
      terminal: true

  policy:
    maxExchangesPerStage: 30
    maxExchangesPerAgentPerStage: 15
    checkpointEvery: 5

size(drafts) > size(verdicts)

reviewer derived (no turn)

newest verdict == approved

newest verdict == changes, under budget

rejected, or budget spent

agent acknowledges

assign-reviewer

draft

review

done

abort

aborted

Three things to hold onto before we start:

  • review is the only state with a human in it. Both its requires are from: { user: ... }, so entering it parks the machine and raises a modal. Everything else is agent-driven or agentless.
  • The reentrancy is review → draft. The machine can visit review more than once. Whether it loops or settles depends entirely on what’s in context.
  • Nothing is ever cleared to make the loop work. There isn’t a single assign: { x: "" } on the artifact fields. The drafts, the verdicts and the reasons all accumulate, and the loop reads its own history to decide what to do next. That’s the whole difference between this machine and the version I’d have written six months ago, and it changes how every guard in it is written.

Why the loop keeps its history

Worth pausing on, because it’s the design decision the rest of the walkthrough hangs off.

A scalar holds one value. So the only way to re-arm a loop built on scalars is to destroy the old one:

onTransition: [ { assign: { draft: "", verdict: "" } } ]   # the old way

That works, and it’s a real idiom - the trigger has to be consumed or the edge re-fires immediately when the machine runs to stable. But look at what it costs in this workflow. Every pass throws away the previous draft and the human’s reasoning. By the time a review loop finishes, the revision history - which is the interesting part of a review loop - is unrecoverable. The reviewer on round three cannot see what they asked for on round one, and neither can the agent.

With list context there is nothing to clear. Each pass appends. Three consequences run through the whole machine:

  • No drain. The artifact fields only grow, so {{ context.drafts }} in the agent’s objective is every attempt and {{ context.reasons }} is every note the humans have written.
  • No bookkeeping counter. The old version of this FSM carried a cycle: { type: int, default: 0 } field to bound the loop. It never worked - default: isn’t part of the context schema, so the key was silently dropped, and nothing ever wrote or read cycle. The revision budget here is size(context.drafts) < 3. The counter is the data, so it cannot disagree with the history it is counting.
  • Presence stops being a signal. This is the one that bites. has(context.verdicts) is true from the first answer onward and never goes back to false, so every test in the loop has to become a comparison - and on both sides. size(drafts) > size(verdicts) is “there’s a draft nobody has judged”; size(verdicts) == size(drafts) is “the newest draft has been judged”. Get only the first and the machine runs draft → review → draft inside a single advance, silently reusing the previous round’s verdict.

That last point is also why reasons is required rather than optional. If a human could skip it, reasons and verdicts would drift out of alignment and reasons[i] would stop being the note for verdicts[i]. Requiring one line - even for an approval - is what keeps the two lists index-aligned, and index alignment is the only reason “the most recent note” means anything.


Step 0 - an empty machine

At load, the context is empty. Nothing has been written, so nothing is eligible. The machine sits in draft waiting for a contribution.

context: { }
state:    draft

One subtlety that matters immediately: a declared-but-unwritten list binds as an empty collection in guard expressions, not as an absent key. Indexing or calling size() on a missing key is a CEL error, and an errored guard is treated as “does not fire” - so without that seeding, size(context.drafts) > size(context.verdicts) would silently never fire until both lists had been written at least once, which for a loop’s first pass is never. Instead it evaluates 0 > 0, which is honestly false, and the machine parks exactly where it should.

Scalars are deliberately not seeded the same way: has(context.reviewer) needs to stay a meaningful test for “has this been decided yet”. An empty list is a true claim (“nothing has accumulated”); an empty string is not the same as an unset one.

Step 1 - the agent drafts (draft → assign-reviewer)

draft’s only requirement is { name: drafts, from: agent }. The agent’s objective, injected every turn, carries the whole history - empty, this first time - and asks for the next attempt:

updateContext(field=drafts, value="Add retry with backoff to the webhook client")

Because drafts is a list, that write appends; it doesn’t replace. The reducer re-runs, 1 > 0 holds, and the edge fires. No one asked the machine to move; the agent contributed a value.

context: { drafts: ["Add retry with backoff…"] }
state:    assign-reviewer

Step 2 - a state with nobody in it

assign-reviewer has no agent, no human and no modal. Its single requirement is from: system with a CEL expression, so the runtime resolves it itself:

- name: reviewer
  from: system
  extract: "size(context.verdicts) >= 2 ? 'dana' : 'brad'"

No verdicts yet, so reviewer becomes brad, the write re-advances the machine, and it falls straight through to review. Zero tokens, one journal entry. The state exists because the next state needs a resolved name before it can page anyone - a template can’t be rendered against a field that hasn’t been computed yet, and auto-resolution is asynchronous, so deriving the value in the same state that consumes it would be a race. Derive first, then use: it’s the same shape as taking an index in CEL before handing it to a tool.

Step 3 - the machine parks and elicits a human (review)

review’s requirements are from: { user: ... } - interactive sources the agent can’t satisfy. The loop stops, and the runtime raises whatever the state is waiting on.

Three things happen at once here:

The target is resolved, not authored. from: { user: "{{ context.reviewer }}" } renders against live context when the state is entered, so the modal goes to brad on the first two rounds and dana on the third. The pool isn’t a branch per person - it’s the enum on the reviewer field, which means a value outside it is rejected at the write rather than discovered later as a page nobody answers.

Two requirements from the same human become one modal. verdicts is a list of an enum, so it renders as a 3-way choice (approved / changes / rejected). reasons is a list of strings, so it’s a text box in the same modal. The human sees one prompt, not two. The form is derived from the context schema, not from the requirement - the requirement only says who, and optionally overlays a label.

blocking: true decides when they see it. That flag is a presentation hint for the multi-agent screen: pop the approval modal immediately rather than deferring it to the pending-review queue. It’s worth being precise that this changes nothing about the machine - the elicitation is still asynchronous, the machine is still parked, and the answer still arrives whenever it arrives. It’s the difference between interrupting someone and leaving them a note.

brad (human)RuntimeMachine (reducer)brad (human)RuntimeMachine (reducer)park at review, 2 pending elicitationson {{ context.reviewer }}raise ONE modal (choice + text),blockingverdict = changes, reason = "useexponential backoff"appends verdicts[0], reasons[0]re-run advance, 1 ==1, newest is changes,under budget, draft

The machine is now parked - not spinning, not guessing. It’s waiting on a human and it can wait days; the parked record is journaled to disk, so a process restart just re-issues the same modal.

Step 4 - the human’s answer is a context write

Whatever the human picks, it lands in context under the field’s name. That’s the entire elicitation mechanism: a modal is just a UI over a context write.

context: { drafts: [d1], verdicts: [changes], reasons: ["use exponential backoff"] }

Now the reducer re-runs from review, and the guards do the routing. Every edge asks the same first question - has the newest draft been judged? - and then a second one:

EdgeSecond conditionFires when
→ donenewest verdict is approvedhuman approved
→ draftnewest verdict is changes, and size(drafts) < 3revisions requested, budget remains
→ abortnewest verdict is rejected, or size(drafts) >= 3human rejected, or we’re out of rounds

The human’s choice is the only thing that matters. The machine doesn’t interpret it, prompt it, or second-guess it - it evaluates the guards and moves.


Path A - approved: the goal is reached

Newest verdict is approved → the first edge fires → done, which is terminal. The run is journaled and the machine stops. The goal - a human-approved work product - was reached by exactly one meaningful write: the human choosing a verdict.

And done can read the whole run, because nothing was thrown away to get there:

context: { drafts: [d1, d2], verdicts: [changes, approved], reasons: [r1, r2] }
state:    done  (terminal)

Path B - changes: the reentrant cycle

This is where the machine earns its “reentrant” name - and where the accumulate design does the most work. The changes edge fires back to draft, and its onTransition is one line:

onTransition:
  - { assign: { reviewer: "" } }   # re-arm the assignment for the next round

That’s the only thing consumed in the entire loop, and it isn’t an artifact - it’s the routing decision. Blanking reviewer is what makes assign-reviewer re-derive it next time round instead of skipping (an auto-resolved requirement only fires when its field is unset). The drafts, verdicts and reasons are all untouched.

So draft is re-entered with a richer objective than it had the first time - it now interpolates one prior attempt and one note - and the agent revises against the actual feedback rather than a paraphrase of it. It appends a second draft. 2 > 1 holds, so the machine moves on.

Then the part that’s easy to get wrong. review is entered again, and both its requirements look satisfied: verdicts and reasons are non-empty and will never be empty again. What holds the machine is the comparison. size(verdicts) == size(drafts) is 1 == 2, which is false on every edge, so nothing fires, the state parks, and the modal is raised properly. The human answers, the lists reach 2 == 2, and the guards evaluate for real.

context: { drafts: [d1, d2], verdicts: [changes], reasons: [r1] }
state:    review  (parked again, with the full history intact)

The loop continues until a human approves or rejects, or until the third draft trips size(drafts) >= 3. That budget is the real backstop, and it’s worth saying plainly what isn’t: the reducer’s no-progress guard stops oscillation within a single advance() call, which keeps the microstep loop hazard-free, but it does not span turns. It will not notice a review cycle going round four times with slightly different text each time. Cross-turn convergence is the guard on the data plus policy.maxIterations, and nothing else.

Path C - rejected: a clean abort

Newest verdict is rejected - or the budget ran out - and the machine routes to abort. Notice abort is not terminal. It’s a deliberate non-terminal state that gives the driving agent one clean turn to be informed and stop: its templated objective hands the agent the entire history, every verdict and every note, and asks it to acknowledge.

ack is an enum with a single legal value, so “acknowledged” is enforced by the schema rather than by hoping the agent types the right token - a write of anything else is rejected at the context layer with the legal values in the error. That write fires the final edge to aborted, which is terminal. The machine doesn’t just vanish; it lets the agent hear the outcome and confirm, then stops.

context: { drafts: […], verdicts: [changes, changes, rejected], reasons: […], ack: acknowledged }
state:    aborted  (terminal)

The whole thing, one idea

Step back and look at what actually moved the machine, start to finish:

MomentWhoWhat happened in context
draftagentdrafts += attempt 1
draft → assign-reviewerguard1 > 0 - an unjudged draft exists
assign-reviewernobodyreviewer = brad, derived in CEL, no turn taken
review(park)nothing - waiting on a human
reviewhumanverdicts += choice, reasons += note
review → draftguard1 == 1, newest is changes, 1 < 3 - budget remains
draftagentdrafts += attempt 2, written against the full history
reviewhumanverdicts += approved
done-terminal, with every draft and every note still in the store

No requestTransition. No sign-off tool. No “agent decides it may move.” Every advance was a context write - by an agent, by a human, or by a CEL expression with nobody attached - that a pure guard reacted to. Elicitation was parking plus a modal. The reviewer was a template resolved at entry rather than a name fixed at authoring time. And reentrancy stopped costing anything: the loop re-armed by consuming one routing field, and kept every artifact it produced along the way.

That’s the reactive reducer pattern doing its job: the workflow is a structural system the LLM and the human both write into, and the machine is the only thing that moves.