Free Ai API

Designing AI Agent State Machines for Reliable Multi-Step Workflows

The demo agent works beautifully right up until the workflow has to wait. Wait for a tool to finish. Wait for a webhook. Wait for a human approval. Wait for a retry window to expire. Then the “simple agent loop” becomes a pile of flags in Redis, a prompt that says “please remember where you were,” and a support ticket asking why the customer got two refund emails.

Multi-step AI agents fail less often because the model is bad, and more often because the execution model is vague. The agent has memory, but no durable state. It has tools, but no transaction boundaries. It has retries, but no defined point from which retrying is safe.

An explicit state machine fixes this by making the workflow’s lifecycle a first-class artifact: current state, allowed transitions, pending actions, failure policy, compensation plan, and audit trail. The model still does the flexible reasoning, but the workflow itself stops being a vibe.

TL;DR: Treat a multi-step AI agent as a durable state machine, not as a chatbot with tools. Define explicit states for planning, execution, verification, approval, failure, and compensation. Persist transitions before side effects. Make events facts, not intentions. Put retries, timeouts, and escalations in code, not prompts. Checkpoint structured context instead of replaying the whole conversation. Test the machine like a distributed system.

📋 Table of Contents


1. The Agent Loop Is a Runtime, Not a Workflow

Scenario: Your agent needs to process a customer escalation: classify the issue, fetch account data, propose a refund, get supervisor approval, execute the refund, and notify the customer. You implement it as a single ReAct-style loop with tools. It works until the approval step takes two hours, the process restarts, and the agent forgets that the refund was already proposed.

Why it matters: A basic agent loop is excellent for interactive problem solving, but it is not a durable workflow engine. The loop’s state lives in the model context, local variables, or temporary memory. If the process restarts, the state disappears. If the model hallucinates progress, there is no authoritative record to contradict it.

Solution: Separate the agent runtime from the workflow state machine.

The runtime can still be an LLM agent that proposes actions. But the workflow should have a typed state object and a transition function:

type WorkflowStatus =
  | "collecting_requirements"
  | "planning"
  | "awaiting_approval"
  | "executing_step"
  | "verifying_step"
  | "compensating"
  | "failed"
  | "completed";

interface WorkflowExecution {
  id: string;
  status: WorkflowStatus;
  version: number;
  input: unknown;
  plan?: Step[];
  currentStepId?: string;
  attempt: number;
  history: TransitionRecord[];
  result?: unknown;
  failure?: FailureInfo;
}

The LLM can influence the workflow by producing proposals: a plan, a tool call, a summary, a verification judgment. But the state machine decides whether those proposals are legal.

function transition(
  execution: WorkflowExecution,
  event: WorkflowEvent
): TransitionResult {
  switch (execution.status) {
    case "collecting_requirements":
      if (event.type === "requirements_collected") {
        return {
          nextState: { ...execution, status: "planning" },
          effects: [{ kind: "generate_plan", input: event.input }],
        };
      }
      break;

    case "planning":
      if (event.type === "plan_created") {
        return {
          nextState: {
            ...execution,
            status: "executing_step",
            plan: event.steps,
            currentStepId: event.steps[0]?.id,
            attempt: 0,
          },
          effects: [{ kind: "execute_step", stepId: event.steps[0]?.id }],
        };
      }
      break;

    // other transitions...
  }

  return { nextState: execution, effects: [] };
}

Why this works: The workflow becomes inspectable. You can answer questions like “What state is execution wf_123 in?” without reading a transcript. You can resume, retry, audit, and test it.

💡 Practical note: If your agent’s only source of truth is the conversation history, you do not have a workflow. You have a transcript with ambitions.


2. Design States Around Decision Points, Not API Calls

A common mistake is naming states after low-level actions: calling_search_api, waiting_for_llm_response, saving_to_database. Those states are brittle. They couple the workflow to implementation details and explode in number as soon as you add more tools.

Better states model the decision points and lifecycle phases of the work.

For a typical multi-step agent, a useful baseline looks like this:

collecting_requirements
  → planning
  → awaiting_approval
  → executing_step
  → verifying_step
  → executing_next_step | compensating | completed

Each state answers a different question:

StateQuestion it answers
collecting_requirementsDo we have enough input to proceed?
planningWhat steps should happen, and in what order?
awaiting_approvalDoes a human need to authorize this?
executing_stepIs a side-effecting action running?
verifying_stepDid the step produce an acceptable result?
compensatingWhat needs to be undone?
failedIs the workflow terminally broken?
completedIs the outcome final?

These states are deliberately broad. They do not say “call Stripe” or “query Postgres.” They say, “We are currently executing a step,” and the step metadata says which tool is involved.

interface Step {
  id: string;
  name: string;
  kind: "read_only" | "mutating" | "human_approval" | "notification";
  tool?: string;
  input: unknown;
  requiresApproval?: boolean;
  retryPolicy?: RetryPolicy;
  compensation?: CompensationAction;
}

Why this works: The state machine remains stable even when the agent’s tools change. You can swap a search tool, add a new verification model, or replace an email provider without rewriting the workflow graph.

⚠️ Gotcha: Avoid creating a separate top-level state for every tool. Use executing_step plus structured step metadata. If a step itself has complex substates, model that step as a nested state machine or sub-workflow.


3. Events Should Be Facts, Not Intentions

State machines become unreliable when events are ambiguous. user_wants_refund is an intention. refund_requested is closer to a fact. refund_executed is a fact. refund_execution_failed is also a fact.

AI agents are full of intentions: the model wants to call a tool, wants to ask a question, wants to finalize an answer. The state machine should only transition on events that represent something that happened or was decisively validated.

A practical event model looks like this:

type WorkflowEvent =
  | { type: "requirements_collected"; input: WorkflowInput }
  | { type: "plan_created"; steps: Step[] }
  | { type: "plan_rejected"; issues: string[] }
  | { type: "approval_requested"; stepId: string }
  | { type: "approval_granted"; stepId: string; approverId: string }
  | { type: "approval_denied"; stepId: string; reason: string }
  | { type: "step_started"; stepId: string; attempt: number }
  | { type: "step_succeeded"; stepId: string; output: unknown }
  | { type: "step_failed"; stepId: string; error: AgentError }
  | { type: "verification_passed"; stepId: string }
  | { type: "verification_failed"; stepId: string; issues: string[] }
  | { type: "timeout"; stepId?: string }
  | { type: "compensation_completed"; stepId: string }
  | { type: "compensation_failed"; stepId: string; error: AgentError };

The transition function should validate that the event matches the current state. A step_succeeded event for a workflow that is already completed should be ignored or logged, not allowed to resurrect the execution.

function canApplyEvent(
  execution: WorkflowExecution,
  event: WorkflowEvent
): boolean {
  if (
    event.type === "step_succeeded" &&
    execution.currentStepId !== event.stepId
  ) {
    return false;
  }

  if (
    execution.status === "completed" ||
    execution.status === "failed"
  ) {
    return false;
  }

  return true;
}

Why this works: The state machine becomes resilient to duplicate events, late callbacks, and confused model output. It also gives you a clean place to enforce authorization: an approval_granted event must carry an approver identity and must be validated against your permission system.

🔍 Why this matters: In agent systems, the model can suggest events, but it should not be allowed to forge them. Tool results, human actions, timers, and external systems are the trusted producers of state-changing events.


4. Persist the Transition Before You Execute the Side Effect

Here is the failure that hurts most: the agent sends an email, charges a payment, or creates a ticket, then crashes before recording that the action happened. On restart, it may perform the action again. Or worse, it may assume the action never happened and take a contradictory path.

The fix is to treat state transitions and side effects as a durability problem.

A useful pattern is:

  1. Persist the intended transition or step record.
  2. Execute the side effect with an idempotency key.
  3. Persist the result.
  4. Emit the event that moves the state machine forward.

Conceptually:

async function executeStep(
  execution: WorkflowExecution,
  step: Step
): Promise<void> {
  const idempotencyKey = `${execution.id}:${step.id}:${execution.attempt}`;

  const existing = await store.getStepResult(execution.id, step.id);

  if (existing?.status === "succeeded") {
    await eventBus.emit({
      type: "step_succeeded",
      stepId: step.id,
      output: existing.output,
    });
    return;
  }

  await store.markStepRunning(execution.id, step.id, {
    attempt: execution.attempt,
    idempotencyKey,
  });

  try {
    const output = await runTool(step, { idempotencyKey });

    await store.markStepSucceeded(execution.id, step.id, output);

    await eventBus.emit({
      type: "step_succeeded",
      stepId: step.id,
      output,
    });
  } catch (error) {
    await store.markStepFailed(execution.id, step.id, error);

    await eventBus.emit({
      type: "step_failed",
      stepId: step.id,
      error: classifyError(error),
    });
  }
}

The critical detail is the idempotency key. If the executor crashes after the tool succeeds but before the result is recorded, a retry with the same key should return the previous result instead of duplicating the side effect.

For external APIs that support idempotency keys, pass them through. For APIs that do not, you need a local deduplication record or a preflight check: “Has this step already produced an effect for this execution and attempt?”

Why this works: The workflow can survive restarts, duplicate events, and partial failures. Recovery becomes a matter of reading durable state and resuming from the last trustworthy transition.

🚨 Production warning: If a step mutates the real world and you cannot make it idempotent, mark it clearly and treat retries as dangerous. Sometimes the correct transition is not “retry,” but “escalate to a human.”


5. Put Retries, Timeouts, and Escalation in the Machine

Prompts are terrible places for retry logic. You can write, “Retry up to three times with exponential backoff,” and the model may follow that instruction sometimes. But “sometimes” is not a production retry policy.

Retry behavior belongs in the state machine or execution runtime.

Define retry policies per step:

interface RetryPolicy {
  maxAttempts: number;
  initialDelayMs: number;
  maxDelayMs: number;
  backoffMultiplier: number;
  retryOn: Array<"transient" | "rate_limited" | "timeout">;
  onExhausted: "fail" | "compensate" | "escalate_human";
}

Then encode the transition:

function handleStepFailed(
  execution: WorkflowExecution,
  event: Extract<WorkflowEvent, { type: "step_failed" }>,
  step: Step
): TransitionResult {
  const policy = step.retryPolicy ?? defaultRetryPolicy;

  const shouldRetry =
    execution.attempt < policy.maxAttempts &&
    policy.retryOn.includes(event.error.category);

  if (shouldRetry) {
    const delay = calculateBackoff(execution.attempt, policy);

    return {
      nextState: {
        ...execution,
        status: "executing_step",
        attempt: execution.attempt + 1,
      },
      effects: [
        {
          kind: "schedule_event",
          delayMs: delay,
          event: { type: "step_started", stepId: step.id, attempt: execution.attempt + 1 },
        },
      ],
    };
  }

  if (policy.onExhausted === "compensate") {
    return startCompensation(execution, event);
  }

  if (policy.onExhausted === "escalate_human") {
    return {
      nextState: {
        ...execution,
        status: "awaiting_approval",
        failure: {
          stepId: step.id,
          reason: event.error.message,
          needsHumanDecision: true,
        },
      },
      effects: [{ kind: "notify_human", executionId: execution.id }],
    };
  }

  return {
    nextState: {
      ...execution,
      status: "failed",
      failure: { stepId: step.id, reason: event.error.message },
    },
    effects: [{ kind: "emit_metric", name: "workflow_failed" }],
  };
}

Timeouts need the same treatment. Every state that can wait should have an expiration path:

executing_step + timeout → step_failed(category="timeout")
awaiting_approval + timeout → reminder, escalate, or deny
verifying_step + timeout → fail closed or route to human review

Why this works: The workflow’s temporal behavior becomes deterministic. You can reason about maximum duration, retry cost, and escalation paths without asking the model to be disciplined.

💡 Practical note: Distinguish transient failures from permanent failures early. A 429 or network timeout may be retryable. An invalid schema, missing permission, or malformed business input usually is not. Blind retries turn permanent errors into expensive loops.


6. Verification States Are Where Reliability Actually Lives

A lot of agent workflows make the same architectural mistake: they treat tool output as final output. The tool returns something, the model sees it, and the workflow moves on. But multi-step workflows usually need a verification phase.

Verification can be lightweight or strict depending on risk:

  • Schema validation for structured output
  • Business-rule checks
  • Policy checks
  • Deterministic assertions
  • A second model acting as a critic
  • Human review for high-risk changes

Model this explicitly:

case "executing_step":
  if (event.type === "step_succeeded") {
    return {
      nextState: {
        ...execution,
        status: "verifying_step",
      },
      effects: [
        {
          kind: "verify_step",
          stepId: event.stepId,
          output: event.output,
        },
      ],
    };
  }
  break;

case "verifying_step":
  if (event.type === "verification_passed") {
    return advanceToNextStep(execution);
  }

  if (event.type === "verification_failed") {
    return handleVerificationFailure(execution, event);
  }
  break;

The verification step should produce structured feedback, not just a boolean:

interface VerificationResult {
  passed: boolean;
  issues: Array<{
    code: string;
    message: string;
    severity: "warning" | "error";
  }>;
  suggestedAction?: "retry_step" | "replan" | "escalate_human" | "accept_with_warning";
}

This matters because LLM-generated actions often fail in subtle ways. The tool call may succeed technically, but the result may be semantically wrong. A refund might have the right currency but the wrong customer. A generated support response might be polite but violate policy. A database update might succeed but miss a required audit field.

Why this works: Verification gives the state machine a place to reject progress without crashing the whole workflow. The agent can replan, retry, or escalate based on structured failure reasons.

🧠 The important part: Do not verify everything with the same intensity. Read-only steps can use cheap validation. Mutating steps, financial actions, external communications, and privileged operations deserve stronger checks.


7. Human Approval Needs Its Own Suspended State

If your agent sometimes needs a human, “sometimes” must be represented as a state. Not a comment in the code. Not a note in the prompt. A state.

interface AwaitingApprovalState {
  status: "awaiting_approval";
  stepId: string;
  requestedAt: string;
  expiresAt: string;
  approverRoles: string[];
  decision?: {
    approverId: string;
    outcome: "approved" | "denied";
    reason?: string;
    decidedAt: string;
  };
}

The transition into this state should persist enough information for a human to decide safely:

function requestApproval(
  execution: WorkflowExecution,
  step: Step
): TransitionResult {
  return {
    nextState: {
      ...execution,
      status: "awaiting_approval",
      currentStepId: step.id,
    },
    effects: [
      {
        kind: "create_approval_request",
        executionId: execution.id,
        stepId: step.id,
        summary: describeStepForHuman(step),
        riskLevel: assessRisk(step),
        expiresAt: addHours(new Date(), 24).toISOString(),
      },
    ],
  };
}

Then handle the possible outcomes:

approval_granted → execute_step
approval_denied → compensate, fail, or replan depending on policy
approval_timeout → remind, escalate, or deny by default

This is also where authorization matters. The state machine should not accept an approval event unless it can verify the approver’s identity and permissions. If the approval came from a UI, the backend should enforce permissions before emitting the event. If it came from Slack or email, the integration still needs to authenticate the user and check their role.

Why this works: The workflow can pause for minutes, hours, or days without losing consistency. The agent does not need to keep a conversation alive just to wait for a person.

⚠️ Gotcha: Do not let the LLM decide that a human “probably approved” based on chat history. Approval must be an explicit, authenticated event from a trusted system.


8. Compensation Is a Workflow, Not a Function

Multi-step agents often perform actions that cannot be cleanly undone. You can void a payment, cancel a reservation, delete a draft, or send a correction email, but those are new actions with their own failure modes.

This is where saga-style compensation fits naturally.

When a mutating step succeeds, record how to compensate it if a later step fails:

interface CompletedStepRecord {
  executionId: string;
  stepId: string;
  output: unknown;
  compensation?: CompensationAction;
  completedAt: string;
}

interface CompensationAction {
  tool: string;
  input: unknown;
  description: string;
  requiresApproval?: boolean;
}

If a later step fails irrecoverably, the state machine enters compensating and works backward through the completed steps:

function startCompensation(
  execution: WorkflowExecution,
  event: Extract<WorkflowEvent, { type: "step_failed" }>
): TransitionResult {
  const completed = getCompletedStepsWithCompensation(execution);

  if (completed.length === 0) {
    return {
      nextState: {
        ...execution,
        status: "failed",
        failure: { stepId: event.stepId, reason: event.error.message },
      },
      effects: [],
    };
  }

  return {
    nextState: {
      ...execution,
      status: "compensating",
      failure: { stepId: event.stepId, reason: event.error.message },
    },
    effects: [
      {
        kind: "run_compensation",
        executionId: execution.id,
        compensationPlan: completed.reverse(),
      },
    ],
  };
}

Compensation needs its own failure handling. A compensation action can time out, fail validation, or be blocked by an external system. If compensation fails, do not silently mark the workflow as clean. Move to compensation_failed and alert someone.

compensating
  → compensation_completed
  → failed

compensating
  → compensation_failed
  → human_intervention_required

Why this works: You acknowledge that distributed workflows do not have magical rollback. Instead, you make the undo path explicit, observable, and testable.

💡 Practical note: For high-risk operations, compensation plans should be reviewed like code. “Send apology email” may be acceptable. “Delete customer account” probably needs approval, audit logging, and a very careful idempotency strategy.


9. Checkpoint Context, Do Not Replay the Whole Conversation

A naive recovery strategy is to feed the entire conversation history back into the model and ask it to continue. This works for short demos. It becomes expensive, slow, and unreliable for long workflows.

Long-running agents need structured checkpoints.

A checkpoint is not the whole transcript. It is the minimum context required to resume safely:

interface WorkflowCheckpoint {
  executionId: string;
  stateVersion: number;
  status: WorkflowStatus;
  currentStepId?: string;
  input: WorkflowInput;
  plan?: Step[];
  completedSteps: Array<{
    stepId: string;
    outputSummary: unknown;
    artifacts: ArtifactReference[];
  }>;
  openQuestions?: string[];
  constraints: PolicyConstraints;
  updatedAt: string;
}

The model can receive a compact summary generated from the checkpoint:

function buildAgentContext(checkpoint: WorkflowCheckpoint) {
  return {
    objective: checkpoint.input.objective,
    currentState: checkpoint.status,
    currentStep: checkpoint.currentStepId,
    completedWork: checkpoint.completedSteps.map(step => ({
      stepId: step.stepId,
      summary: summarize(step.outputSummary),
    })),
    constraints: checkpoint.constraints,
    nextAllowedActions: getAllowedActions(checkpoint.status),
  };
}

This has several benefits:

  • Lower token cost
  • Less drift from irrelevant conversation history
  • Cleaner separation between durable state and model reasoning
  • Easier auditing
  • Better ability to swap models mid-workflow

It also forces you to decide what matters. If a piece of information is required to continue the workflow, it belongs in the checkpoint. If it is only useful for explaining the past, it can stay in logs or transcripts.

Be careful with secrets. Do not store raw API keys, passwords, or sensitive tokens in workflow state. Store references that your runtime can resolve securely.

{
  "type": "secret_reference",
  "provider": "vault",
  "path": "payments/prod/api-key",
  "scope": "read-only"
}

Why this works: The agent can resume after restarts, delays, and handoffs without pretending the entire conversation is the only source of truth.

🔍 Why this matters: If your agent cannot resume without replaying thousands of tokens, you have not built a durable workflow. You have built an expensive memory test.


10. Test the State Machine Like a Distributed System

Prompt evaluation is important, but it is not enough. A multi-step agent has distributed-system problems: duplicate events, partial failures, timeouts, restarts, out-of-order callbacks, and race conditions.

You need at least four layers of testing.

Transition tests

These are deterministic unit tests for the state machine.

test("step_failed schedules retry when error is transient", () => {
  const execution = makeExecution({
    status: "executing_step",
    currentStepId: "step_1",
    attempt: 1,
  });

  const result = transition(execution, {
    type: "step_failed",
    stepId: "step_1",
    error: { category: "transient", message: "timeout" },
  });

  expect(result.nextState.status).toBe("executing_step");
  expect(result.nextState.attempt).toBe(2);
  expect(result.effects).toContainEqual(
    expect.objectContaining({ kind: "schedule_event" })
  );
});

Illegal-state tests

These verify that bad events do not corrupt the workflow.

test("approval_granted is ignored after workflow completed", () => {
  const execution = makeExecution({ status: "completed" });

  const result = transition(execution, {
    type: "approval_granted",
    stepId: "step_1",
    approverId: "user_123",
  });

  expect(result.nextState).toEqual(execution);
  expect(result.effects).toHaveLength(0);
});

Failure simulation tests

Simulate tool failures, slow tools, duplicate callbacks, and restarts.

Given the workflow is executing step 2
When the tool succeeds but the process crashes before recording success
And the executor restarts with the same idempotency key
Then the step result is reused
And no duplicate side effect occurs

Trace and eval tests

For the LLM-dependent parts, use trace-based evaluation:

  • Did the planner produce a valid plan?
  • Did the agent choose a tool allowed in the current state?
  • Did the verifier catch policy violations?
  • Did the model ask for missing information instead of guessing?

A useful test matrix:

FailureWhat to testExpected behavior
Tool timeoutInject timeout eventRetry, escalate, or fail according to policy
Duplicate tool resultSend same step_succeeded twiceSecond event is ignored or deduplicated
Invalid planPlanner returns malformed stepsReplan with attempt cap or escalate
Human denies approvalEmit approval_deniedStop, compensate, or choose alternate path
Process restart mid-stepKill executor and resumeResume from checkpoint safely
Compensation failureMake compensation tool failEnter compensation_failed and alert

Why this works: You stop relying on the model to be the only safety mechanism. The workflow becomes testable independently of model nondeterminism.

🧠 The important part: Use LLM evaluation where judgment matters. Use deterministic tests where correctness matters. A reliable agent needs both.


Choosing the Right Agent Workflow Shape

Not every agent needs a full state machine. The right design depends on risk, duration, and side effects.

PatternBest forStrengthMain risk
Free-form agent loopInteractive assistants, read-only Q&AFlexibilityPoor durability and auditability
Fixed DAG pipelinePredictable batch workflowsSimplicity and observabilityLimited adaptability
State machine with agent stepsMulti-step workflows with approvals, retries, and side effectsReliability and controlMore design work
Hierarchical state machineComplex workflows with subtasksScales complexityCan become over-engineered
Human-operated workflow with AI assistanceHigh-risk or legally sensitive actionsSafetySlower throughput

My default is:

  • Use a free-form agent loop when the task is short, mostly read-only, and failure is cheap.
  • Use a DAG when the steps are known in advance and rarely change.
  • Use a durable state machine when the agent performs multi-step work that can pause, fail, retry, require approval, or mutate external systems.
  • Use human-in-the-loop states whenever the cost of a wrong automated action is high.
  • Avoid rigid state machines for open-ended exploration where the user is actively steering every move and no durable side effects occur.

The most reliable AI agent architectures are not the ones that give the model unlimited control. They are the ones that give the model enough freedom to be useful, while making the workflow’s progress explicit, recoverable, and auditable.

In other words: let the model reason, but make the machine remember.

Post a Comment

Previous Post Next Post