Skip to content

Command Palette

Search for a command to run...

Chapter 2·38 min read·Advanced

Agent Loops

The ReAct pattern: reason, act, observe, loop. With stopping conditions, budgets and safety.

What is an agent loop?

An agent loop is the cycle: LLM reasons → decides an action (tool call) → executes → observes result → reasons again → ... → final answer. The ReAct pattern (Reason + Act) is the canonical formulation. The loop continues until the model produces a final answer with no tool calls, OR a stopping condition fires (max iterations, cost budget, timeout).

Why loops need guardrails

Without guardrails, agents loop forever — calling the same tool with paraphrased arguments, burning budget, never converging. Stopping conditions (max_iter, cost budget, timeout) are mandatory in production. So is deduplication of tool calls and a 'reflect and decide' step.

The production agent loop

Task → LLM (with tools + max_iter + budget) → if tool_calls: dedupe against history, validate args, execute with timeout, append observation → if no tool_calls: return final answer → if max_iter or budget exceeded: return partial + escalate. Every step is traced.

Production agent loop

agent_loop.pypython
import json
from dataclasses import dataclass, field

@dataclass
class AgentConfig:
    max_iterations: int = 10
    max_cost_usd: float = 1.0
    timeout_seconds: float = 60.0

@dataclass
class AgentState:
    messages: list[dict] = field(default_factory=list)
    iterations: int = 0
    cost_usd: float = 0.0
    tool_calls_made: list[str] = field(default_factory=list)

def run_agent(task: str, tools: dict, config: AgentConfig) -> str:
    state = AgentState(messages=[{"role": "user", "content": task}])

    while True:
        # Stopping conditions
        if state.iterations >= config.max_iterations:
            return f"[Stopped at max_iterations={config.max_iterations}] " + force_answer(state)
        if state.cost_usd >= config.max_cost_usd:
            return f"[Stopped at cost budget] " + str(config.max_cost_usd) + " " + force_answer(state)

        state.iterations += 1

        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=state.messages,
            tools=[t.definition() for t in tools.values()],
            tool_choice="auto",
        )
        msg = response.choices[0].message
        state.cost_usd += estimate_cost(response.usage)

        # No tool calls → final answer
        if not msg.tool_calls:
            return msg.content

        # Append the assistant message with tool calls
        state.messages.append(msg.model_dump())

        # Execute each tool call
        for tc in msg.tool_calls:
            tool = tools[tc.function.name]
            args = json.loads(tc.function.arguments)

            # Dedupe: skip if we called this exact tool+args recently
            call_sig = f"{tc.function.name}:{json.dumps(args, sort_keys=True)}"
            if call_sig in state.tool_calls_made[-3:]:
                observation = '{"error": "Already called with these args. Try a different approach."}'
            else:
                state.tool_calls_made.append(call_sig)
                observation = tool.execute(args)

            state.messages.append({
                "role": "tool",
                "tool_call_id": tc.id,
                "content": observation,
            })

def force_answer(state: AgentState) -> str:
    """When budget exhausted, ask the model to answer with what it has."""
    state.messages.append({
        "role": "user",
        "content": "Budget exhausted. Provide your best answer with current information.",
    })
    r = client.chat.completions.create(
        model="gpt-4o-mini", messages=state.messages, tools=None
    )
    return r.choices[0].message.content

Experiment: stopping conditions

Toggle guardrails on/off. See how they prevent runaway agents.

Configure agent guardrails. Observe behaviour on a hard task.

What to observe

All three guardrails (max_iter, cost budget, dedup) are mandatory. Missing any one leads to runaway agents. The 'force_answer' fallback on budget exhaustion is critical — the agent must always return SOMETHING, not hang. Trace every step so you can debug.

Production agent loops

Production agents: max_iter + cost budget + timeout (all three), deduplication of recent tool calls, per-tool timeout, trace every step, force_answer on budget exhaustion, and a reflection step before the final answer ('Do I have enough to answer?'). For destructive tools: human approval gate.

Challenge

Your agent loops 25 times on a research task and costs $15. The system prompt says 'be thorough'. Diagnose and fix the loop. (See the 'Agent That Wouldn't Stop' challenge.)

Production checklist

Production checklist

0 of 9 checked

Knowledge check

An agent keeps calling search() with slightly different queries and never converges. Which fix is MOST effective?

Complete

You can now build a production agent loop with guardrails. Next: memory and planning — making agents persist context and decompose tasks.

Mark this chapter as complete

Track your progress and unlock the next chapter.

Email updates

Get the next chapter and new guides by email.

One email per substantial release. Unsubscribe in one click.

We will email you a confirmation link first, then send an occasional email when a substantial guide is published. Unsubscribe in one click. Stored on our own servers, never sold or shared. See the privacy policy.

Continue learning