The Run That Kept Repeating Itself
An on-call bot receives this at 02:14:
user: Which service caused the checkout incident, and what changed in deploy 4471?
Its first version sends one prompt and hopes the model already knows. Its second version is more ambitious: while not done: calling tools as fast as the model proposes them. By 02:16 it has called search_deploy eleven times with identical arguments, the transcript is 90k tokens, and no answer has arrived. Both versions miss the same thing: a cycle with fixed phases and a hard bound on how many times it may run.
The cycle
goal + user request
│
▼
┌───────────────┐
│ THINK │ reason: what is known, what is missing
└──────┬────────┘
▼
┌───────────────┐
│ ACT │ commit to exactly one action
└──────┬────────┘
▼
┌───────────────┐
│ OBSERVE │ run it, append the result to the scratchpad
└──────┬────────┘
▼
goal met? ── no ──► THINK again (step cap not reached)
│
yes / cap hit
▼
answer, or bounded failure
Each phase answers a question the others cannot. Think is where the model decides what to do next. Act is where the world gets touched — one tool call, not a wish list. Observe is where reality is written down. Skipping observe is exactly what produced those eleven identical calls: the model never saw its own previous result, so as far as it knew, nothing had happened yet.
The scratchpad — the list of thought, action, observation triples re-sent every iteration — is the loop's only memory. Without it, each turn starts amnesiac. With it, step 6 can cite the file step 2 read, which is the property that makes multi-tool tasks solvable at all, and the property Evaluating Memory Quality exists to measure in a shipped product.
The bound matters as much as the cycle. while not done: has no answer for a confused model, and confusion is the common case here rather than the exception. A step cap plus a goal check means the loop ends either way: with a result, or with a failure a human can inspect.
A Bounded Think-Act-Observe Loop
The engine is a for loop with an explicit done signal. Each iteration hands the full scratchpad back to the model:
import json
MAX_STEPS = 6
def run_agent(goal, ask, act, emit):
scratchpad = [] # every (thought, action, observation) triple
for step in range(MAX_STEPS): # the cap is structural, not a runtime hope
thought = ask(goal, scratchpad)
action = thought.get("action") # {"tool": ..., "args": ...} or None
if action is None: # the model called done(answer) explicitly
return {"status": "done", "answer": thought["answer"],
"steps": step, "trace": scratchpad}
observation = act(action["tool"], action["args"])
scratchpad.append({"step": step, "action": action,
"observation": observation})
emit("agent.step", {"step": step, "tool": action["tool"],
"result_bytes": len(json.dumps(observation))})
return {"status": "step_cap", "steps": MAX_STEPS, "trace": scratchpad}
Run against the 02:14 request:
goal: Which service caused the checkout incident, and what changed in deploy 4471?
step 0 search_deploy(incident="checkout") → {"service": "payments-api", "deploy": 4471}
step 1 get_diff(deploy=4471) → {"files": 12, "summary": "retry budget 5s → 1s"}
step 2 action=None → answer ready
status=done steps=3
Four lines carry the design:
ask(goal, scratchpad)— the model sees every prior observation, so step 1 acts on what step 0 found. The cost is real: each round re-sends the transcript, so scratchpad size is the loop's main cost lever.action is None— completion is a signal the model emits, not a guess about its prose. Inferring done from fluent text ends runs early with confident wrong answers.observation = act(...)— state stores the tool's return value, never the model's paraphrase of it. Paraphrase is where facts drift.for step in range(MAX_STEPS)— a bug in the done check cannot unbound the run the way awhileloop would.
When the cap hits, the function returns a structured failure carrying the trace instead of the last thing the model said. The guards themselves — iteration cap, goal check, token budget — are the subject of Stopping Conditions and Budgets.
Loop States, Scratchpad, and Trace
Two structural choices decide whether this loop is debuggable a week later.
States as data, not recursion. Write the transitions as named values and assert the legal ones:
THINK ──act──► ACT ──observe──► OBSERVE ──not done──► THINK
▲ │
└────────────────────── done ─────────────────────────────┘
Recursive run() calls hide the transition history inside the call stack, so OBSERVE → ACT — acting twice without looking — is invisible. With an explicit state variable that sequence fails an assertion at the exact line it happened. Keep the scratchpad attached to OBSERVE, because a state you can name is a state you can log, and a state you can log is a state you can compare between a passing run and a failing one.
Failure modes, in the order they show up in real runs:
- Runaway loop.
while not done:with no cap: a confused model re-issues calls until the token budget drains. The cap must be structural — see Stopping Conditions and Budgets. - Echo step. The model repeats its previous action because its observation never reached the prompt. The scratchpad was built but not re-sent, which looks from the model's side exactly like nothing happened.
- Silent finish. The model returns prose with no done signal and no goal check. The loop either keeps working on a finished task or accepts a fluent answer the goal check would have rejected.
- Blind run. No trace events. The run failed at 02:14 and nothing can explain why. Emit one structured event per iteration — step index, action, arguments, observation size, latency — into the same pipeline you use for Tracing LLM Calls, and the run replays offline step by step with the exact inputs.
This gives you a bounded machine that can touch the world. What it does first, in what order, and whether it reorganizes after a surprise is Planning Strategies for Agents, the topic that follows this one.
Quiz
1. What must the loop re-send to the model on every iteration?
2. Why require an explicit done signal instead of inferring completion from the model's prose?
3. What does the OBSERVE phase provide that THINK and ACT cannot?
4. The step cap is reached with no answer. What should run_agent return?
Flashcards
Question
What are the four phases of the core agent loop?
Click to reveal answer
Answer
Think (reason about known and missing), Act (commit to one tool call), Observe (record the real result), then repeat until the goal is met or the step cap hits.
Question
Why must the scratchpad be re-sent every iteration?
Click to reveal answer
Answer
It is the loop's only memory. Omit it and each turn starts amnesiac, so the model re-runs actions whose results it already produced.
Question
Why model the loop as explicit states instead of recursion?
Click to reveal answer
Answer
Named states with asserted transitions make illegal sequences like acting twice without observing fail at a specific line, rather than hiding in the call stack.
Question
What goes into one trace event?
Click to reveal answer
Answer
Step index, action name, arguments, observation size, and latency — enough to replay a failed run offline with its exact inputs.
Question
What does the loop return when the step cap is hit?
Click to reveal answer
Answer
A structured failure status with the full trace, not the last partial answer, so a human can see exactly where progress stopped.
Revision Notes
Key Takeaways
- 1. The loop is think, act, observe — each phase produces what the others cannot, and observe is what stops repeat actions
- 2. The scratchpad is the only memory; it must be re-sent every iteration and is the loop's main cost lever
- 3. Bound the run structurally with a step cap, and end it on an explicit done signal verified against the goal
- 4. One trace event per iteration is what makes a failed run replayable instead of merely unfortunate
Interview Tips
- • Draw the cycle and name what each phase contributes before writing any code
- • Explain why you record the tool return rather than the model's summary of it
- • Distinguish the step cap (guarantees termination) from the goal check (allows early exit) — they solve different failures
- • Say what you log per iteration so you can answer 'how would you debug this run last night?'
Cheat Sheet
The Core Agent Loop — Cheat Sheet
Cycle
THINK → ACT → OBSERVE → (goal met? → answer : THINK again)
Skeleton
for step in range(MAX_STEPS): # cap is structural
thought = ask(goal, scratchpad) # full history every time
action = thought.get('action')
if action is None: # explicit done signal
return {'status': 'done', 'answer': thought['answer'], 'trace': scratchpad}
observation = act(action['tool'], action['args'])
scratchpad.append({"step": step, "action": action, "observation": observation})
emit('agent.step', {...}) # one event per iteration
return {'status': 'step_cap', 'trace': scratchpad}
Rules
- Record the tool's return value, never the model's paraphrase of it
- Completion = emitted signal checked by a goal verifier, not fluent prose
- States as named values with asserted transitions; recursion hides history
- Emit step index, args, observation size, latency → replay failures offline
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
| identical calls until tokens drain | while not done, no cap |
structural for cap + goal check |
| same action re-issued | observation never re-sent | append and re-send scratchpad |
| answer arrives with goal unmet | done inferred from prose | explicit done tool + verifier |
| cannot explain a 02:14 failure | no trace events | per-iteration structured events |