All writing

Agent State Machines: Stop Runaway Agents With Explicit States

Agents go rogue not because the model is dumb, but because nobody defined what "done" looks like. Teams spend weeks adding guardrails, retry logic, and prompt patches on top of a fundamentally stateless orchestrator — only to rediscover the same runaway loop in a new disguise two sprints later. The fix isn't more prompting. It's state machines.

This is the architecture pattern I reach for when an agentic system needs to be predictable enough to run unsupervised in production.

Why Agents Cascade Without Explicit State

Most agentic frameworks today — LangGraph, CrewAI, AutoGen, custom orchestrators — let you wire nodes together and call it a workflow. But a graph of nodes isn't a state machine. A state machine has explicit states, defined legal transitions between them, and terminal states from which no further action can be taken. A node graph has none of that by default.

The result: an agent that retrieves data, calls a tool, gets an ambiguous response, decides to retry, hits a rate limit, decides to escalate, calls a different tool to handle the escalation, and ends up doing three times the work with no defined stopping condition. By the time your telemetry catches it, you've burned tokens, possibly taken irreversible actions on external APIs, and produced output nobody trusts.

This is categorically different from a single LLM call going wrong. The failure mode is emergent — it arises from legal individual steps combining into an illegal aggregate behavior. State machines prevent that class of failure at the design level, not the monitoring level.

The Four States Every Production Agent Needs

Before you reach for a framework, define the lifecycle explicitly. Every agent — regardless of domain — should be expressible in terms of four mandatory states:

StateMeaningTerminal?
IDLEWaiting for a trigger or inputNo
RUNNINGActively executing a step or subtaskNo
SUSPENDEDPaused pending human review or external signalNo
TERMINATEDReached an end condition (success, failure, or timeout)Yes

Anything beyond these four is domain-specific enrichment — WAITING_FOR_TOOL, AWAITING_APPROVAL, RETRYING — and should be modeled as substates of RUNNING or SUSPENDED, not new top-level states. Keep the top-level graph flat enough to reason about at a glance.

The critical discipline: every state transition must be explicit and logged. Not inferred from model output. Not derived from whether a tool call returned an error. Explicitly coded.

Building the State Machine: A Concrete Pattern

Here's the minimal Python skeleton I use when wiring a new agent. It's not tied to any framework — it's just a class that owns its own state.

python
from enum import Enum, auto
from typing import Callable
import logging

class AgentState(Enum):
    IDLE = auto()
    RUNNING = auto()
    SUSPENDED = auto()
    TERMINATED = auto()

class IllegalTransitionError(Exception):
    pass

class StatefulAgent:
    LEGAL_TRANSITIONS = {
        AgentState.IDLE:       {AgentState.RUNNING, AgentState.TERMINATED},
        AgentState.RUNNING:    {AgentState.SUSPENDED, AgentState.TERMINATED},
        AgentState.SUSPENDED:  {AgentState.RUNNING, AgentState.TERMINATED},
        AgentState.TERMINATED: set(),  # no exit from terminal
    }

    def __init__(self, name: str, max_steps: int = 20):
        self.name = name
        self.state = AgentState.IDLE
        self.step_count = 0
        self.max_steps = max_steps
        self._history: list[tuple[AgentState, AgentState, str]] = []

    def transition(self, target: AgentState, reason: str) -> None:
        if target not in self.LEGAL_TRANSITIONS[self.state]:
            raise IllegalTransitionError(
                f"{self.name}: {self.state}{target} is not a legal transition"
            )
        logging.info(f"[{self.name}] {self.state}{target} | {reason}")
        self._history.append((self.state, target, reason))
        self.state = target

    def tick(self, step_fn: Callable) -> None:
        """Advance the agent by one step. Safe to call in a loop."""
        if self.state == AgentState.TERMINATED:
            return
        if self.state == AgentState.SUSPENDED:
            # Suspended agents wait for an external trigger, not a tick.
            logging.debug(f"[{self.name}] Tick ignored — agent is SUSPENDED")
            return
        self.step_count += 1
        if self.step_count > self.max_steps:
            self.transition(AgentState.TERMINATED, "max_steps_exceeded")
            return
        self.transition(AgentState.RUNNING, f"step_{self.step_count}")
        try:
            step_fn(self)
        except IllegalTransitionError:
            # Re-raise: the caller needs to know the agent tried an illegal move.
            raise
        except Exception as exc:
            # Any unexpected error terminates the agent rather than leaving
            # it stuck in RUNNING with no defined next state.
            logging.exception(f"[{self.name}] Unhandled error in step_fn: {exc}")
            self.transition(AgentState.TERMINATED, f"unhandled_error: {type(exc).__name__}")

A few things worth noting in this skeleton. LEGAL_TRANSITIONS is a pure data structure — not logic, not conditions, not if-statements. You read it and immediately know what the machine can do. TERMINATED has an empty transition set, which means once you reach it, the agent is provably done — no re-entry possible without instantiating a new agent. max_steps is a hard ceiling, not a soft prompt instruction telling the model to "stop after 20 steps." Prompts lie. Counters don't. And the tick() error handler is deliberate: an unhandled exception drives the agent to TERMINATED rather than leaving it wedged in RUNNING with no defined exit path — which is exactly the failure mode we're designing against.

The Three Transition Triggers That Cause Most Runaway Behavior

Once you've got the skeleton, the question is: what actually calls transition()? This is where most teams get it wrong. They write transition logic that's entangled with tool responses, model output parsing, and exception handlers all at once. Keep these three trigger categories cleanly separated:

1. Deterministic triggers — step count, elapsed wall-clock time, token budget exhaustion. These are non-negotiable hard stops. Wire them at the orchestrator level, outside the agent's own step function, so the agent cannot override them.

2. Model-derived triggers — the LLM returns a structured signal (e.g., {"action": "escalate", "reason": "ambiguous_input"}). The critical rule: validate this signal against your state machine before acting on it. A model that hallucinates "action": "completed" mid-task should hit an IllegalTransitionError, not silently succeed. If you're using tool calling, your tool schema should include an explicit finish tool — not a convention in the prompt, but an actual callable that moves the agent to TERMINATED.

3. External triggers — webhook callbacks, human approvals, downstream API responses. These land in SUSPENDED agents and call transition(RUNNING, ...) only after the external condition is met. Never poll for these inside the agent's step loop — that's where you get the nested retry spirals.

State Machines in Multi-Agent Systems: The Parent-Child Contract

When you have multiple agents — a planner, several workers, a critic — each agent owns its own state machine. The parent (orchestrator) does not reach into a child's state. It sends a message and waits for a terminal event: either TERMINATED with a result payload, or TERMINATED with an error payload. This is the contract.

Imagine a travel platform where a booking agent spawns three sub-agents: one for flights, one for hotels, one for ground transport. The booking agent transitions to SUSPENDED after spawning. When all three sub-agents reach TERMINATED, the booking agent receives their result events and transitions back to RUNNING to consolidate. If any sub-agent hits max_steps_exceeded or returns an error payload, the booking agent doesn't retry inline — it transitions to TERMINATED with a partial failure code that the caller can handle.

This pattern enforces two critical properties: isolation (a runaway sub-agent can't drag the parent into an infinite loop) and auditability (every agent's full transition history is self-contained and loggable). That auditability is what feeds your observability layer — a topic that deserves its own deep-dive on instrumentation, trace correlation, and alerting thresholds for non-success terminal rates.

The Decision Rule: When to Use a Full State Machine vs. a Simple Loop

Not every agent needs this. Over-engineering a one-shot summarization agent with a full state machine is waste. Use this framework when any two or more of these are true:

  • The agent takes actions with external side effects (API writes, database mutations, emails sent)
  • The agent can loop or retry
  • The agent can spawn sub-agents or delegate to tools that have their own latency/failure modes
  • Human approval is part of the workflow
  • The agent runs unattended on a schedule
  • Compliance or audit trails are required

If only one of these is true, a simple for step in range(max_steps) loop with a break condition is probably fine. The moment a second condition appears, formalize the state machine. The cost is two hours of design time. The cost of not doing it is discovering a production incident from your telemetry at 2am.

The Contrarian Take: Your Agent Framework Is Hiding This Problem From You

LangGraph gives you nodes and edges. AutoGen gives you conversations. CrewAI gives you roles. None of them give you explicit terminal states enforced at the framework level — they give you mechanisms for you to build that yourself, and most teams don't. The framework's abstraction is seductive because it feels complete. It isn't. The state machine is the layer the frameworks assume you'll add and almost nobody does.

An agent without explicit terminal states is just a while loop with extra steps. That sentence should be on a poster in every AI engineering team's war room.

What to Actually Do

  1. Audit your current agents for terminal states. If you can't point to the exact line of code that makes an agent stop — not a prompt instruction, actual code — fix that first.

  2. Define LEGAL_TRANSITIONS before you write step logic. The transition map is your specification. If your team can't agree on it in under 30 minutes, the agent's scope is too broad.

  3. Add a hard-coded max_steps ceiling at the orchestrator level, separate from any model-side instruction. Start at 20 for complex agents, 10 for workers. Tune up from data, never assume high is safer.

  4. Make finish an explicit tool in your tool schema with a required reason enum (success, user_requested_stop, max_retries_exceeded, ambiguous_input). Log it. Alert on high rates of non-success terminals.

  5. In multi-agent systems, enforce the parent-child contract in code. A parent agent should only read TERMINATED events from children — never inspect a child's internal state mid-execution.

Runaway agents are an architecture debt. The bill comes due in production, not in demos.

Working on something like this? I take on a few fractional-CTO and AI engagements at a time.

The AI CTO playbook

Get my AI playbooks — straight to your inbox

Practical notes on shipping production AI, scaling teams, and the calls a CTO actually has to make. A few times a month. No spam, no fluff.