Architecture 10 min read Published October 16, 2026 Updated Oct 16, 2026

Finite State Machines vs. Unbounded Agentic Loops: Building Deterministic AI Workflows

Why unbounded ReAct loops cannot guarantee termination, and how a finite state machine runtime with explicit transition tables bounds AI agent cost, time, and blast radius.

Alex Vance
Alex Vance
Principal Enterprise Systems Architect
Share
Diagram contrasting an unbounded ReAct agent loop with a finite state machine task lifecycle containing explicit guarded transitions and terminal states

Unbounded agentic looping versus an explicit FSM task lifecycle with guarded transitions.

The Failure Mode Nobody Benchmarks

Agent benchmarks measure whether an agent eventually succeeded. Very few measure whether it would have stopped.

That asymmetry is the source of most production incidents in agentic systems. A ReAct-style loop — reason, act, observe, repeat — is an elegant control structure with one property that enterprise buyers consistently underrate: nothing about the loop guarantees that it ends. The model emits a final answer when it decides it is done, and "decides" here means a sampling outcome over a probability distribution, conditioned on a context window that is itself being rewritten on every turn.

This is fine for a coding assistant answering a question. It is not fine for a system that executes payments, mutates customer records, or burns metered GPU time against a fixed budget.

An agent evaluation that measures success rate is silent about the only question a risk committee actually asks: what is the maximum loss this system can produce in a single run?

A finite state machine answers that question with a constant. An unbounded loop answers it with "unknown."


What "Deterministic" Actually Means in an Agent Platform

Determinism has nothing to do with making the model reproducible. A transformer sampled at non-zero temperature is, and will remain, stochastic. Prompt engineers who chase bit-identical outputs are solving the wrong problem.

Platform-level determinism is a narrower and far more useful claim:

  1. Closure. For a given current state and a given event, the set of legal next states is fixed, finite, and enumerable before deployment.
  2. Non-forgeability. Model output cannot add a state to that set. A tool call, a completion string, or a plausible-sounding summary is an event, never a transition.
  3. Totality of failure. An undefined transition is rejected loudly rather than absorbed silently into whatever state the process happened to be in.
  4. Permanence. Terminal states cannot be left. Ever.

Notice that none of these properties constrain what the model is allowed to say. They constrain what the runtime is allowed to do next. That distinction is what separates a governed platform from a script with an LLM call in the middle.


The Transition Table Is the Policy

The most valuable artifact in a deterministic agent runtime is not the prompt library. It is the transition table — and it belongs in version control, where it can be reviewed, diffed, and argued about in a pull request.

typescript
// src/lib/task-engine/state-machine.ts
export const VALID_TRANSITIONS: Record<TaskStatus, TaskStatus[]> = {
  PENDING:             ['QUEUED', 'CANCELLED'],
  QUEUED:              ['RESERVING_CREDITS', 'CANCELLED'],
  RESERVING_CREDITS:   ['RUNNING', 'INSUFFICIENT_CREDITS', 'FAILED'],
  RUNNING:             ['AWAITING_APPROVAL', 'COMPLETING', 'FAILED', 'TIMED_OUT', 'CANCELLED'],
  AWAITING_APPROVAL:   ['APPROVED', 'RUNNING', 'CANCELLED', 'TIMED_OUT'],
  APPROVED:            ['RUNNING', 'COMPLETED', 'FAILED'],
  PAUSED:              ['QUEUED', 'CANCELLED'],
  COMPLETING:          ['COMPLETED', 'FAILED'],
  INSUFFICIENT_CREDITS: [],
  COMPLETED:            [],
  FAILED:               [],
  CANCELLED:            [],
  TIMED_OUT:            [],
};

Two details in that table are doing most of the work.

The empty arrays. Every terminal state has an empty allow-list. There is no code path — no retry handler, no "resume anyway" admin action, no optimistic recovery routine — that can move a task out of COMPLETED. This sounds obvious until you consider what an agent that recovers from a completed payment run actually is: a double-spend machine. Retries belong to a new task with a new id and a new audit trail, not to a resurrected one.

The narrow fan-out of RUNNING. An executing task can move to exactly five states. It cannot skip to COMPLETED while credits are unsettled, and it cannot jump to INSUFFICIENT_CREDITS after work has begun. The table encodes invariants that would otherwise live in a comment nobody reads.

Enforcement is deliberately boring:

typescript
// Simplified from src/lib/task-engine/state-machine.ts
export function canTransition(from: TaskStatus, to: TaskStatus): boolean {
  if (from === to) return true;
  return (VALID_TRANSITIONS[from] || []).includes(to);
}

export async function transitionTask(taskId, toStatus, options = {}) {
  const task = await prisma.task.findUnique({ where: { id: taskId } });
  const fromStatus = task.status;

  if (!canTransition(fromStatus, toStatus)) {
    throw new BadRequestError(
      `Illegal task state transition: cannot move task ${taskId} from ${fromStatus} to ${toStatus}`
    );
  }

  // The state change and its evidence commit together or not at all.
  return prisma.$transaction(async (tx) => {
    const updated = await tx.task.update({ where: { id: taskId }, data: { status: toStatus } });
    await tx.taskStateTransition.create({
      data: { taskId, fromStatus, toStatus, reason: options.reason, triggeredBy: options.triggeredBy },
    });
    return updated;
  });
}

The from === to short-circuit is not cosmetic. Queue systems deliver at least once, so a duplicate approval webhook or a re-enqueued job will legitimately re-request the state it is already in. Treating that as an error would make the system fragile; treating it as success without an audit row would make it dishonest. The transaction writes both.

And when a transition is illegal, the caller gets an HTTP 400 naming both states. Failures of control flow should be loud. The dangerous version of this system is the one that silently coerces an unexpected transition and logs a warning.


The 13 States of a Governed Task

Here is the full lifecycle CadreGPT enforces, with the guards that gate each edge:

text
  PENDING ──▶ QUEUED ──▶ RESERVING_CREDITS ──▶ RUNNING ──▶ COMPLETING ──▶ COMPLETED
     │            │              │                  │
     │            │              └──▶ INSUFFICIENT_CREDITS  (terminal)
     │            │                                 │
     │            └──▶ CANCELLED                    ├──▶ AWAITING_APPROVAL ──▶ APPROVED ──▶ RUNNING
     │                (terminal)                    │            │
     │                                              │            └──▶ CANCELLED / TIMED_OUT
     └──▶ CANCELLED                                 ├──▶ FAILED     (terminal)
                                                    ├──▶ TIMED_OUT  (terminal)
                                                    └──▶ CANCELLED  (terminal)

Every edge above is a decision the runtime makes, not one the model makes. The model's influence is confined to a single question, asked once per turn: which tool should I call next? The state machine answers the far more consequential question: is there a next turn at all?

That inversion is the whole design. Autonomy inside the step; authority over the loop.

The thirteenth state, PAUSED, is reserved for operator-initiated holds and can only exit toward QUEUED or CANCELLED. It exists so that a human pausing a run is a modelled event with a defined resume path, rather than a worker quietly abandoned mid-turn.


Bounding the Inner ReAct Loop

A common misreading of deterministic orchestration is that it eliminates the reasoning loop. It does not — it budgets it. CadreGPT keeps a genuine multi-turn ReAct loop inside each agent invocation, then surrounds it with four independent stop conditions.

typescript
// Simplified from src/lib/worker/job-processor.ts
while (currentTurn < task.maxTurns && !isFinished) {
  // Guard 1: worker lifecycle — hand the turn back to the queue, never lose state
  if (isWorkerShuttingDown()) {
    return transitionTask(taskId, 'QUEUED', { reason: 'Graceful shutdown between turns' });
  }

  currentTurn++;

  // Guard 2: wall-clock and per-task deadline (default 600s sandbox limit)
  const wallClockExceeded = Date.now() - executionStartedAt > MAX_TASK_WALL_CLOCK_MS;
  const deadlineExceeded  = task.deadlineAt ? Date.now() > task.deadlineAt.getTime() : false;
  if (wallClockExceeded || deadlineExceeded) {
    return transitionTask(taskId, 'TIMED_OUT', { triggeredBy: 'guardrail:wall_clock' });
  }

  // Guard 3: cumulative cost ceiling (task ceiling ∩ BUDGET_CAP guardrail policy)
  if (totalCost.gte(effectiveCostCeiling)) {
    return transitionTask(taskId, 'TIMED_OUT', { triggeredBy: 'guardrail:cost_ceiling' });
  }

  // Guard 4: context pressure — trim the window, fail only this task at hard limits
  if (readMemoryPressure().level === 'HARD') { /* ... */ }

  // ... one model turn, one tool dispatch, one persisted step ...
}

if (!isFinished) {
  return transitionTask(taskId, 'TIMED_OUT', { reason: 'Turn budget exhausted' });
}

Note what each guard protects against, because they are not redundant:

  • Turn budget (maxTurns, default 10) bounds reasoning depth.
  • Cost ceiling bounds money, and it is enforced against the smaller of the task's own ceiling and any workspace-level budget cap policy.
  • Wall clock and deadlineAt bound time — a task that stays cheap but hangs on a slow upstream API still gets stopped.
  • Graceful shutdown is the inverse case: instead of a guard that stops the run, it is a transition that preserves it, so a deploy never costs you a completed turn.

The last line matters most. The loop can exit for reasons that are not success, and when it does, the task lands in TIMED_OUT — not COMPLETED, not "probably fine." A run that ran out of fuel is reported as a run that ran out of fuel.


The Hidden Quadratic: Why Unbounded Loops Cost More Than They Look

Turn limits feel like an arbitrary operational nicety until you price them.

Each turn of a ReAct loop re-transmits the conversation so far. If the initial context is S tokens and every turn appends roughly k tokens of tool output and reasoning, the input volume at turn t is I(t) = S + k(t - 1). Total input across T turns is therefore an arithmetic series:

text
Turns:      1        2          3                 ...   T
Input:      S      S + k      S + 2k              ...   S + k(T-1)
───────────────────────────────────────────────────────────────
Total  =  T·S  +  k·(0 + 1 + 2 + ... + (T-1))
       =  T·S  +  k·T(T-1)/2            →  Θ(T²)

So a loop that runs ten times longer does not cost ten times more. Dominated by the quadratic term, it costs on the order of a hundred times more. This is why runaway agents produce invoices that look like accounting errors.

Bounding is the fix, and it is worth stating precisely. In an unconstrained loop the turn count T is a random variable with a heavy tail: P(T > n) decays geometrically but never reaches zero, so expected cost is unbounded whenever the per-turn cost is positive and the loop has no hard cap. Cap it at Tmax and the loss distribution is truncated:

text
max cost  =  Tmax  ×  max input volume at Tmax  ×  unit price

which is a number you can put in a risk register before the system ever runs. That transformation — from an unbounded distribution to a computable constant — is the entire commercial argument for FSM orchestration at enterprise scale. The companion piece on two-phase credit metering covers how the money side is reserved and settled; the turn cap is what stops the loop from ever needing a disputed invoice.


Finite State Machine vs. Unbounded ReAct: Head to Head

Architectural VectorUnbounded ReAct LoopPrompt-Guarded LoopDeterministic FSM Runtime
Termination proofNone — model-decidedStatistical, degrades with context lengthEnforced by transition table and budgets
Maximum single-run lossUnboundedLoosely boundedComputable constant per task
Failure surfacingSilent, plausible-sounding outputRetry stormsIllegal transitions rejected with a named error
State inspectabilityReconstructed from logsPartialExplicit rows plus an append-only transition log
Crash recoveryLost mid-loopPartial replayResumes from persisted steps at the next legal transition
Retry semanticsUnbounded and implicitAttempt counter in promptDeclared budget; terminal states never re-entered
Human review insertionAdded as an afterthoughtBolt-on UI modalA state in the machine with its own expiry policy
Audit evidenceConversational transcriptMixed sourcesState change and evidence commit in one transaction

Where FSMs Fall Short and Where the Hybrid Wins

Honesty matters more than advocacy here. A strict FSM is a poor fit for genuinely open-ended exploration: deep crawls of unknown breadth, research whose length cannot be predicted, debugging that may need an unbounded number of attempts. Forcing those into a rigid graph produces a machine with one enormous "do research" state — which is just an unbounded loop wearing a state name.

The workable pattern is an envelope, not a cage:

  • The outer graph is deterministic. Node types are fixed and finite: TRANSFORMER, AGENT_INVOKE, CONDITIONAL_GATE, PARALLEL_FAN_OUT, PARALLEL_FAN_IN, HUMAN_APPROVAL, DELAY, WEBHOOK_CALLBACK. Edges are declared; cycles are explicit and bounded.
  • Each AGENT_INVOKE node owns a budgeted ReAct loop. All the reasoning flexibility stays; only the bound is imposed.
  • The journal is the database. Every node writes a step keyed by node id and carries its continuation data — child task id, approval id, start anchor. A tick can be abandoned at any instant and the next tick rebuilds progress from rows, not from a process heap.
  • Waiting is a first-class outcome, not a blocked thread. A parent yields its queue slot and re-enqueues rather than holding a worker hostage, and child agent tasks run on a separate queue so a waiting parent can never starve the children it is waiting for.
  • Unimplemented node types fail loudly. A runtime that reports "Agent X executed successfully" for a node it does not know how to execute is worse than one that crashes, because the failure is now in the audit trail as a success.

Multi-agent topologies fall out of the same machinery: a supervisor is a CONDITIONAL_GATE; a fan-out is PARALLEL_FAN_OUT; a join is PARALLEL_FAN_IN; and a review step is HUMAN_APPROVAL — a node, not an exception handler. The visual workflow canvas exposes that envelope directly: you draw the nodes and edges, and the runtime supplies the guards, the journal, and the terminal states. The human-in-the-loop governance deep dive covers the approval semantics in detail.


Production Checklist for Deterministic Agent Workflows

  1. Put the transition table in source control. If it lives in a workflow database edited through a UI, you have configuration, not a guarantee. Review changes to it the way you review changes to authorization middleware.
  2. Give every terminal state an empty allow-list. Then audit for any admin path, retry handler, or recovery job that writes status directly instead of calling the transition function. One bypass collapses the whole model.
  3. Bound time and money separately. Cheap-but-hanging tasks and fast-but-expensive tasks are different failure modes; a single guard always misses one of them.
  4. Persist the step before you yield. Any pause — approval, capacity wait, deploy, shutdown — must be reconstructible from the database alone. Test it by killing the worker mid-run.
  5. Make the halting reason part of the output. TIMED_OUT with guardrail:cost_ceiling tells an operator what to change. A generic FAILED tells them to open a ticket.
  6. Distinguish "the model said it was done" from "the runtime verified it was done." Only the second one should ever move a task to a terminal success state.

Summary & Next Steps

The debate between deterministic orchestration and autonomous looping is usually framed as a trade-off between flexibility and control. It is really a question of where you locate the bound. An unbounded loop locates it nowhere and hopes the model behaves; a finite state machine locates it in a table that a reviewer can read in thirty seconds and a risk committee can sign off on.

The practical recipe is small: draw the states, declare the transitions, empty the terminal allow-lists, budget the inner loop on four independent axes, and persist every step so that pausing is cheap. What remains is genuinely autonomous — the model still picks tools, composes reasoning, and adapts to what it observes. What disappears is the failure mode where nobody can say in advance how the run ends.

Continue the series with human-in-the-loop approval gates, which turns the AWAITING_APPROVAL state into a working review surface, and with two-phase financial invariance, which makes per-task cost ceilings enforceable rather than aspirational.

Alex Vance

Alex Vance

Verified Enterprise Author

Principal Enterprise Systems Architect

Systems architect specializing in distributed consensus, fault-tolerant orchestration, and deterministic AI agent governance at enterprise scale.

Frequently Asked Questions

No — an FSM constrains control flow, not content. The model still reasons freely inside a state, chooses tools, and composes text. What the state machine decides is whether there is a legal next state at all. CadreGPT keeps a bounded ReAct loop inside each AGENT_INVOKE state, so autonomy lives inside the step and the graph governs between steps.
Related Topics:#State Machines#Agentic Architecture#Deterministic Workflows#ReAct Loops#Enterprise AI#Orchestration

Recommended Architectural Deep Dives

Governance

Human-in-the-Loop AI Governance: Architecting Deterministic Approval Gates in Enterprise Agents

How to architect deterministic human review gates, SSE approval drawers, BullMQ Redis locking, and state-machine invariance in enterprise AI agent platforms.

8 min readRead Deep Dive
Engineering

Financial Invariance in Autonomous AI: Architecting Two-Phase Token & Credit Metering

How CadreGPT eliminates runaway AI compute bills using two-phase credit reservations, SERIALIZABLE isolation, and real-time token telemetry.

7 min readRead Deep Dive