Skip to main content
AI Agent State Machines: No More Infinite Loops

AI Agent State Machines: No More Infinite Loops

Best Practices
7 min readBy Daily Miranda Pardo

You build the agent. It works locally. Three days after deploying to production, someone tells you it's been looping through the same reasoning step 40 times.

That's not a model problem. That's a design problem.

AI agents without explicit state are while-loops that nobody stops. In production, the thing that finally stops them is usually a request timeout, a token limit, or an unexpectedly large API bill.

The anti-pattern: the loop nobody can stop

The most common agent implementation looks roughly like this:

while (!done) {
  const response = await llm.complete(messages);
  if (response.toolCalls) {
    const results = await executeTools(response.toolCalls);
    messages.push(...results);
  } else {
    done = true;
  }
}

This code has a fundamental problem: there are no states, only boolean conditions. The agent has no idea what phase of the task it's in. It doesn't know if it's gathering context, reasoning about a decision, or executing a tool. It only knows whether it's done or not.

In production, the consequences are predictable:

  • The agent calls the same tool three times because there's no record that it already ran that step
  • When a tool fails, there's no way to resume from where it was — you start over from scratch
  • Human-in-the-loop is bolted on as an if inside the loop, with no record of which state paused execution
  • Logs are a flat list of actions with no structure — debugging becomes impossible

What a state machine is and why agents need one

A state machine is a model with a finite set of states and explicit transitions between them. The system can only be in one state at a time, and transitions happen through defined events.

For an AI agent, the natural states are:

StateWhat the agent does
idleWaiting for activation
collectingGathering context before reasoning
reasoningAnalyzing context and deciding what to do
actingExecuting a tool or external call
waiting_humanPaused, waiting for human approval
errorHandling a failure with a defined strategy
doneTask completed

With this model, each loop iteration isn't "am I done?", it's "what state am I in and what event moves me to the next one?"

AI agent state machine: states and transitions diagram in TypeScript — DAILYMP

TypeScript implementation: explicit state and typed transitions

No XState or external libraries required. The pattern works cleanly in plain TypeScript:

type AgentState =
  | 'idle'
  | 'collecting'
  | 'reasoning'
  | 'acting'
  | 'waiting_human'
  | 'error'
  | 'done';

type AgentEvent =
  | { type: 'START'; input: string }
  | { type: 'CONTEXT_READY'; context: Context }
  | { type: 'TOOL_CALL'; tool: string; args: unknown }
  | { type: 'TOOL_RESULT'; result: unknown }
  | { type: 'NEEDS_HUMAN'; question: string }
  | { type: 'HUMAN_APPROVED'; decision: string }
  | { type: 'COMPLETE'; output: string }
  | { type: 'ERROR'; reason: string }
  | { type: 'RETRY' };

function transition(state: AgentState, event: AgentEvent): AgentState {
  switch (state) {
    case 'idle':
      if (event.type === 'START') return 'collecting';
      break;
    case 'collecting':
      if (event.type === 'CONTEXT_READY') return 'reasoning';
      break;
    case 'reasoning':
      if (event.type === 'TOOL_CALL') return 'acting';
      if (event.type === 'NEEDS_HUMAN') return 'waiting_human';
      if (event.type === 'COMPLETE') return 'done';
      break;
    case 'acting':
      if (event.type === 'TOOL_RESULT') return 'reasoning';
      if (event.type === 'ERROR') return 'error';
      break;
    case 'waiting_human':
      if (event.type === 'HUMAN_APPROVED') return 'reasoning';
      break;
    case 'error':
      if (event.type === 'RETRY') return 'collecting';
      if (event.type === 'NEEDS_HUMAN') return 'waiting_human';
      break;
    case 'done':
      break;
  }
  throw new Error(`Invalid transition: ${state} + ${event.type}`);
}

The AgentEvent discriminated union means TypeScript rejects any transition you haven't defined at compile time. No runtime surprises.

The main loop: now with history

With the state machine, the agent loop changes shape entirely:

async function runAgent(input: string) {
  let state: AgentState = 'idle';
  const history: Array<{ state: AgentState; event: AgentEvent; ts: number }> = [];

  function emit(event: AgentEvent) {
    const next = transition(state, event);
    history.push({ state, event, ts: Date.now() });
    state = next;
    return next;
  }

  emit({ type: 'START', input });

  while (state !== 'done' && state !== 'waiting_human') {
    if (state === 'collecting') {
      const context = await gatherContext(input);
      emit({ type: 'CONTEXT_READY', context });
    }

    if (state === 'reasoning') {
      const decision = await llm.reason(context, history);
      if (decision.needsHuman) {
        emit({ type: 'NEEDS_HUMAN', question: decision.question });
      } else if (decision.toolCall) {
        emit({ type: 'TOOL_CALL', tool: decision.toolCall.name, args: decision.toolCall.args });
      } else {
        emit({ type: 'COMPLETE', output: decision.output });
      }
    }

    if (state === 'acting') {
      try {
        const result = await executeTool(/* ... */);
        emit({ type: 'TOOL_RESULT', result });
      } catch (err) {
        emit({ type: 'ERROR', reason: String(err) });
      }
    }

    if (state === 'error') {
      const canRetry = history.filter(h => h.event.type === 'RETRY').length < 3;
      if (canRetry) {
        emit({ type: 'RETRY' });
      } else {
        emit({ type: 'NEEDS_HUMAN', question: 'Max retries reached' });
      }
    }
  }

  return { state, history };
}

Notice the most important change: history is the source of truth. If the agent pauses to wait for human approval, you can serialize state + history to the database, then resume exactly where it stopped when the human responds — no lost context, no starting over.

Human-in-the-loop as a first-class citizen

With the state machine, human-in-the-loop stops being an if (isAmbiguous) askUser() shoehorned into the main loop.

The waiting_human state is as legitimate as acting. When the agent reaches it:

  1. The full state is persisted (database, Redis, whatever you're using)
  2. A notification is sent to the human with the specific question
  3. The agent session closes cleanly
  4. When the human responds, a new session loads the persisted state and emits HUMAN_APPROVED
  5. The agent continues from reasoning with full context intact

This pattern is central to the automation agents we build at DAILYMP: quote approval flows, contract validation, incident escalation — processes that need human oversight without losing the thread of the workflow.

What you gain in production

The difference isn't theoretical. With explicit state:

Real debugging: the history of transitions tells you exactly what happened, in what order, and how long each step took. When an agent fails at 3 AM, you don't trawl flat logs — you read the state sequence.

Intelligent retry: you don't restart from scratch. The agent returns to the state before the error, not to the beginning of the flow.

Typed iteration limits: if state === 'reasoning' appears more than N times in history without a COMPLETE, there's an explicit rule to escalate it. Not an arbitrary timeout.

Team visibility: anyone can read the state diagram and understand what the agent does without digging into code. This matters when you need to explain the process to a CTO or a non-technical stakeholder.

If you're building agents that integrate with existing systems — Odoo, CRMs, internal tools — the state machine is what makes that integration maintainable six months out, not just functional on deploy day.

The most expensive mistake you can make

Underestimating flow complexity.

An agent that "only needs to do three things" ends up with seven edge cases: what if the tool times out, what if the LLM returns malformed JSON, what if the user cancels mid-flow, what if two agents run in parallel over the same resource.

Without explicit states, each edge case becomes a new if inside the loop. In six months you have a 300-line while-loop that nobody understands and that fails in ways nobody predicted.

With a state machine, each edge case is a new transition. It's on the diagram. It's in the types. It's in the log.


Building an agent for a critical business process and don't want to pay the price of learning this in production incidents?

Let's talk architecture before the problem exists →

Share article

Repetitive processes in your business?

Download the free AI Automation Map — the 5 most time-consuming processes and how to fix them.

No spam. Just the PDF. Unsubscribe anytime.

Written by Daily Miranda Pardo

I help businesses automate processes, build AI agents and connect intelligent systems.