Wicked Smart Data
LearnArticlesAbout
Sign InSign Up
LearnArticlesAboutContact
Sign InSign Up
Wicked Smart Data

The go-to platform for professionals who want to master data, automation, and AI — from Excel fundamentals to cutting-edge machine learning.

Platform

  • Learning Paths
  • Articles
  • About
  • Contact

Connect

  • Contact Us
  • RSS Feed

© 2026 Wicked Smart Data. All rights reserved.

Privacy PolicyTerms of Service
All Articles
Agentic Loop Design: Building Reliable Multi-Step LLM Pipelines with Retry Logic, State Management, and Human-in-the-Loop Escalation

Agentic Loop Design: Building Reliable Multi-Step LLM Pipelines with Retry Logic, State Management, and Human-in-the-Loop Escalation

AI & Machine Learning🔥 Expert26 min readJul 6, 2026Updated Jul 6, 2026
Table of Contents
  • Introduction
  • Prerequisites
  • The Mental Model: An Agent Is a State Machine
  • Persisting State: Surviving Crashes and Enabling Replay
  • Tool Execution and Error Classification
  • The Main Agent Loop
Human-in-the-Loop Escalation
  • Confidence Scoring and Proactive Escalation
  • Idempotency and Side-Effect Management
  • Observability: Instrumenting the Loop
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Summary & Next Steps
  • Agentic Loop Design: Building Reliable Multi-Step LLM Pipelines with Retry Logic, State Management, and Human-in-the-Loop Escalation

    Introduction

    You've built a chatbot that works great for single-turn Q&A. Then someone asks you to make it "actually do things" — pull data from a database, generate a report, validate the output, email a stakeholder if something looks wrong, and retry if the LLM hallucinates a function call. Suddenly, you're not building a prompt anymore. You're building a system. And systems fail in ways that prompts don't.

    Agentic loops — LLM pipelines where the model reasons, acts, observes results, and decides what to do next — are one of the most powerful patterns in modern AI engineering. They're also one of the most brittle if you don't design them carefully. An agent that silently retries a failed database write three times without telling anyone has just corrupted your data three times. An agent that loops forever because it can't parse its own tool output will burn through your API budget while you sleep. The gap between a demo that impresses stakeholders and a production system that earns trust comes down almost entirely to the engineering discipline you apply to the loop itself.

    By the end of this lesson, you'll be able to design and implement production-grade agentic loops from scratch. You'll understand the state machine model underlying most agent frameworks, how to write retry logic that distinguishes between recoverable and unrecoverable errors, how to persist agent state so runs can resume after failure, and when and how to pause execution and hand control to a human.

    What you'll learn:

    • How to model an agentic loop as an explicit state machine and why that framing prevents entire classes of bugs
    • How to implement layered retry logic with exponential backoff, jitter, and error classification
    • How to design a persistent state schema that survives process crashes and enables replay
    • How to build human-in-the-loop escalation that blocks execution gracefully without losing progress
    • How to instrument and observe an agentic loop in production so you can debug failures after the fact

    Prerequisites

    This lesson assumes you're comfortable with:

    • Python at an intermediate-to-advanced level (async/await, dataclasses, type annotations)
    • LLM API usage (OpenAI function calling / tool use, or equivalent)
    • Basic familiarity with concepts like tokens, completions, and structured outputs
    • JSON schema and basic data validation (Pydantic is used extensively in examples)
    • SQL or a key-value store for state persistence (examples use SQLite, but the patterns transfer)

    You don't need prior experience with LangChain, LlamaIndex, or any agent framework — we're building from primitives on purpose. Understanding what's happening underneath frameworks makes you a much more effective user of them.


    The Mental Model: An Agent Is a State Machine

    Before writing a single line of code, let's get the mental model right, because most agent bugs are really modeling bugs in disguise.

    An agentic loop has a finite set of states, a set of transitions between those states, and actions that happen during or between transitions. Sound familiar? That's a state machine. When you accept this framing, a lot of questions answer themselves: What happens when a tool call fails? That's a transition to an error state. What triggers human review? That's a transition to a waiting state. Can we retry from here? That depends on whether the current state is idempotent.

    Here's the state diagram for a moderately realistic agent — one that processes financial reports:

    IDLE
      │ (task received)
      ▼
    PLANNING
      │ (plan generated)
      ▼
    EXECUTING ◄────────────────────────┐
      │                                │
      ├─ (tool call succeeds) ─────► OBSERVING
      │                                │
      ├─ (tool call fails, retryable) ─┘ (retry counter incremented)
      │
      ├─ (tool call fails, unrecoverable) ──► ERROR
      │
      ├─ (confidence below threshold) ──► AWAITING_HUMAN
      │                                        │
      ◄────────────────────────────────────────┘ (human responds)
      │
      ├─ (all steps complete) ──► COMPLETING
      │
      └─ (max steps exceeded) ──► ABORTED
    

    Now translate this to code. We'll represent states as an enum and the agent's current position as a field on a persistent state object:

    from enum import Enum
    from datetime import datetime
    from typing import Any, Optional
    from pydantic import BaseModel, Field
    import uuid
    
    class AgentState(str, Enum):
        IDLE = "idle"
        PLANNING = "planning"
        EXECUTING = "executing"
        OBSERVING = "observing"
        AWAITING_HUMAN = "awaiting_human"
        COMPLETING = "completing"
        ERROR = "error"
        ABORTED = "aborted"
    
    class ToolCall(BaseModel):
        tool_name: str
        arguments: dict[str, Any]
        result: Optional[Any] = None
        error: Optional[str] = None
        attempt_count: int = 0
        succeeded: bool = False
    
    class AgentRunState(BaseModel):
        run_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
        task: str
        state: AgentState = AgentState.IDLE
        plan: Optional[list[str]] = None
        tool_call_history: list[ToolCall] = Field(default_factory=list)
        current_step_index: int = 0
        retry_count: int = 0
        max_retries: int = 3
        max_steps: int = 20
        created_at: datetime = Field(default_factory=datetime.utcnow)
        updated_at: datetime = Field(default_factory=datetime.utcnow)
        human_input_request: Optional[str] = None
        human_input_response: Optional[str] = None
        final_output: Optional[str] = None
        error_message: Optional[str] = None
    
        def transition_to(self, new_state: AgentState) -> None:
            """Explicit state transitions with logging."""
            print(f"[{self.run_id}] {self.state} → {new_state}")
            self.state = new_state
            self.updated_at = datetime.utcnow()
    

    The transition_to method is deliberately simple. In production you'd add validation logic here — for example, raising an exception if a transition is illegal (you should never go from COMPLETING back to EXECUTING). Making transitions explicit rather than just reassigning self.state = something everywhere means you have one place to add logging, validation, and side effects.

    Why this matters in practice: When an agent fails at 2 AM and you look at the logs, the state transition history is the single most valuable debugging artifact you have. If your state changes are scattered across 15 functions with no centralized logging, you'll spend hours reconstructing what happened. Centralize your transitions.


    Persisting State: Surviving Crashes and Enabling Replay

    An agent that loses all its progress when a process crashes is not a production agent — it's an expensive demo. State persistence is what separates the two.

    The requirements for agent state storage are different from typical application storage. You need:

    1. Durability — state must survive process restarts
    2. Queryability — you need to find runs by status, age, task type
    3. Auditability — you need the full history, not just the current state
    4. Fast reads/writes on the hot path — the agent is writing state after every step

    SQLite works well for local development and moderate scale. Postgres is better for production multi-agent systems. We'll use SQLite with a simple wrapper so the interface is backend-agnostic.

    import sqlite3
    import json
    from pathlib import Path
    
    class AgentStateStore:
        def __init__(self, db_path: str = "agent_state.db"):
            self.db_path = db_path
            self._initialize_db()
    
        def _initialize_db(self):
            with sqlite3.connect(self.db_path) as conn:
                conn.execute("""
                    CREATE TABLE IF NOT EXISTS agent_runs (
                        run_id TEXT PRIMARY KEY,
                        state TEXT NOT NULL,
                        task TEXT NOT NULL,
                        full_state_json TEXT NOT NULL,
                        created_at TEXT NOT NULL,
                        updated_at TEXT NOT NULL
                    )
                """)
                conn.execute("""
                    CREATE INDEX IF NOT EXISTS idx_state 
                    ON agent_runs(state)
                """)
                conn.commit()
    
        def save(self, agent_run: AgentRunState) -> None:
            with sqlite3.connect(self.db_path) as conn:
                conn.execute("""
                    INSERT INTO agent_runs 
                        (run_id, state, task, full_state_json, created_at, updated_at)
                    VALUES (?, ?, ?, ?, ?, ?)
                    ON CONFLICT(run_id) DO UPDATE SET
                        state = excluded.state,
                        full_state_json = excluded.full_state_json,
                        updated_at = excluded.updated_at
                """, (
                    agent_run.run_id,
                    agent_run.state.value,
                    agent_run.task,
                    agent_run.model_dump_json(),
                    agent_run.created_at.isoformat(),
                    agent_run.updated_at.isoformat()
                ))
                conn.commit()
    
        def load(self, run_id: str) -> Optional[AgentRunState]:
            with sqlite3.connect(self.db_path) as conn:
                row = conn.execute(
                    "SELECT full_state_json FROM agent_runs WHERE run_id = ?",
                    (run_id,)
                ).fetchone()
            if row is None:
                return None
            return AgentRunState.model_validate_json(row[0])
    
        def find_by_state(self, state: AgentState) -> list[AgentRunState]:
            with sqlite3.connect(self.db_path) as conn:
                rows = conn.execute(
                    "SELECT full_state_json FROM agent_runs WHERE state = ?",
                    (state.value,)
                ).fetchall()
            return [AgentRunState.model_validate_json(row[0]) for row in rows]
    

    The key design decision here is storing full_state_json alongside indexed columns. The indexed columns let you query efficiently ("give me all runs awaiting human review") while the JSON blob lets you reconstruct the full agent state without schema migrations every time you add a field.

    Warning: The UPSERT pattern here (ON CONFLICT DO UPDATE) is intentional. You want save() to be idempotent — calling it twice with the same state should be safe. This matters because your retry logic will sometimes call save() more than once for a given state if an exception occurs mid-step.

    Now, every step of your agent loop ends with a store.save(state) call. If the process dies, the next invocation loads the state and resumes. This is the foundation of reliable agentic systems.


    Tool Execution and Error Classification

    The most dangerous part of an agentic loop is tool execution — this is where the agent reaches out and touches the real world. A poor tool execution layer treats all errors the same way. A good one classifies errors and responds appropriately.

    There are three broad categories of tool call errors:

    1. Transient errors — the tool is temporarily unavailable. The right response is retry with backoff. Examples: API rate limits (HTTP 429), network timeouts, database connection pool exhaustion.

    2. Recoverable logic errors — the LLM called the tool with bad arguments. The right response is retry with corrective context fed back to the LLM. Examples: invalid SQL syntax, a date range where start > end, a missing required field.

    3. Unrecoverable errors — retrying won't help. The right response is escalation (human review or abort). Examples: authentication failures, attempting to write to a read-only resource, a tool call that would cause data loss with no reversibility.

    from enum import Enum
    import asyncio
    import random
    
    class ErrorCategory(str, Enum):
        TRANSIENT = "transient"
        RECOVERABLE_LOGIC = "recoverable_logic"
        UNRECOVERABLE = "unrecoverable"
    
    class ToolExecutionError(Exception):
        def __init__(self, message: str, category: ErrorCategory, original: Exception = None):
            super().__init__(message)
            self.category = category
            self.original = original
    
    def classify_error(exc: Exception) -> ErrorCategory:
        """
        Map exceptions to error categories.
        You'll expand this based on the tools you're wrapping.
        """
        err_str = str(exc).lower()
        
        # Transient: worth retrying automatically
        if any(kw in err_str for kw in ["timeout", "rate limit", "429", "503", "connection"]):
            return ErrorCategory.TRANSIENT
        
        # Recoverable logic: the LLM needs corrective feedback
        if any(kw in err_str for kw in ["invalid", "syntax error", "validation", "parse error"]):
            return ErrorCategory.RECOVERABLE_LOGIC
        
        # Everything else: escalate
        return ErrorCategory.UNRECOVERABLE
    

    Now, here's the retry wrapper with exponential backoff and jitter. The jitter is not optional — without it, a fleet of agents that all fail at the same moment will all retry at the same moment, creating a thundering herd that takes down your backend:

    async def execute_tool_with_retry(
        tool_fn,
        arguments: dict,
        max_transient_retries: int = 4,
        base_delay: float = 1.0,
    ) -> Any:
        """
        Execute a tool with layered retry logic.
        Returns the result or raises a ToolExecutionError.
        """
        last_exception = None
        
        for attempt in range(max_transient_retries + 1):
            try:
                result = await tool_fn(**arguments)
                return result
            
            except Exception as exc:
                last_exception = exc
                category = classify_error(exc)
                
                if category == ErrorCategory.UNRECOVERABLE:
                    raise ToolExecutionError(
                        f"Unrecoverable error in tool: {exc}",
                        category=ErrorCategory.UNRECOVERABLE,
                        original=exc
                    )
                
                if category == ErrorCategory.RECOVERABLE_LOGIC:
                    # Don't retry at the tool level — surface to the LLM
                    raise ToolExecutionError(
                        f"Logic error (feed back to LLM): {exc}",
                        category=ErrorCategory.RECOVERABLE_LOGIC,
                        original=exc
                    )
                
                # Transient: retry with exponential backoff + jitter
                if attempt < max_transient_retries:
                    delay = base_delay * (2 ** attempt) + random.uniform(0, 1.0)
                    print(f"Transient error (attempt {attempt + 1}), retrying in {delay:.2f}s: {exc}")
                    await asyncio.sleep(delay)
                else:
                    raise ToolExecutionError(
                        f"Max transient retries exceeded: {exc}",
                        category=ErrorCategory.TRANSIENT,
                        original=exc
                    )
        
        raise ToolExecutionError(
            f"Exhausted retries: {last_exception}",
            category=ErrorCategory.TRANSIENT
        )
    

    Design note: Notice that RECOVERABLE_LOGIC errors are raised immediately rather than retried at the tool level. That's intentional. The fix for a logic error isn't "try the same thing again" — it's "go back to the LLM with information about what went wrong." We handle that in the main loop.


    The Main Agent Loop

    Now we bring it all together. Here's the core loop for an agent that uses OpenAI tool calling to accomplish multi-step tasks. This is intentionally written to be readable and hackable, not maximally concise:

    import openai
    from openai import AsyncOpenAI
    
    client = AsyncOpenAI()
    
    # Define tools the agent can call
    TOOLS = [
        {
            "type": "function",
            "function": {
                "name": "query_database",
                "description": "Run a read-only SQL query against the analytics database.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "sql": {
                            "type": "string",
                            "description": "Valid SQL SELECT statement"
                        }
                    },
                    "required": ["sql"]
                }
            }
        },
        {
            "type": "function",
            "function": {
                "name": "send_email_report",
                "description": "Send a formatted report to a stakeholder email address.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "recipient_email": {"type": "string"},
                        "subject": {"type": "string"},
                        "body_markdown": {"type": "string"}
                    },
                    "required": ["recipient_email", "subject", "body_markdown"]
                }
            }
        }
    ]
    
    async def run_agent_loop(
        task: str,
        store: AgentStateStore,
        tool_registry: dict,
        run_id: Optional[str] = None,
    ) -> AgentRunState:
        
        # Resume existing run or start new one
        if run_id:
            state = store.load(run_id)
            if state is None:
                raise ValueError(f"No run found with id {run_id}")
            print(f"Resuming run {run_id} from state {state.state}")
        else:
            state = AgentRunState(task=task)
            state.transition_to(AgentState.PLANNING)
            store.save(state)
        
        # Build message history from persisted state
        messages = build_message_history(state)
        
        while state.state not in {
            AgentState.COMPLETING,
            AgentState.ERROR,
            AgentState.ABORTED,
            AgentState.AWAITING_HUMAN
        }:
            # Guard: max steps
            if state.current_step_index >= state.max_steps:
                state.transition_to(AgentState.ABORTED)
                state.error_message = f"Exceeded max steps ({state.max_steps})"
                store.save(state)
                break
            
            state.transition_to(AgentState.EXECUTING)
            store.save(state)
            
            # Call the LLM
            try:
                response = await client.chat.completions.create(
                    model="gpt-4o",
                    messages=messages,
                    tools=TOOLS,
                    tool_choice="auto",
                )
            except openai.RateLimitError as e:
                # LLM API itself rate-limited — treat as transient
                delay = 10 + random.uniform(0, 5)
                print(f"LLM rate limited, waiting {delay:.1f}s")
                await asyncio.sleep(delay)
                continue
            except openai.APIError as e:
                state.error_message = f"LLM API error: {e}"
                state.transition_to(AgentState.ERROR)
                store.save(state)
                break
            
            choice = response.choices[0]
            messages.append(choice.message)
            state.current_step_index += 1
            
            # Agent decided it's done
            if choice.finish_reason == "stop":
                state.final_output = choice.message.content
                state.transition_to(AgentState.COMPLETING)
                store.save(state)
                break
            
            # Agent wants to call a tool
            if choice.finish_reason == "tool_calls":
                for tool_call in choice.message.tool_calls:
                    tool_call_record = ToolCall(
                        tool_name=tool_call.function.name,
                        arguments=json.loads(tool_call.function.arguments)
                    )
                    
                    try:
                        tool_fn = tool_registry[tool_call.function.name]
                        result = await execute_tool_with_retry(
                            tool_fn,
                            tool_call_record.arguments
                        )
                        tool_call_record.result = result
                        tool_call_record.succeeded = True
                        
                        # Feed result back to conversation
                        messages.append({
                            "role": "tool",
                            "tool_call_id": tool_call.id,
                            "content": json.dumps(result)
                        })
                    
                    except ToolExecutionError as e:
                        tool_call_record.error = str(e)
                        tool_call_record.attempt_count += 1
                        
                        if e.category == ErrorCategory.RECOVERABLE_LOGIC:
                            # Give the LLM a chance to self-correct
                            messages.append({
                                "role": "tool",
                                "tool_call_id": tool_call.id,
                                "content": f"ERROR: {e}. Please review your arguments and try again."
                            })
                            state.retry_count += 1
                            
                            if state.retry_count >= state.max_retries:
                                await escalate_to_human(
                                    state,
                                    store,
                                    reason=f"Agent failed to self-correct after {state.retry_count} attempts. Last error: {e}"
                                )
                                return state
                        
                        elif e.category in {ErrorCategory.UNRECOVERABLE, ErrorCategory.TRANSIENT}:
                            await escalate_to_human(
                                state,
                                store,
                                reason=f"Tool '{tool_call.function.name}' failed with unrecoverable error: {e}"
                            )
                            return state
                    
                    state.tool_call_history.append(tool_call_record)
                    state.transition_to(AgentState.OBSERVING)
                    store.save(state)
        
        return state
    
    
    def build_message_history(state: AgentRunState) -> list[dict]:
        """Reconstruct message history from persisted tool call history."""
        messages = [
            {
                "role": "system",
                "content": (
                    "You are a data analyst agent. Complete the assigned task step by step, "
                    "using the provided tools. Be methodical. If a tool returns an error, "
                    "analyze it carefully before retrying."
                )
            },
            {
                "role": "user",
                "content": state.task
            }
        ]
        
        # Replay tool call history into message format
        for tc in state.tool_call_history:
            if tc.succeeded:
                messages.append({
                    "role": "assistant",
                    "content": None,
                    "tool_calls": [{
                        "id": f"call_{tc.tool_name}_{id(tc)}",
                        "type": "function",
                        "function": {
                            "name": tc.tool_name,
                            "arguments": json.dumps(tc.arguments)
                        }
                    }]
                })
                messages.append({
                    "role": "tool",
                    "tool_call_id": f"call_{tc.tool_name}_{id(tc)}",
                    "content": json.dumps(tc.result)
                })
        
        return messages
    

    A note on context window management: As your tool call history grows, you'll eventually exceed the context window. The build_message_history function above is naive — it replays everything. In production, you need a context management strategy: summarize old tool results, drop the least-relevant steps, or use a retrieval layer. We cover this in depth in the context management lesson, but be aware the problem exists.


    Human-in-the-Loop Escalation

    Human escalation is where a lot of agent designs fall down. The naive approach is to just raise an exception and let an on-call engineer figure it out. The better approach is to pause the agent in a well-defined state, package the context a human needs to make a decision, and resume automatically once they respond.

    async def escalate_to_human(
        state: AgentRunState,
        store: AgentStateStore,
        reason: str
    ) -> None:
        """
        Pause the agent and notify a human reviewer.
        The agent run remains in AWAITING_HUMAN state until
        human_input_response is populated.
        """
        state.human_input_request = reason
        state.transition_to(AgentState.AWAITING_HUMAN)
        store.save(state)
        
        # In production: send a Slack message, PagerDuty alert, email, etc.
        # The notification should include a link to a review UI with the run_id.
        await notify_human_reviewer(
            run_id=state.run_id,
            task=state.task,
            reason=reason,
            tool_history_summary=summarize_tool_history(state.tool_call_history)
        )
        
        print(f"Run {state.run_id} escalated to human review: {reason}")
    
    
    async def notify_human_reviewer(
        run_id: str,
        task: str,
        reason: str,
        tool_history_summary: str
    ) -> None:
        """
        Stub for your notification system.
        Replace with your Slack/PagerDuty/email integration.
        """
        print(f"""
        === HUMAN REVIEW REQUIRED ===
        Run ID: {run_id}
        Task: {task}
        Reason: {reason}
        
        Tool History:
        {tool_history_summary}
        
        To resume: POST /agent/resume with run_id and response
        ============================
        """)
    
    
    def summarize_tool_history(tool_calls: list[ToolCall]) -> str:
        lines = []
        for i, tc in enumerate(tool_calls, 1):
            status = "✓" if tc.succeeded else "✗"
            lines.append(f"{i}. [{status}] {tc.tool_name}({json.dumps(tc.arguments)[:80]}...)")
            if tc.error:
                lines.append(f"   Error: {tc.error}")
        return "\n".join(lines) if lines else "No tool calls made yet."
    

    Now, the resume path. This is the part most tutorials skip. A human has reviewed the situation and sent a response — how does the agent pick up where it left off?

    async def resume_from_human_input(
        run_id: str,
        human_response: str,
        store: AgentStateStore,
        tool_registry: dict,
    ) -> AgentRunState:
        """
        Resume an agent run that was awaiting human input.
        The human's response is injected into the conversation as context.
        """
        state = store.load(run_id)
        if state is None:
            raise ValueError(f"Run {run_id} not found")
        
        if state.state != AgentState.AWAITING_HUMAN:
            raise ValueError(f"Run {run_id} is in state {state.state}, not AWAITING_HUMAN")
        
        state.human_input_response = human_response
        state.retry_count = 0  # Reset retry counter after human intervention
        
        # We'll pass the human's guidance as a system message on resume
        # The run_agent_loop will call build_message_history which needs to include it
        state.tool_call_history.append(
            ToolCall(
                tool_name="__human_input__",
                arguments={"question": state.human_input_request},
                result={"human_response": human_response},
                succeeded=True
            )
        )
        
        # Resume the loop
        return await run_agent_loop(
            task=state.task,
            store=store,
            tool_registry=tool_registry,
            run_id=run_id
        )
    

    You'll notice the human input is inserted as a synthetic tool call in the history. This is a practical trick: it means build_message_history naturally includes the human's guidance when reconstructing the conversation, without needing special-case logic for "was there a human intervention?"


    Confidence Scoring and Proactive Escalation

    So far we've discussed reactive escalation — the agent escalates because something broke. But you also want proactive escalation — the agent escalates because it's uncertain, even if nothing technically failed.

    One practical approach is to ask the LLM to self-assess confidence as part of its structured output before taking consequential actions:

    from pydantic import BaseModel
    
    class PreActionCheck(BaseModel):
        action_description: str
        confidence_score: float  # 0.0 to 1.0
        confidence_rationale: str
        is_reversible: bool
        potential_side_effects: list[str]
    
    async def pre_action_confidence_check(
        client: AsyncOpenAI,
        proposed_action: str,
        context: str
    ) -> PreActionCheck:
        """
        Ask the LLM to evaluate its own confidence before a consequential action.
        This is a separate, lightweight LLM call — not part of the main loop.
        """
        response = await client.beta.chat.completions.parse(
            model="gpt-4o-mini",  # Use a cheaper model for the check
            messages=[
                {
                    "role": "system",
                    "content": "Evaluate the proposed action and return a structured confidence assessment."
                },
                {
                    "role": "user",
                    "content": f"Context: {context}\n\nProposed action: {proposed_action}"
                }
            ],
            response_format=PreActionCheck,
        )
        return response.choices[0].message.parsed
    
    CONFIDENCE_THRESHOLD = 0.75
    IRREVERSIBLE_ACTIONS = {"send_email_report", "write_database", "delete_record"}
    
    async def should_escalate_before_action(
        tool_name: str,
        arguments: dict,
        context: str,
        client: AsyncOpenAI
    ) -> tuple[bool, str]:
        """
        Returns (should_escalate, reason).
        """
        if tool_name not in IRREVERSIBLE_ACTIONS:
            return False, ""
        
        check = await pre_action_confidence_check(
            client,
            proposed_action=f"Call {tool_name} with {json.dumps(arguments)[:200]}",
            context=context
        )
        
        if check.confidence_score < CONFIDENCE_THRESHOLD:
            reason = (
                f"Confidence {check.confidence_score:.0%} below threshold for "
                f"irreversible action '{tool_name}'. "
                f"Rationale: {check.confidence_rationale}"
            )
            return True, reason
        
        return False, ""
    

    Calibration warning: LLM self-reported confidence scores are notoriously poorly calibrated. The model may say "95% confidence" and be wrong, or "40% confidence" and be right. Treat these scores as heuristics that trigger human review for genuinely ambiguous cases, not as precise probability estimates. Over time, tune your threshold based on observed outcomes in your specific domain.


    Idempotency and Side-Effect Management

    One of the subtlest problems in agentic systems is side-effect management. If your agent retries a step, does it do the thing twice? Sending an email twice, charging a credit card twice, or inserting a row twice are all real failure modes.

    The solution is idempotency keys. Before calling any tool with side effects, generate an idempotency key tied to the run_id and step:

    import hashlib
    
    def generate_idempotency_key(run_id: str, tool_name: str, arguments: dict) -> str:
        """
        Deterministic idempotency key based on run context.
        Same run + same tool + same arguments = same key.
        """
        content = f"{run_id}:{tool_name}:{json.dumps(arguments, sort_keys=True)}"
        return hashlib.sha256(content.encode()).hexdigest()[:16]
    
    # Usage in tool wrapper:
    async def send_email_report_idempotent(
        recipient_email: str,
        subject: str,
        body_markdown: str,
        idempotency_key: str
    ) -> dict:
        """
        Email sender that respects idempotency keys.
        In practice, you'd check a sent_emails table before sending.
        """
        # Check if we already sent this
        already_sent = await check_email_sent(idempotency_key)
        if already_sent:
            return {"status": "already_sent", "idempotency_key": idempotency_key}
        
        # Actually send
        result = await _do_send_email(recipient_email, subject, body_markdown)
        
        # Record that we sent it
        await record_email_sent(idempotency_key, recipient_email, subject)
        
        return {"status": "sent", "idempotency_key": idempotency_key}
    

    Not every tool you wrap will support idempotency keys natively. For those that don't, maintain an idempotency table in your state store:

    class IdempotencyStore:
        def __init__(self, db_path: str = "agent_state.db"):
            self.db_path = db_path
            with sqlite3.connect(db_path) as conn:
                conn.execute("""
                    CREATE TABLE IF NOT EXISTS idempotency_log (
                        key TEXT PRIMARY KEY,
                        tool_name TEXT,
                        result_json TEXT,
                        executed_at TEXT
                    )
                """)
                conn.commit()
        
        def get_cached_result(self, key: str) -> Optional[Any]:
            with sqlite3.connect(self.db_path) as conn:
                row = conn.execute(
                    "SELECT result_json FROM idempotency_log WHERE key = ?", (key,)
                ).fetchone()
            return json.loads(row[0]) if row else None
        
        def record_result(self, key: str, tool_name: str, result: Any) -> None:
            with sqlite3.connect(self.db_path) as conn:
                conn.execute("""
                    INSERT OR IGNORE INTO idempotency_log (key, tool_name, result_json, executed_at)
                    VALUES (?, ?, ?, ?)
                """, (key, tool_name, json.dumps(result), datetime.utcnow().isoformat()))
                conn.commit()
    

    Observability: Instrumenting the Loop

    You cannot debug what you cannot observe. An agentic loop that runs silently and fails silently is a production nightmare. Here's a minimal but effective observability layer:

    import time
    from dataclasses import dataclass, field
    from typing import Callable
    
    @dataclass
    class AgentMetrics:
        run_id: str
        total_llm_calls: int = 0
        total_tool_calls: int = 0
        total_tool_errors: int = 0
        total_human_escalations: int = 0
        total_tokens_used: int = 0
        loop_start_time: float = field(default_factory=time.time)
        step_durations: list[float] = field(default_factory=list)
        
        def record_step(self, duration: float):
            self.step_durations.append(duration)
        
        def summary(self) -> dict:
            elapsed = time.time() - self.loop_start_time
            return {
                "run_id": self.run_id,
                "elapsed_seconds": round(elapsed, 2),
                "total_llm_calls": self.total_llm_calls,
                "total_tool_calls": self.total_tool_calls,
                "error_rate": round(
                    self.total_tool_errors / max(self.total_tool_calls, 1), 3
                ),
                "total_tokens_used": self.total_tokens_used,
                "avg_step_duration_ms": round(
                    sum(self.step_durations) / max(len(self.step_durations), 1) * 1000, 1
                ),
                "human_escalations": self.total_human_escalations
            }
    

    In production, you'd push these metrics to Prometheus, Datadog, or OpenTelemetry. The fields above give you the minimum viable dashboard:

    • Error rate tells you if your error classification is working or if you're silently absorbing failures
    • Total tokens used tells you if your context is growing unboundedly (a sign you need context compression)
    • Human escalations tells you if your confidence thresholds need tuning
    • Avg step duration tells you if a particular class of tasks is much slower than expected

    Structured logging tip: Every log line from your agent loop should include the run_id. This lets you grep or query for the complete trace of any single run. Without this, correlating log lines from a long-running, concurrent agent system becomes nearly impossible.


    Hands-On Exercise

    You'll build a complete agentic loop for a realistic data task: a "quarterly metrics agent" that queries a SQLite database for Q3 revenue data, calculates month-over-month growth, and emails a formatted summary to a stakeholder — with full retry logic, state persistence, and human escalation if the growth rate exceeds a threshold (because anomalous numbers deserve human eyes).

    Setup:

    # Create a test database
    import sqlite3
    
    def setup_test_database(db_path: str = "metrics.db"):
        with sqlite3.connect(db_path) as conn:
            conn.execute("""
                CREATE TABLE IF NOT EXISTS monthly_revenue (
                    month TEXT PRIMARY KEY,
                    revenue_usd REAL,
                    region TEXT
                )
            """)
            conn.executemany(
                "INSERT OR REPLACE INTO monthly_revenue VALUES (?, ?, ?)",
                [
                    ("2024-07", 1_250_000, "North America"),
                    ("2024-08", 1_380_000, "North America"),
                    ("2024-09", 1_895_000, "North America"),  # Big jump — triggers escalation
                ]
            )
            conn.commit()
    

    Your task:

    1. Implement query_database as an async function that runs SELECT queries against metrics.db, returning results as a list of dicts. Add error handling that raises ToolExecutionError(ErrorCategory.RECOVERABLE_LOGIC) for SQL syntax errors.

    2. Implement send_email_report as a stub that prints the email content and records the send in an idempotency table. Use the idempotency pattern from the lesson to ensure it never sends twice.

    3. Wire up run_agent_loop with these two tools, using this task string:

      Query the monthly_revenue table for Q3 2024 (July, August, September). 
      Calculate month-over-month growth rates. If any month shows growth > 30%, 
      flag it as anomalous. Generate a markdown-formatted summary and send it to 
      analytics@company.com with subject "Q3 2024 Revenue Summary".
      
    4. Add the should_escalate_before_action check before send_email_report calls, with a confidence threshold of 0.80. Since September shows 37% growth, the agent should flag the anomaly in its report and the confidence check should trigger human review before sending.

    5. Run the loop, observe the escalation, then use resume_from_human_input to inject the human response: "Confirmed — the September spike is accurate due to enterprise deal close. Proceed with sending."

    6. Verify the final state shows COMPLETING, that the email was only sent once (even if you run the loop twice), and that AgentMetrics.summary() shows exactly one human escalation.


    Common Mistakes & Troubleshooting

    Mistake 1: Treating all retry logic as identical

    The most common mistake is a single retry loop that doesn't distinguish between error types. If the LLM generates invalid SQL and you just retry the same tool call with the same SQL, you'll exhaust your retries without ever solving the problem. The fix is the three-category classification system from this lesson. Always ask: "Is this error transient, a logic error, or unrecoverable?"

    Mistake 2: State drift — saving state before confirming success

    If you save state.transition_to(OBSERVING) before the tool result is confirmed durable, you can end up with a state that says "tool succeeded" when it actually didn't. Save state after the tool result is in hand, not before. The pattern is: execute → record result → save state.

    Mistake 3: Missing the max_steps guard

    An agent that loops forever is not theoretical — it happens when the LLM gets into a pattern where each tool result looks like it should trigger another tool call. The max_steps guard is not a suggestion. Set it conservatively (20-30 steps is plenty for most tasks), and treat ABORTED as a signal to tune your prompt rather than to increase the limit.

    Mistake 4: Escalating without context

    A human reviewer who receives "the agent failed" is useless. A human reviewer who receives the full tool call history, the last error message, and the agent's current reasoning has what they need to make a decision in seconds. Always include summarize_tool_history and the human_input_request context in your escalation notifications.

    Mistake 5: Not testing the resume path

    The happy path gets tested constantly. The resume-from-human-input path almost never does, until it breaks in production at the worst possible moment. Write an integration test that explicitly runs a task to escalation, calls resume_from_human_input, and verifies the final state. Make it part of your CI pipeline.

    Mistake 6: Infinite context accumulation

    Every tool result you feed back into the conversation grows the context window. For long-running agents, you'll eventually hit the model's context limit. Watch your total_tokens_used metric. When it approaches 80% of your model's limit, implement context compression: summarize old tool results, drop intermediate reasoning steps, or use a RAG layer to retrieve relevant history rather than including everything.

    Troubleshooting: The agent loops on OBSERVING → EXECUTING without making progress

    This usually means the tool results are being appended to the message list correctly, but the model isn't recognizing them as resolved. Double-check that your tool_call_id values match between the assistant message (where the tool call is declared) and the tool message (where the result is provided). A mismatch causes OpenAI's API to silently treat the result as malformed, and the model re-requests the same tool.

    Troubleshooting: Human escalation triggers but resume never works

    The most common cause is that build_message_history doesn't handle the __human_input__ synthetic tool call correctly. When replaying history, the human input needs to surface as a user or system message, not as a tool result (since the LLM wasn't the one who called a tool). Consider a different representation: a special assistant message summarizing what the human said, inserted at the correct point in the conversation.


    Summary & Next Steps

    You've now built the core engineering infrastructure for production-grade agentic loops from the ground up. Let's consolidate the key design principles:

    State machines, not implicit flow. Modeling your agent as an explicit state machine with typed states and logged transitions turns debugging from archaeology into structured investigation. The question "what happened?" has a clear answer.

    Error classification is non-negotiable. Three categories — transient, recoverable logic, unrecoverable — determine whether you retry silently, surface to the LLM, or escalate to a human. Conflating these categories is the source of most reliability failures.

    Persistence enables recovery. Every state transition should be persisted before the agent moves on. This isn't overhead — it's the foundation of a system that stakeholders can trust to run unattended.

    Human escalation is a feature, not a failure. Well-designed escalation with rich context, a clean resume path, and idempotent tool execution means humans and agents can collaborate reliably rather than the agent trying to handle everything alone.

    Observe everything. Token counts, step durations, error rates, and escalation frequencies are your telemetry. Without them, you're flying blind.

    Where to go next:

    • Context window management — how to use summarization, sliding window, and retrieval-augmented approaches to handle long-running agents without hitting token limits
    • Multi-agent orchestration — when one agent spawns or delegates to another, the state management and error propagation patterns change significantly
    • Evaluation and testing for agentic systems — how to write deterministic tests for systems with inherent non-determinism, including golden-trace evaluation and failure injection
    • Tool design principles — how to design tools that are easy for LLMs to call correctly, with good descriptions, constrained argument schemas, and informative error messages that enable self-correction
    • Cost optimization — routing simple steps to smaller models, caching repeated tool results, and using structured outputs to reduce hallucination-driven retries

    The gap between an agentic prototype and an agentic system that earns production trust is precisely the engineering discipline covered in this lesson. Build it right from the start.

    Learning Path: Building with LLMs

    Previous

    Implementing Conversational Memory: Managing Context Windows and Chat History at Scale

    Related Articles

    AI & Machine Learning🔥 Expert

    Retrieval-Augmented Generation in Practice: Building Knowledge-Grounded AI Pipelines for Enterprise Data Workflows

    28 min
    AI & Machine Learning⚡ Practitioner

    Tool-Augmented AI Agents: Giving Your Agent Web Search, Code Execution, and API Access

    21 min
    AI & Machine Learning⚡ Practitioner

    Implementing Conversational Memory: Managing Context Windows and Chat History at Scale

    21 min

    On this page

    • Introduction
    • Prerequisites
    • The Mental Model: An Agent Is a State Machine
    • Persisting State: Surviving Crashes and Enabling Replay
    • Tool Execution and Error Classification
    • The Main Agent Loop
    • Human-in-the-Loop Escalation
    • Confidence Scoring and Proactive Escalation
    • Idempotency and Side-Effect Management
    • Observability: Instrumenting the Loop
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • Summary & Next Steps