DEV Community

Staff Systems Lab
Staff Systems Lab

Posted on

Designing Deterministic AI Agent Loops: Architecture, Verification, and Replay State Machines

Designing Deterministic AI Agent Loops: Architecture, Verification, and Replay State Machines

Most software engineers building with Large Language Models (LLMs) eventually hit the exact same wall: the naive agent loop problem.

You start with a straightforward loop:

  1. User gives a prompt.
  2. LLM selects a tool to call.
  3. Runtime executes the tool.
  4. Result is appended to context.
  5. Repeat until LLM emits a final response.

In demos, this works brilliantly. In production, it breaks in infuriatingly subtle ways. The agent gets stuck in infinite loops repeating failing tool calls, hallucinates arguments when tool output schema changes slightly, or produces intermediate outputs that violate domain invariants—costing hundreds of dollars in API credits while degrading user trust.

The core issue is architectural: we treat AI agents like deterministic functions, while executing their state mutations non-deterministically without real boundary validation.

To move from fragile AI prototypes to mission-critical infrastructure, we must treat the agent runtime as a Finite State Machine (FSM) with isolated verification gates and event-sourced replay capabilities.

In this article, we'll design and build a deterministic, production-ready AI agent execution runtime in TypeScript, step by step.


The Fallacy of Prompt-Based Verification

A common fix for agent unreliability is "prompt-based self-correction"—telling the LLM in the system prompt: "If your tool execution fails, reflect on your mistake and try again."

This fails in production for three distinct reasons:

  1. Context Pollution: Every failed tool call and hallucinated response degrades the context window. As context length increases, attention degrades, making subsequent corrections less reliable.
  2. Non-Idempotent Side Effects: If an agent executes an API payload half-correctly (e.g., creating a user record but failing to send an email), blindly re-prompting the model can lead to duplicated mutations.
  3. Unbounded Halting Problem: Without strict structural boundaries, the agent's retry space is unbounded. It will burn tokens trying variations of invalid state transitions.

The Solution: The OPVC Pipeline Architecture

Instead of relying on the LLM to govern its own control flow, we decouple execution into four deterministic phases: Observe, Propose, Verify, Commit (OPVC).

flowchart TD
    A[State Machine Engine] -->|1. Observe Current State| B[Context Assembly]
    B -->|2. Propose Action| C[LLM Planner]
    C -->|Candidate Transition| D{3. Verification Gate}
    D -->|Invalid / Invariant Violations| E[Synthesize Error Event]
    E -->|Inject Guidance| A
    D -->|Valid Transition| F[4. Commit Execution]
    F -->|Execute Tool / Mutate State| G[Append Event to Journal]
    G --> A
  • Observe: Read current state and active constraints from a read-only event journal.
  • Propose: LLM emits a single candidate state transition (a structured tool invocation proposal).
  • Verify: A deterministic runtime gate (Zod schemas, AST parsers, domain invariants) inspects the proposal before execution.
  • Commit: If passed, the execution layer runs the tool, appends the result to an immutable journal, and advances the state machine.

Implementing a Verifiable Agent Runtime

Let's construct a type-safe runtime that enforces this architecture.

1. Defining the Core State & Event Contracts

First, we establish strict typed contracts for our execution journal and tool schemas using Zod and TypeScript.

import { z } from "zod";

// Representing immutable events in our agent's history
export type AgentEvent =
  | { type: "USER_INPUT"; payload: string; timestamp: number }
  | { type: "ACTION_PROPOSED"; tool: string; args: unknown; timestamp: number }
  | { type: "VERIFICATION_FAILED"; error: string; timestamp: number }
  | { type: "ACTION_COMMITTED"; tool: string; result: unknown; timestamp: number }
  | { type: "AGENT_HALTED"; reason: string; timestamp: number };

export interface AgentState {
  history: AgentEvent[];
  status: "IDLE" | "AWAITING_PROPOSAL" | "VERIFYING" | "EXECUTING" | "COMPLETED" | "FAILED";
  consecutiveFailures: number;
  maxRetries: number;
}

// Definition for a verifiable tool
export interface VerifiableTool<TInput = any, TOutput = any> {
  name: string;
  description: string;
  schema: z.ZodSchema<TInput>;
  // Deterministic guard checking business logic beyond pure JSON schema validation
  guard?: (input: TInput, history: AgentEvent[]) => { valid: boolean; reason?: string };
  execute: (input: TInput) => Promise<TOutput>;
}
Enter fullscreen mode Exit fullscreen mode

2. Building the Verification Gate

The Verification Gate acts as an intermediate firewall between the model's output and your downstream services.

export class VerificationGate {
  constructor(private tools: Map<string, VerifiableTool>) {}

  public verify(
    toolName: string,
    rawArgs: unknown,
    history: AgentEvent[]
  ): { success: true; validatedArgs: any } | { success: false; error: string } {
    const tool = this.tools.get(toolName);
    if (!tool) {
      return { success: false, error: `Tool '${toolName}' does not exist.` };
    }

    // 1. Schema / Type Validation
    const parseResult = tool.schema.safeParse(rawArgs);
    if (!parseResult.success) {
      return {
        success: false,
        error: `Schema mismatch for tool '${toolName}': ${parseResult.error.message}`,
      };
    }

    // 2. Business Invariant / Guard Validation
    if (tool.guard) {
      const guardResult = tool.guard(parseResult.data, history);
      if (!guardResult.valid) {
        return {
          success: false,
          error: `Invariant guard failed for '${toolName}': ${guardResult.reason}`,
        };
      }
    }

    return { success: true, validatedArgs: parseResult.data };
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice the secondary guard step: schema parsing verifies structural soundness, but guards verify semantic invariants (e.g., "cannot withdraw an amount greater than the current balance calculated from state history").

3. The State Machine Runtime

Now we wire this into a deterministic loop runner that manages state transitions cleanly:

export interface LLMProvider {
  proposeAction(
    history: AgentEvent[],
    availableTools: Array<{ name: string; description: string }>
  ): Promise<{ tool: string; args: unknown } | { finalAnswer: string }>;
}

export class DeterministicAgentEngine {
  private state: AgentState;
  private tools: Map<string, VerifiableTool> = new Map();
  private verifier: VerificationGate;

  constructor(
    private llm: LLMProvider,
    tools: VerifiableTool[],
    maxRetries = 3
  ) {
    tools.forEach((t) => this.tools.set(t.name, t));
    this.verifier = new VerificationGate(this.tools);
    this.state = {
      history: [],
      status: "IDLE",
      consecutiveFailures: 0,
      maxRetries,
    };
  }

  public async run(userInput: string): Promise<string> {
    this.appendEvent({ type: "USER_INPUT", payload: userInput, timestamp: Date.now() });
    this.state.status = "AWAITING_PROPOSAL";

    while (this.state.status !== "COMPLETED" && this.state.status !== "FAILED") {
      if (this.state.consecutiveFailures >= this.state.maxRetries) {
        this.state.status = "FAILED";
        this.appendEvent({
          type: "AGENT_HALTED",
          reason: `Exceeded max consecutive verification failures (${this.state.maxRetries}).`,
          timestamp: Date.now(),
        });
        throw new Error(`Agent halted: Too many invalid action attempts.`);
      }

      // PHASE 1 & 2: OBSERVE & PROPOSE
      const toolDescriptions = Array.from(this.tools.values()).map((t) => ({
        name: t.name,
        description: t.description,
      }));

      const proposal = await this.llm.proposeAction(this.state.history, toolDescriptions);

      if ("finalAnswer" in proposal) {
        this.state.status = "COMPLETED";
        return proposal.finalAnswer;
      }

      this.appendEvent({
        type: "ACTION_PROPOSED",
        tool: proposal.tool,
        args: proposal.args,
        timestamp: Date.now(),
      });

      // PHASE 3: VERIFY
      this.state.status = "VERIFYING";
      const verification = this.verifier.verify(
        proposal.tool,
        proposal.args,
        this.state.history
      );

      if (!verification.success) {
        this.state.consecutiveFailures++;
        this.appendEvent({
          type: "VERIFICATION_FAILED",
          error: verification.error,
          timestamp: Date.now(),
        });
        this.state.status = "AWAITING_PROPOSAL";
        continue; // Loop back; LLM sees the explicit VERIFICATION_FAILED event
      }

      // PHASE 4: COMMIT
      this.state.status = "EXECUTING";
      this.state.consecutiveFailures = 0; // Reset counter on valid proposal
      const targetTool = this.tools.get(proposal.tool)!;

      try {
        const result = await targetTool.execute(verification.validatedArgs);
        this.appendEvent({
          type: "ACTION_COMMITTED",
          tool: proposal.tool,
          result,
          timestamp: Date.now(),
        });
        this.state.status = "AWAITING_PROPOSAL";
      } catch (execError: any) {
        this.appendEvent({
          type: "VERIFICATION_FAILED",
          error: `Tool execution runtime error: ${execError.message}`,
          timestamp: Date.now(),
        });
        this.state.status = "AWAITING_PROPOSAL";
      }
    }

    throw new Error("Engine terminated unexpectedly.");
  }

  private appendEvent(event: AgentEvent) {
    this.state.history.push(event);
  }

  public getJournal(): readonly AgentEvent[] {
    return Object.freeze([...this.state.history]);
  }
}
Enter fullscreen mode Exit fullscreen mode

Enabling Deterministic State Replay for Debugging

One of the biggest pain points in agent operations is debugging production errors. When an agent fails on step 7 of an enterprise workflow, how do you reproduce it locally when LLM responses are inherently stochastic?

Because our runtime uses an event-sourced journal, we can implement Deterministic Replay Mocking.

export class ReplayLLMProvider implements LLMProvider {
  private proposalPointer = 0;

  constructor(private recordedJournal: AgentEvent[]) {}

  async proposeAction(): Promise<{ tool: string; args: unknown } | { finalAnswer: string }> {
    // Advance to next recorded PROPOSED action in the event log
    while (this.proposalPointer < this.recordedJournal.length) {
      const event = this.recordedJournal[this.proposalPointer++];
      if (event.type === "ACTION_PROPOSED") {
        return { tool: event.tool, args: event.args };
      }
    }
    return { finalAnswer: "Replay execution completed." };
  }
}
Enter fullscreen mode Exit fullscreen mode

By injecting a ReplayLLMProvider populated with production error logs, developers can replay the exact state sequence locally, step through custom verification guards, and inspect invariant failures without burning LLM tokens or making single API calls.


Architecture Comparison: Naive Loop vs. Verifiable State Machine

Feature Naive Agent Loop Verifiable OPVC Runtime
Control Flow Controlled by LLM output string Governed by explicit FSM state
Tool Execution Directly triggered from raw output Enforced via Schema + Semantic Invariant Guards
Failure Handling Prompt concatenation ("Try again") Explicit failure events & finite retry budget
Side Effect Isolation None (Immediate mutation) Staged commit phase
Debuggability Low (non-reproducible) High (Event Sourcing & Replay Engine)

Real-World Trade-Offs

While this architectural pattern solves reliability issues, it introduces explicit system trade-offs that staff engineers must balance:

1. Latency Overhead vs. Safety Bounds

  • Trade-off: Adding verification gates, schema validations, and state checks adds milliseconds to runtime execution.
  • Verdict: In sub-second latency applications, complex guards might feel heavy. However, in automated background agents (e.g., database cleanups, customer support actions, financial orchestration), safety vastly outweighs a 20ms guard execution check.

2. Context Window Consumption

  • Trade-off: Appending granular VERIFICATION_FAILED events to the state journal consumes token window space.
  • Solution: Implement Journal Trimming strategies. When consecutiveFailures > 1, compact redundant failure events into a single summary frame before passing context to the LLM planner.

Common Architectural Pitfalls

Pitfall 1: Mutating External State Inside the Verification Phase

Never allow verification functions to alter database state or call mutation endpoints. Verification functions must be pure functions operating only on the proposed payload and historical state.

Pitfall 2: Circular Repair Loops

If the LLM makes an error and the verification error message is vague (e.g., Invalid input), the LLM will repeatedly propose variations of the exact same broken input.

Fix: Ensure your VerificationGate returns actionable structural guidance:

// BAD ERROR RESPONSE
"Invalid argument for refund."

// GOOD ERROR RESPONSE
"Invariant Failure: Parameter 'amount' ($150) exceeds maximum order total ($100). Re-evaluate refund limits."
Enter fullscreen mode Exit fullscreen mode

When to Use This Pattern

Use explicit OPVC State Machine architectures when:

  • Your agent has tool access to systems with irreversible side effects (SQL databases, payment APIs, Cloud infrastructure).
  • You operate in domain-constrained environments (Healthcare, Finance, Internal Ops tools).
  • You require compliance audit logs of every internal thought/validation step.

Skip this complexity when:

  • Building simple stateless Q&A chatbots.
  • Operating in pure read-only context environments where errors have zero side effects.

Summary Checklist for Production AI Agents

  1. [ ] Isolate Tool Schemas: Use strict Zod / JSON schemas on all tool inputs.
  2. [ ] Enforce Semantic Guards: Implement code-level invariant checks beyond simple data-type checking.
  3. [ ] Bounded Retry Loops: Set strict threshold limits for consecutive verification failures to prevent token burn.
  4. [ ] Event-Sourced Logging: Store state transitions as discrete, typed events to enable offline replay debugging.

By treating AI agents not as magic autonomous entities, but as probabilistic components operating inside deterministic state machines, we construct AI systems that scale reliably in production environments.

Top comments (1)

Collapse
 
mateo_ruiz_6992b1fce47843 profile image
Mateo Ruiz

The OPVC separation is the part I’d emphasize most. The important boundary isn’t just validating the LLM’s output it’s making sure the model never owns the transition from proposal to side effect. Once Verify and Commit are deterministic, retries become a runtime concern instead of another prompt-engineering exercise.

I’ve seen this distinction matter in production agent work at IT Path Solutions as well: replayability becomes dramatically more useful when the journal captures the exact proposed transition, verification result, and committed outcome. That gives you something stronger than “re-run the prompt” you can reproduce the state machine and inspect exactly where the invariant stopped holding.

One addition I’d make is to treat idempotency keys as part of the Commit boundary for non-idempotent tools. Verification can prove an action is valid, but it can’t prevent a valid action from being executed twice after a timeout or ambiguous network failure.