Part 1: The Core Loop & Controlling It
Key Points
- The agent loop is the ReAct cycle — Thought, Action, Observation — wrapped in orchestration code.
- The LLM only emits a request to call a tool; your code executes it. Every real-world side effect passes through code you control.
- The pivotal step: after a tool executes, its result is appended back into the conversation and the model is called again.
- The loop ends when the model requests no tools (final answer) or a hard iteration cap forces it to stop.
- Model-signaled termination is a hope; the deterministic safety cap is a guarantee. A well-engineered loop always has both, plus timeouts.
The ReAct cycle: thought, action, observation
The agent loop is best understood through the ReAct pattern — Reasoning and Acting. It connects the LLM's "thinking" to external actions through a structured cycle of thought, action, and observation. Think of a chef working through an unfamiliar recipe without reading ahead: read the current step (thought), chop the onions (action), check whether they are fine enough (observation), and only then decide what to do next. The chef interleaves reasoning and acting, one step at a time.
Each iteration proceeds in three stages, mapping directly onto three actors in your code:
| Stage | Who performs it | Input | Output |
| Thought | The LLM | Conversation + tool list | Reasoning about next step |
| Action | The LLM | Reasoning | A tool call + arguments (structured JSON) |
| Observation | Your orchestration code | Tool call + arguments | The tool's return value (result or error) |
Notice that the LLM never touches the outside world directly. It only emits a request; your loop actually executes it and hands back the result. This separation is the foundation of loop safety.
flowchart LR
T["Thought
(LLM reasons)"] --> A["Action
(LLM emits tool call)"]
A --> O["Observation
(your code runs tool)"]
O -->|"append result, recall model"| T
O -.->|"no tool call = done"| F["Final Answer"]
[Animation slot: the ReAct spiral — show thought/action/observation returning to the same three stages, but with the conversation transcript growing longer each loop.]
Appending results and terminating
The single most important mechanical detail: after a tool executes, its result is appended back to the conversation, and the model is called again. The conversation grows like an accumulating transcript — each [tool] message is an observation the model sees on its next call. This is why the loop is drawn as a spiral rather than a circle: it returns to the same three stages, but with more context each time.
A ReAct loop must stop, and there are two fundamentally different ways it can. First, the model signals completion: in modern function-calling agents, it responds with no tool calls. Second, a hard safety cap is hit: a maximum number of loops forces termination even if the model never signals completion. This is explicitly a safety mechanism, not a convenience — it prevents infinite loops when the model enters a repetitive cycle.
Figure 6.2: A multi-step tool run for rescheduling an appointment
sequenceDiagram
participant U as User
participant O as Orchestration loop
participant M as Model (LLM)
participant T as Tools (calendar)
U->>O: "Move dentist appt, tell my wife"
O->>M: Conversation + tool list
M-->>O: tool_call calendar.find
O->>T: calendar.find(query="dentist")
T-->>O: {id: evt_88, start: 15:00}
O->>M: Append observation, recall
M-->>O: tool_call calendar.free_slots
O->>T: calendar.free_slots(next week)
T-->>O: [slot1, slot2]
O->>M: Append observation, recall
M-->>O: tool_call calendar.move
O->>T: calendar.move(id=evt_88)
T-->>O: {ok: true}
O->>M: Append observation, recall
M-->>O: Final answer (no tool calls)
O-->>U: "Done, and I messaged your wife."
Figure 6.1: The read-act-observe loop with its termination and max-iteration branches
flowchart TD
A["Start: user message + tools"] --> B{"Iterations < max?"}
B -->|"no"| F["Force final answer (partial)"]
B -->|"yes"| C["READ: call model with full conversation"]
C --> D["Append model response to conversation"]
D --> E{"Tool calls present?"}
E -->|"no (final answer)"| G["Return response text"]
E -->|"yes"| H["ACT + OBSERVE: execute each tool"]
H --> I["Append tool results as observations"]
I --> B
F --> Z["End"]
G --> Z
Controlling the loop: budgets, guardrails, timeouts
Left unbounded, an agent loop is a liability. An IAL occurs when "an agentic feedback path repeatedly triggers execution without an effective stopping bound." The dominant consequence is API cost exhaustion in 95.6% of cases. The first and most important control is the max iterations cap, recommended at 10–15 for most task-completion agents. Beyond iteration count, production agents also track token budgets and cost budgets, because a loop with few iterations but enormous per-step context can still be ruinously expensive.
A robust agent layers its defenses rather than trusting any single mechanism:
- Prompt-level stop instructions give the LLM a lexical target ("respond directly without calling any tool") — eliminating ~60–70% of loop incidents in simple agents, but not sufficient alone.
- Code-level guardrails — a
max_iterations cap, a LoopDetector, and a state TTL counter — are non-negotiable for production and are the primary layer.
- Duplicate-call debouncing — a
DebounceHook blocks a tool called with identical parameters too many times, returning "BLOCKED: Duplicate call."
The cardinal principle: deterministic stopping bounds are safer than model-dependent termination. Never let the model be the only thing that decides when to stop, and place each bound inside the actual feedback path that repeats. Iteration caps bound how many steps run, but not how long they take — so production loops add wall-clock timeouts (per-step and overall run) and support cancellation for interactive assistants where a user may abandon a request mid-loop.
[Animation slot: layered defenses — show a runaway loop passing through prompt instruction, then max_iterations, then debounce/timeout gates, illustrating defense in depth.]
Part 2: State Across Steps & Reliability and Observability
Key Points
- The scratchpad is the agent's working memory — a dynamically growing record of thoughts, actions, and observations that lets it chain dependent steps without repeating work.
- Because the scratchpad re-sends everything each iteration, long loops drive token cost (roughly quadratic), latency, and context-window exhaustion. Compact and truncate observations inside the loop.
- ReAct interleaves reasoning and acting step by step; Plan-and-Execute plans once up front, then executes — a trade-off of adaptability vs. efficiency.
- Instrument every loop with an end-to-end trace: one span per LLM call, per tool call, and per state transition. Log intermediate tool responses separately.
- Handle partial failures by feeding errors back as observations, but always bound the recovery (retry caps, debouncing) so error-feedback never becomes an infinite loop.
Accumulating state: the scratchpad
The agent loop is only useful because it remembers. Each tool result is appended to the conversation as an observation, and the mechanism that formalizes this accumulation is the scratchpad: an area storing the agent's sequence of thoughts, actions, and observations from previous steps. LangChain's create_react_agent manages this by appending each Thought, Action, and Observation to a dynamically growing string inside the prompt, giving the model full context of prior steps so it does not repeat work.
The analogy is a detective's notepad. Each interview (tool call) yields a fact jotted down. Before the next interview, the detective glances back over every note, so they never ask the same question twice. Remove the notepad and the detective would interrogate the same witness in circles — exactly the duplicate-call failure the DebounceHook guards against.
The scratchpad holds two kinds of content: intermediate reasoning (thoughts) — the "R" in ReAct — and tool observations, the raw results returned by your tools. This dual nature reflects a broader design contrast:
| Dimension | ReAct (interleaved) | Plan-and-Execute |
| When it reasons | At every step, using latest observation | Once up front, then executes |
| Adaptability | High — course-corrects each step | Lower — plan may go stale |
| Scratchpad growth | Grows every iteration | Plan fixed; execution logs grow |
| Best for | Uncertain, exploratory tasks | Well-defined, predictable workflows |
| Model calls | One per step (more expensive) | Fewer reasoning calls |
Context growth during long loops
Because the scratchpad is a dynamically growing string, every iteration makes the prompt larger. A ten-step loop re-sends everything from steps 1 through 9 on step 10. This has three compounding costs: token cost grows roughly quadratically, latency increases as context lengthens, and context-window exhaustion becomes a hard failure mode. The mitigations are the context-management techniques from Chapter 3, now applied inside the loop: summarize or compact old observations, truncate verbose tool outputs, and drop intermediate reasoning that has served its purpose. A tool returning a 50 KB JSON blob should be distilled to the few fields the model actually needs before it enters the scratchpad.
[Animation slot: the growing transcript — visualize the scratchpad re-sending steps 1..N-1 on each call, with the token count rising quadratically, then show truncation/compaction flattening the curve.]
Reliability: partial failures and observability
An agent loop that works in a demo can fail in a hundred quiet ways in production. Failures are rarely a single API error; they are sequences of decisions, tool calls, and routing events, each of which must be individually traceable. Agent observability rests on the classic triad — traces, metrics, logs — and the central artifact is an end-to-end trace with a span for each LLM call, each tool call, and each state transition. A span is a timed, named record of one unit of work with its inputs, outputs, and metadata; a trace is the tree of spans for one complete run.
Two production practices are worth committing to memory: instrument intermediate tool responses as separate spans (silent malformations that agents "hallucinate around" won't surface in final-response logging alone), and call flush_traces() in background workers so queue consumers and cron jobs don't lose telemetry.
In a multi-step loop, some steps will fail. The wrong answer is to crash the whole loop on the first tool error. The right answer is to catch the error and feed it back to the model as an observation (e.g., {"error": "calendar service unavailable"}). The model can then reason about it: retry, try a different tool, or tell the user it couldn't complete that step. But recovery must itself be bounded, or you recreate the top IAL failure pattern: retry feedback without bounds accounts for 25% of infinite-loop cases. Cap retries explicitly and let the duplicate-call debouncer catch a model that keeps re-issuing the same failing call.
Figure 6.3: Handling a partial tool failure as a bounded, recoverable observation
flowchart TD
A["Execute tool call"] --> B{"Tool succeeded?"}
B -->|"yes"| C["result = return value"]
B -->|"no"| D["Catch ToolError"]
D --> E["result = {error: message}"]
C --> F["Append result as observation"]
E --> F
F --> G{"Retries within max_retry?"}
G -->|"no"| H["Stop: report partial to user"]
G -->|"yes"| I{"Duplicate call blocked?"}
I -->|"yes (BLOCKED)"| H
I -->|"no"| J["Model reasons on next iteration"]
Preventing infinite loops: defense in depth
Everything assembles into a defense-in-depth checklist against the single most dangerous failure of an agent:
| Defense | What it stops | Layer |
| Prompt-level stop instruction | ~60–70% of loops in simple agents | Prompt |
max_iterations (10–15) | Tool-call iteration without bounds | Code (deterministic) |
max_retry cap | Retry feedback without bounds | Code (deterministic) |
| DebounceHook / duplicate detection | Repeated identical tool calls | Code (deterministic) |
| State TTL / reentry counter | Message reentry without bounds | Code (deterministic) |
| Per-step & overall timeouts | Hung tools and stalled runs | Code (deterministic) |
| Per-tool-call spans + retry metrics | Detecting loops that slip through | Observability |
Two root-cause reminders anchor the checklist: exit conditions must not rely solely on LLM outputs, and the bound must sit inside the actual feedback path — a limit placed outside the loop that repeats will never fire.