Siddhant Deval
Siddhant Deval
system design5 min read

Agent Loops & Deterministic State Machines

Naive while-loop agent implementations hallucinate function arguments, enter cyclic tool-calling spirals, and silently burn thousands of tokens without completing tasks. This article models agent execution as a Finite State Machine (FSM) with explicit transition guards, maximum step budgets, and structured error recovery for the Workspace autonomous research agent.

Series·Part 15 of 24

Full-Stack AI Architecture: From Streaming Foundations to Resilient Agentic Systems in Node.js & React

Agent Loops & Deterministic State Machines

A while (true) loop with tool calling looks like an agent. In a demo, it works. In production, it burns $400 in API tokens cycling through the same failed tool call, hangs indefinitely on network timeouts, or hallucinates circular chains of function arguments that reference each other's phantom outputs. The loop has no exit conditions beyond max_tokens, no structured error recovery, and no audit trail.

This article replaces the naive loop with a Finite State Machine (FSM) agent runtime: explicit states with guarded transitions, a step budget that terminates runaway loops, and structured tool error recovery that feeds error context back to the model rather than crashing the process.

Architectural Note

Who this is for: Engineers who have built LLM agents with ReAct loops and hit runaway costs, infinite loops, or agents that silently fail to complete tasks. FSM modeling eliminates these failure modes structurally.


1. The FSM Model for Agent Execution

1.1 States and Transitions

Each arrow in the diagram corresponds to a guarded transition function in code. There is no escape hatch — the agent cannot be in an undefined state.

Architectural Note

In this synchronous implementation, AWAITING_RESULT is a transient state — set and resolved within the same callTool() call before the event loop yields. Part 17 shows how Inngest durable steps make this genuinely async: the runtime serialises the full FSM context at AWAITING_RESULT and resumes it only after a human approval event — surviving server restarts in between.


2. TypeScript FSM Agent Implementation

TYPESCRIPT
// ✅ Deterministic FSM Agent Runtime
// file: src/agents/FsmAgentRuntime.ts

import { generateObject } from 'ai'
import { createAnthropic } from '@ai-sdk/anthropic'
import { z } from 'zod'
import type { AgentTool, AgentToolResult } from './types'

const anthropic = createAnthropic()

type AgentState = 'PLANNING' | 'TOOL_CALLING' | 'AWAITING_RESULT' | 'AWAITING_HUMAN' | 'RESPONDING' | 'FAILED'

const PlanSchema = z.object({
  reasoning: z.string(),
  nextAction: z.enum(['call_tool', 'respond_directly', 'request_human_approval']),
  toolName: z.string().optional(),
  toolArgs: z.record(z.unknown()).optional(),
  responseText: z.string().optional(),
})

export interface AgentConfig {
  maxSteps: number        // Hard budget — prevents runaway loops
  maxTokensPerStep: number
  tools: AgentTool[]
  systemPrompt: string
  requireHumanApproval: (toolName: string, args: unknown) => boolean
}

export class FsmAgentRuntime {
  private state: AgentState = 'PLANNING'
  private steps = 0
  private messages: { role: string; content: string }[] = []

  constructor(private readonly config: AgentConfig) {}

  async run(userTask: string): Promise<string> {
    this.messages.push({ role: 'user', content: userTask })
    this.state = 'PLANNING'

    while (this.state !== 'RESPONDING' && this.state !== 'FAILED') {
      if (this.steps >= this.config.maxSteps) {
        this.state = 'FAILED'
        return `Agent exceeded maximum step budget (${this.config.maxSteps} steps) without completing the task.`
      }

      this.steps++
      await this.transition()
    }

    return this.state === 'FAILED'
      ? 'The agent was unable to complete the task.'
      : (this.messages.at(-1)?.content ?? '')
  }

  private async transition(): Promise<void> {
    switch (this.state) {
      case 'PLANNING': return this.plan()
      case 'TOOL_CALLING': return this.callTool()
      case 'AWAITING_RESULT': return this.processResult()
      case 'AWAITING_HUMAN': return this.awaitHuman()
    }
  }

  private async plan(): Promise<void> {
    const { object: plan } = await generateObject({
      model: anthropic('claude-3-5-sonnet-20241022'),
      schema: PlanSchema,
      maxTokens: this.config.maxTokensPerStep,
      system: this.config.systemPrompt + '\n\nAvailable tools: ' +
        this.config.tools.map(t => `${t.name}: ${t.description}`).join(', '),
      messages: this.messages as any,
    })

    this.messages.push({ role: 'assistant', content: JSON.stringify(plan) })

    if (plan.nextAction === 'respond_directly') {
      this.messages.push({ role: 'assistant', content: plan.responseText ?? '' })
      this.state = 'RESPONDING'
    } else if (plan.nextAction === 'request_human_approval') {
      this.state = 'AWAITING_HUMAN'
    } else {
      this.state = 'TOOL_CALLING'
    }
  }

  private async callTool(): Promise<void> {
    const lastPlan = JSON.parse(this.messages.at(-1)?.content ?? '{}')
    const { toolName, toolArgs } = lastPlan

    if (!toolName) { this.state = 'FAILED'; return }

    // Check if this tool requires human approval
    if (this.config.requireHumanApproval(toolName, toolArgs)) {
      this.state = 'AWAITING_HUMAN'
      return
    }

    const tool = this.config.tools.find(t => t.name === toolName)
    if (!tool) {
      this.messages.push({ role: 'user', content: `Tool "${toolName}" not found.` })
      this.state = 'PLANNING'  // Re-plan with error context
      return
    }

    this.state = 'AWAITING_RESULT'
    // Execution happens in processResult (simulated here as immediate)
    const result = await tool.execute(toolArgs ?? {})
    this.messages.push({
      role: 'user',
      content: `Tool result for ${toolName}:\n${JSON.stringify(result)}`,
    })

    // Re-plan after result
    this.state = 'PLANNING'
  }

  private async processResult(): Promise<void> {
    // State handled inline in callTool — this handles async tool patterns
    this.state = 'PLANNING'
  }

  private async awaitHuman(): Promise<void> {
    // In production: pause here and emit approval request event (Part 17)
    // The runtime suspends; resumed by durable execution framework
    this.state = 'FAILED'  // Simplified — full implementation in Part 17
  }
}

3. Tool Error Recovery

3.1 Structured Error Context

Tool failures must not crash the agent — they must be fed back as structured context so the model can adapt its plan.

TYPESCRIPT
// ✅ Structured tool error wrapper
export async function executeToolSafely(
  tool: AgentTool,
  args: unknown,
): Promise<AgentToolResult> {
  try {
    const result = await tool.execute(args as Record<string, unknown>)
    return { success: true, result }
  } catch (error) {
    // Convert error to structured model-readable context
    return {
      success: false,
      error: {
        type: error instanceof Error ? error.constructor.name : 'UnknownError',
        message: error instanceof Error ? error.message : String(error),
        recoverable: !(error instanceof FatalToolError),
        suggestion: error instanceof ValidationError
          ? `Check the tool arguments: ${error.details}`
          : 'Try a different approach or request human assistance.',
      },
    }
  }
}

Summary

Pattern Problem Eliminated
FSM states Undefined/impossible agent states that cause unpredictable behaviour
Step budget Runaway loops consuming thousands of tokens without task completion
Structured errors Uncaught tool exceptions crashing the agent process
Planning phase Hallucinated tool arguments from reactive immediate tool calls

What's Next

Part 16 replaces proprietary function-calling with the open Model Context Protocol (MCP). We implement a custom MCP server using TypeScript SDK v2, expose enterprise tools via Streamable HTTP transport, and secure the server with OAuth 2.1 bearer token validation.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#AI Agents#State Machines#FSM#TypeScript#Node.js#LLM#Orchestration
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.

Series·Part 15 of 24

Full-Stack AI Architecture: From Streaming Foundations to Resilient Agentic Systems in Node.js & React

Curriculum Roadmap
01The Full-Stack AI Paradigm Shift: Beyond Toy Wrappers & Prompt Concatenation
02The Anatomy of an LLM Call: Tokens, Context Windows & Probabilistic Mechanics
03Structured Outputs & Formal Invariants: Taming Non-Determinism with Zod & TypeBox
04Full-Stack Streaming Protocols: SSE, Fetch Streams & WebSockets
05V8 Streams, Server-Side Backpressure & Cancellations in Node.js
06React 18/19 Streaming UI & Generative Components
07Generative UI & Interactive Canvas Synchronization
08High-Dimensional Vector Embeddings & Similarity Math
09Vector Storage & Indexing at Scale: HNSW vs IVF in PostgreSQL (pgvector)
10Hybrid Search: Dense Vectors Meet Sparse Lexical BM25
11Multi-Stage Retrieval & Cross-Encoder Reranking
12Advanced RAG Architectures: Contextual Retrieval & Parent-Document Splitting
13Query Transformation, Decomposition & Multi-Query Expansion
14Dynamic Context Window Management & Long-Context Packing
15Agent Loops & Deterministic State Machines
Reading
16Model Context Protocol (MCP) in Full-Stack Architecture
17Human-in-the-Loop & Durable Agent Workflows
18Multi-Agent Orchestration & Collaborative Swarms
19Semantic Caching & Provider Cost Optimization
20Multi-Provider AI Gateways: Fallbacks, Rate Limiting & Hedged Requests
21Client-Side AI & Edge Hybrid Execution: WebGPU & Transformers.js
22Production Evals & Guardrails: Measuring Retrieval and Generation
23Full-Stack AI Observability & Tracing with OpenTelemetry
24Production Capstone: Assembling The Enterprise Collaborative AI Workspace