The agent loop
The mechanism underneath every agent framework. Being able to write it from memory, and name where it breaks, is the practical test.
ReAct
Reason + Act. The model alternates between thinking and calling tools, feeding each observation back in.
Thought: I need the customer's order history.
Action: search_orders(email="a@b.com")
Observation: [3 orders, most recent #1042 shipped 2 days ago]
Thought: #1042 is the likely subject. Check its tracking.
Action: get_tracking(order_id="1042")
Observation: Delivered yesterday 14:20.
Thought: I can answer now.
Answer: Order #1042 was delivered yesterday at 14:20.Modern implementations don’t parse Thought:/Action: from text — native tool calling returns structured calls. But the loop shape is the same, and “ReAct” still names it.
messages = [{"role": "user", "content": query}]
for step in range(MAX_STEPS):
response = llm(messages, tools=TOOLS)
messages.append(response)
if not response.tool_calls:
# model decided it's done
return response.content
for call in response.tool_calls:
# validate args before this
result = execute(call)
messages.append({
"role": "tool",
"tool_call_id": call.id,
# bound the observation
"content": truncate(result),
})
# loud, not silent
raise AgentStepLimitExceeded(step)Four things in that snippet that separate working code from a demo:
MAX_STEPS— always. Without it a confused agent loops until your bill notices.truncate(result)— an unbounded tool result can blow the context window in one call.- Validate arguments before executing. The model produces plausible-looking arguments that don’t always satisfy your schema.
- Fail loudly on step exhaustion. Silently returning a partial answer hides the failure.
Termination
Deciding when to stop is where naive loops break.
| Condition | Why |
|---|---|
| Model returns no tool call | the normal exit |
| Step limit reached | the safety net — always set one |
| Token/cost budget exhausted | protects against expensive loops |
| Wall-clock timeout | protects the caller’s latency budget |
| Repeated identical call | loop detection; see below |
Explicit finish tool |
makes completion a deliberate decision |
An explicit finish(answer) tool is underrated: it makes “I’m done” an action the model takes rather than an absence you infer, and it lets you enforce a schema on the final answer.
Loop detection
The most common runtime failure is an agent repeating the same call because the result isn’t what it wanted.
seen = collections.Counter()
key = (call.name, json.dumps(call.args, sort_keys=True))
seen[key] += 1
if seen[key] > 2:
# Don't just abort - tell the model what's happening
messages.append({
"role": "tool", "tool_call_id": call.id,
"content": f"You have called {call.name} with these arguments "
f"{seen[key]} times and received the same result. "
f"Try a different approach or explain what's blocking you.",
})
continueFeeding the observation back is better than aborting — the model frequently recovers when told it’s stuck. Aborting turns a recoverable situation into a failure.
Parallel tool calls
Models can return several tool calls in one response. Executing them concurrently is usually free latency:
async with asyncio.TaskGroup() as tg:
tasks = {c.id: tg.create_task(execute_async(c)) for c in response.tool_calls}Two cautions: tools with side effects may not be safe to run concurrently, and every result must be appended — a missing tool_call_id response breaks the message sequence with most providers. See TaskGroup and Structured Concurrency.
Context growth
Every observation is appended, so the prompt grows monotonically. By step 10 you may be re-sending a large transcript on every call.
Mitigations, roughly in order of preference:
- Return less. Summarise or filter tool output at the tool, not afterwards.
- Truncate old observations, keeping the most recent verbatim.
- Summarise middle history while keeping the first message (the task) and recent turns.
- Externalise — write large results to a store, put a reference in context.
- Sub-agents — delegate a subtask to a fresh context and return only its conclusion.
Note that rewriting history invalidates prefix caching from the point of change, so a summarisation that saves tokens can cost more in re-prefill. Measure both. See KV cache and Context engineering.
Planning variants
| Pattern | Idea | Trade-off |
|---|---|---|
| ReAct | decide one step at a time | adaptive; can wander |
| Plan-and-execute | plan all steps up front, then run | cheaper, auditable; brittle if reality differs |
| Reflexion | act, critique the result, retry | better quality, more calls |
| Tree of thoughts | explore several branches | expensive; rarely worth it in production |
Plan-and-execute is genuinely useful when you want the plan reviewable before anything executes — it turns an opaque process into something a human can approve.
Observability
An agent without tracing is undebuggable. Log per step: the model call, its inputs and outputs, tool name and arguments, result, latency, tokens, cost.
with tracer.start_span("agent.step", attributes={"step": step}):
...OpenTelemetry has GenAI semantic conventions for exactly this, so agent traces sit alongside your normal service traces rather than in a separate tool. LangSmith, Langfuse and similar build on the same idea. See OpenTelemetry.
Interview angle 6
- “Describe the agent loop.” — call the model with the tool schemas; if it returns tool calls, execute them and append the results as tool messages; repeat until it returns a plain answer or a limit trips. ReAct is the name for the reason-then-act alternation.
- “How do you stop an agent looping forever?” — a step limit and a cost/time budget as hard stops, plus loop detection on repeated identical calls. Feed the detection back to the model as an observation rather than aborting; it often recovers.
- “What blows up the context in an agent?” — unbounded tool results. Truncate or summarise at the tool boundary, externalise large payloads, and note that rewriting history to save tokens invalidates the prefix cache.
- “The model returns three tool calls at once. What do you do?” — execute concurrently where the tools are side-effect-free, and append a result for every
tool_call_id— a missing one breaks the message sequence. - “ReAct vs plan-and-execute?” — ReAct decides one step at a time and adapts; plan-and-execute commits to a plan up front, which is cheaper and auditable but brittle when reality diverges. Plan-first is valuable when a human should approve before anything runs.
- “How do you debug an agent that gave a wrong answer?” — per-step traces of model input/output, tool calls, arguments, results, latency and tokens. Without that you’re guessing. OpenTelemetry GenAI conventions put it in the same tracing system as everything else.