ai_ml / agents orchestration / 02_the_agent_loop.md

The agent loop

6 interview angles 5 min read source

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:
        return response.content              # model decided it's done

    for call in response.tool_calls:
        result = execute(call)               # validate args before this
        messages.append({
            "role": "tool",
            "tool_call_id": call.id,
            "content": truncate(result),      # bound the observation
        })

raise AgentStepLimitExceeded(step)            # loud, not silent

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.",
    })
    continue

Feeding 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 ../../backend/04_async_concurrency/12_taskgroup_structured_concurrency.md.

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 ../06_transformers_llm/05_kv_cache.md and ../12_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 ../../backend/15_observability/07_opentelemetry.md.

Interview angle

  • “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.