Binding LLMs to Finite State Machines Using the Reactive Reducer Pattern
A little background
Picture this: you’ve built a workflow. A code change gets drafted, a reviewer agent looks it over, a human signs off, and it ships. You’ve got LLM agents that can do each of those steps brilliantly in isolation. So you wire them up and call it a day.
And then production happens.
The drafting agent “helpfully” re-drafts while the reviewer is still reading. The reviewer approves, but the machine is already three states past that. A human sign-off sits in a queue for two days while the whole run lives in volatile memory, waiting to be forgotten by a process restart. You end up writing a mountain of glue code whose only job is to make the agents behave - reminding them where they are, what they’re allowed to do, and when to stop.
That glue code becomes the actual product. And it’s where everything breaks.
Here’s the thing: the problem was never the LLMs. The problem is that we were asking a non-deterministic actor to be a deterministic control structure. An LLM is a fantastic contributor. It is a terrible state machine.
The core idea: nobody asks the machine to move
The fix I’ve been building in Aegis is a design pattern I call a reactive reducer. The single idea to internalize before anything else:
Context is the single source of truth. Actors (agents, humans, tools, the system) only ever contribute to context. Transitions are pure, guarded conditions over context, re-evaluated after every context change - and the machine advances itself.
There is no requestTransition call. No sign-off tool. No “agent asks to advance” button. An agent’s choice is itself a context write that a guard routes on. The agent stages a draft; the reducer notices; the machine moves. No one ever pokes the machine directly.
This is the xstate/Redux lineage, and it exists to eliminate a whole failure class: the agent that won’t or forgets to poke a gated edge, or the one that flails deciding whether it may transition. The question “am I allowed to move?” is replaced by “what did I just contribute?” - and the machine answers that for everyone.
Three consequences fall out of this, and they’re worth calling out explicitly:
- Legality is structural, not evaluated. The reducer only ever fires declared edges. If an edge isn’t in the YAML, it can’t happen - there’s no fire-time legality check to author, test, or bypass.
- The reducer is pure. It computes eligibility, fires declared edges, and runs actions. It never touches the bus and never blocks. Elicitation (the human modal, the agent summon) is reported out as pending work, and a human’s answer re-enters as an ordinary context write. That purity is what lets the identical core lower to headless targets (Mutiny, Flink) - only the effect sink and the async-resume transport differ.
- Parking is a first-class state. A state waiting on a human or a delegate agent doesn’t spin, time out, or guess. It parks, reports what it’s waiting on, and a later contribution re-enters as a clean write plus a re-run.
The machine: a microstep loop
The engine is FsmReducer.advance. On every trigger - a state entry, or any context mutation - it runs to a stable configuration:
- Eligibility - the current state’s edges are eligible only when all of its required
requiresare satisfied: the namedcontextfield is present and everyvalidatepredicate passes. Not satisfied → the machine parks (waiting for a contribution). - Routing - among the eligible edges, the first one in declaration order whose guards all pass fires. An edge with no guards is unconditional. No edge passes → park.
- Fire - run the source state’s
exitactions, then the edge’sonTransitionactions, then move to the target, then run the target’sentryactions. Then repeat from step 1 in the new state. - Stop - when a state parks, a terminal state is reached, the microstep cap (128) is hit, or the machine re-enters a state with unchanged context (the no-progress guard).
Here’s the loop in a nutshell - simplified, but faithful to the essential shape:
// FsmReducer.advance - the essential loop
Advance advance(FsmDefinition fsm, FsmInstanceState state) {
List<FsmTransition> fired = new ArrayList<>();
for (int steps = 0; steps < MICROSTEP_CAP; steps++) {
FsmState current = state.current();
if (current.isTerminal()) break;
// 1. Eligibility: all required requirements satisfied?
if (!requirementsSatisfied(current, state.context())) {
return park(current, state); // report pending elicitations
}
// 2. Routing: first declared edge whose guards all pass
FsmTransition edge = current.transitions().stream()
.filter(e -> guardsPass(e.guards(), state))
.findFirst()
.orElse(null);
if (edge == null) {
return park(current, state);
}
// 3. Fire: exit → onTransition → move → entry
runActions(current.exit());
runActions(edge.onTransition());
state = state.transitioned(edge.toState());
fired.add(edge);
runActions(fsm.state(edge.toState()).entry());
// 4. No-progress guard: re-entered with unchanged context → stop
if (state.sameConfigAsPrevious()) break;
}
return new Advance(fired, state.current(), state.pendingElicitations());
}
Notice what’s not in that loop: no switch on agent identity, no “who’s turn is it”, no waiting, no I/O. The two backstops - the 128-microstep cap and the no-progress guard - mean a badly authored loop is a config bug you see immediately, not a runaway process. Oscillation is caught, not suffered.
The DSL: a workflow as data
An Aegis FSM is a YAML file under .aegis/fsms/, wrapped in a top-level fsm: key. Everything is data - states, contracts, routing, effects, policy. Here’s the live example I keep coming back to, a two-agent review workflow:
fsm:
name: delegated-review
version: 1
initialState: draft
context: # typed schema - the single source of truth
draft: { type: string }
verdict: { type: enum, values: [approved, changes, rejected] }
agents:
envoy: { canTransition: [draft], guidance: full }
reviewer: { canTransition: [review], guidance: full }
states:
draft:
description: >-
Draft the work product, then STAGE it so the reviewer can see it:
updateContext(field=draft, value=<the full draft>).
requires:
- { name: draft, from: agent }
transitions:
- { toState: review }
review:
description: "Awaiting the reviewer's verdict (approved / changes / rejected)."
requires:
- { name: verdict, from: { agent: reviewer } } # parks; summons reviewer
transitions:
- { toState: done, guards: [{ backend: cel, expression: "context.verdict == 'approved'" }] }
- toState: draft
guards: [{ backend: cel, expression: "context.verdict == 'changes'" }]
onTransition:
- { assign: { verdict: "", draft: "" } } # consume so the loop re-parks
- { toState: rejected, guards: [{ backend: cel, expression: "context.verdict == 'rejected'" }] }
done: { terminal: true }
rejected: { terminal: true }
policy:
maxExchangesPerStage: 30
maxExchangesPerAgentPerStage: 15
checkpointEvery: 5
A few things to notice:
-
There is no
owneroractorfield. Ownership is derived: if all of a state’srequiresarefrom: agentit’s a work state; if any requirement comes from a{user}or another{agent}, the state parks and waits. The machine infers who it’s waiting for from the data. -
requiresis the I/O contract. Each requirement names acontextfield and who contributes it. Thefromselector is the only “new” concept in the DSL - and it’s data, not logic:YAML form Meaning from: agent(or omitted)the driving agent produces it in its own turn from: { user: brad }human-in-the-loop - raised as a modal; parks the machine from: { agent: reviewer }a peer agent is summoned (A2A); parks the machine from: { tool: webSearch }a named tool node produces it automatically from: systemproduced automatically by the system -
Guards are plain CEL over
context. No bespoke condition syntax, nowhen:keyword, no scalarguard:. Routing is a guard, and a guard is a pure predicate - no wall-clock, no randomness, no I/O. -
Effects live only in actions (
entry/exit/onTransition):assign(literal context writes),emit,send,spawn(child FSM),inject(a template as a system message),restrict(tool restrictions). A guard may never cause an effect. That separation is the whole ballgame - routing stays inspectable and side effects stay in one auditable place.
A worked example: delegated review
Let’s trace the run above, because the parking behavior is where the pattern earns its keep:
- Envoy enters
draft. Its guidance says the objective, and the state’s contract says it must producecontext.draft. Envoy writes it:updateContext(field=draft, value=...). The reducer re-runs:draftis satisfied, the unconditional edge fires, and the machine rests inreview. reviewparks. The requirement isfrom: { agent: reviewer }- an interactive source the envoy can’t satisfy. The loop stops and reports the pending elicitation; the runtime summons the reviewer over A2A. No one is polling. No one is asking.- The reviewer contributes.
updateContext(field=verdict, value="approved"). The reducer re-runs: theapprovedguard fires, the machine lands indone- terminal. The run is journaled, and ifpolicy.onTerminalsays so, claim-checked blobs get purged.
And the rework loop is where the “consume the trigger” trick shows up. When the reviewer says changes, the edge back to draft clears verdict and draft in an onTransition assign:
- toState: draft
guards: [{ backend: cel, expression: "context.verdict == 'changes'" }]
onTransition:
- { assign: { verdict: "", draft: "" } }
Because the machine runs to stable, leaving verdict == 'changes' set would immediately re-fire the same edge. Clearing the triggering context in an action is how you get “fire once when X happened” semantics - and it’s the difference between a rework loop and an infinite one. (The no-progress guard is your backstop if you get this wrong.)
One authoring pitfall worth knowing: an agent’s canTransition must list every agent-driven state it acts in, not just the first. If draft, make-changes, and new-draft are all from: agent states, the driving agent needs canTransition: [draft, make-changes, new-draft]. Omit a reentrant state and no agent is authorized there - a permanent stall. The runtime now yells at you when this happens, but you should author it right the first time.
Guards: routing is data
Guards are evaluated by the general guardrail engine at the TRANSITION site - the same engine that gates tool calls and delegation, not a bespoke FSM syntax. That buys you two things:
Joins are just predicates. Don’t invent an “N-of-M” primitive or a count field. A join is a guard over has(...). Want “(A and B) or C”? That’s it:
- toState: done
guards:
- { backend: cel, expression: "(has(context.a) && has(context.b)) || has(context.c)" }
To let the guard be the sole join - rather than double-gated by the built-in all-required AND - mark the fan-out requirements required: false. They’re still elicited on entry; they just don’t block edge eligibility, so the guard alone decides. That’s exactly how a panel-review workflow works: three reviewer agents plus a human sign-off, all required: false, joined by (security && perf && human) || legal_override.
Failure is hazard-free. A guard that throws is treated as not passing - the edge simply doesn’t fire. Never a crash, never a half-applied transition. The reactive loop stays hazard-free by construction.
What the agent actually sees
This is the part people underestimate: the agent doesn’t hold the workflow in its head. It reads the store.
Every turn, the runtime injects a CONTEXT (workflow data set so far): block listing every set field and its value, alongside the current state’s objective and tool steering. So an agent re-engaged in a later state can read what a human or an earlier state put in context - a review’s change_request_reason, a prior draft, a verdict - without you wiring any of it:
CONTEXT (workflow data set so far):
draft: "Add retry with backoff to the webhook client…"
verdict: changes
change_request_reason: "Backoff should be exponential, not fixed"
And a state’s description or a requirement’s prompt can embed live values with {{ context.<field> }} templating, so the objective for a rework state can literally read: Changes were requested: ”{{ context.change_request_reason }}”. Produce an updated draft.
The agent’s job shrinks to the one thing LLMs are good at: produce a value, well, for the current objective. Everything else - routing, waiting, legality, memory - is the machine’s job.
Durability: parking, envelopes, and surviving restarts
A workflow that parks on a human can wait days. It cannot live only in volatile memory. This is where the reactive-reducer design pays off architecturally, because the serializable core is deliberately small.
Every instance’s portable state - current state, iteration counter, guard-relevant context, and transition history - lives behind a single immutable Envelope, updated copy-on-write (a version-CAS on an AtomicReference), so concurrent updates compose without a lock. The envelope carries only what’s portable across every compile target: no live agent references, no bus handles, no sockets. Those get reconstructed per target.
Three durability mechanisms ride on that core:
- Claim-checking. A context field declared
blob/refgets offloaded to aPayloadStoreand carried in the envelope as a smallClaimRef(store, key, checksum, TTL). Full drafts and files never bloat the envelope; guards see the reference, and the value is resolved at the point of use. - Run journaling. Every run is journaled to disk - the latest envelope snapshot plus an append-only causal event trace (
created,context,transition,elicitation,response,terminal). Runs are not deleted at session cleanup; they’re the durable record, inspectable and replayable for evals. - The park store. A record parked on a human or external delegate is persisted as
(interventionId, Envelope, pending elicitation). A resume - or a process restart followed by a resume - rehydrates every parked instance and re-issues the elicitation. RPC-free, no external workflow service, no “where did that run go?”.
The authoring consequence is a clean rule: if a value must be durable and inspectable, it belongs in context (the envelope). Session-scoped counters - exchange budgets, drift, corrections - are re-derived and never need to survive.
The outcome
What does this buy you, concretely?
- The LLM stops being the control structure. It’s a contributor with a contract. The failure modes you fight are “the agent produced a bad value” - catchable with
validatepredicates - instead of “the agent did a thing that wasn’t in the plan”, which is uncatchable. - The workflow is reviewable data. A YAML file in git. Scannable diffs. The routing logic is a handful of CEL expressions, not a tangle of if-statements across five services.
- Legality is structural. Undeclared edges can’t happen. There’s no fire-time permission check to get wrong.
- One core, many targets. The same pure reducer runs interactively (with HIL modals and A2A summons) and lowers to headless runtimes - only the effect sink and the resume transport change.
- Durable by construction. Parked runs survive restarts. Every run is journaled. Big values are claim-checked. The two-day human wait is a feature, not a bug.
I’ve been running this pattern against real multi-agent workflows - drafting, delegated review, panel sign-off - and the glue code is gone. What’s left is a reducer, a YAML file, and agents doing the one job they’re good at.
Until next time…

If you’re binding LLMs to workflows, consider the reactive reducer pattern. Make context the single source of truth. Let actors contribute values, never request moves. Keep guards pure and effects in one place. And make durability a property of the core, not a feature you bolt on.
And remember: sometimes the best way to control a non-deterministic system isn’t to give it more control - it’s to take the control away entirely, and let it write to the one place that matters.
Stay curious, keep building, and I’ll catch you in the next one.