AI EngineeringZero to ProductionHome·About·Contact
Agents & Tools · Part 4.2 · Intermediate

Agent patterns

In 4.1 you built the basic agent loop: the model looks at a goal, calls a tool, reads the result, and repeats until done. That loop is the engine — but a bare loop is not a strategy. This chapter is about the named control-flow patterns that decide how the agent reasons: ReAct (think, act, observe, repeat), plan-and-execute (plan upfront, then run the plan), reflection (draft, critique, revise), and routing (classify first, then dispatch). You will also learn the error-handling and retry control flow that keeps any of these patterns from falling over when a tool misbehaves. Every lab runs offline against a deterministic fake decider so you can see the exact shape of the control flow.

⏱️ ~95 min🤖 Agents🎯 Intermediate🐍 Runnable Python
🤖 What runs here, and what a real model replacesEvery lab in this chapter runs right here, offline, with no key. The trick is that we replace the LLM with a tiny deterministic fake decider — a plain Python function that returns the same “thought” or “plan” every time. That lets you watch the control flow — the loop, the branch, the retry — without a network call. In production you swap that one function for a real model call (e.g. claude-opus-4-8); the surrounding control flow is identical. Any traces shown are illustrative, not benchmarks.

Learning objectives

  • Recall the basic agent loop from 4.1 and explain why it needs a reasoning structure on top.
  • Implement the ReAct pattern — interleave Thought → Action → Observation until the task is solved.
  • Implement plan-and-execute and say precisely how it differs from ReAct.
  • Add a reflection (self-critique) step that revises a first draft.
  • Build a router that classifies a request and dispatches to the right sub-flow.
  • Wrap tool calls in error handling with capped retries that feed the error back to the model.
  • Pick the right pattern for a given task instead of reaching for one reflex.

1 · Recap — the loop needs a strategy

In 4.1 the agent loop was: give the model a goal and a set of tools; it emits a tool call; you run the tool and hand back the result; repeat until the model says it is done. That is the mechanism. What it does not tell you is how the model should decide what to do at each step — and for anything beyond one tool call, that structure is what separates a reliable agent from one that flails.

The patterns below are the well-known, publicly documented ways to impose that structure. They are not competing products; they are shapes of control flow. A production agent often composes several: route first, then run a plan, reflecting on the risky step, retrying any tool that fails.

Goal from user Reasoning pattern ReAct / plan / reflect / route Tools run + observe Result done or loop
The distinction to hold ontoThe loop (4.1) is how the agent acts. A pattern (this chapter) is how the agent thinks inside that loop. You will layer robust tool design (4.3), reliability and guardrails (4.4), and full production systems (4.5) on top of these patterns later.

2 · ReAct — reason, act, observe, repeat

ReAct (Reason + Act) is the workhorse pattern for multi-step reasoning. Instead of guessing the whole answer at once, the model alternates between a Thought (private reasoning about what to do next), an Action (a concrete tool call), and an Observation (the tool's result). It reads the observation, thinks again, and repeats until it can answer. The interleaving is the point: each action is chosen in light of what the previous one returned.

Thought what next? Action call a tool Observation read result Answer when done

Here is a full ReAct loop solving a 2-step task — look up two facts, then combine them. The fake_model() stands in for the LLM: given the running trace, it returns the next Thought+Action. In production this one function becomes a model call; the loop around it does not change.

python · react loop (runnable — click ▶ Open in terminal)
react.py# ReAct: interleave Thought -> Action -> Observation until we can answer.
# The 'model' is a deterministic fake so this runs offline; swap it for a real
# LLM call and the control flow is IDENTICAL.

TOOLS = {
    'population': lambda city: {'Paris': 2_100_000, 'Lyon': 500_000}[city],
}

def fake_model(trace):
    """Return the next 'Thought' + 'Action' given the trace so far.
    A real LLM replaces this; it reads the same trace and emits the same shape."""
    obs = [line for line in trace if line.startswith('Observation:')]
    if len(obs) == 0:
        return ('Thought: I need the population of Paris first.',
                ('population', 'Paris'))
    if len(obs) == 1:
        return ('Thought: Now I need the population of Lyon.',
                ('population', 'Lyon'))
    return ('Thought: I have both; I can answer now.', ('finish', None))

def react(goal, max_steps=6):
    trace = [f'Goal: {goal}']
    facts = []
    for step in range(max_steps):
        thought, (tool, arg) = fake_model(trace)
        trace.append(thought)
        if tool == 'finish':
            total = sum(facts)
            trace.append(f'Answer: combined population = {total:,}')
            break
        trace.append(f'Action: {tool}({arg!r})')
        result = TOOLS[tool](arg)
        facts.append(result)
        trace.append(f'Observation: {result:,}')
    return trace

for line in react('total population of Paris and Lyon'):
    print(line)
Goal: total population of Paris and Lyon
Thought: I need the population of Paris first.
Action: population('Paris')
Observation: 2,100,000
Thought: Now I need the population of Lyon.
Action: population('Lyon')
Observation: 500,000
Thought: I have both; I can answer now.
Answer: combined population = 2,600,000

Notice the loop never assumes how many steps it takes — it keeps going until the model emits finish, bounded by max_steps. That decide-as-you-go quality is ReAct's strength: the agent adapts to what each observation reveals. Its cost is that it thinks step by step, so it can wander if the model is weak or the tools are noisy.

Why the trace mattersThe full Thought/Action/Observation trace is the model's working memory — you pass it back in every step so the next decision is informed. Keeping that trace clean and inspectable is also how you debug an agent later (trajectory evaluation lives in 4.4).

3 · Plan-and-execute — decide the whole path first

Plan-and-execute flips the order. The model first writes a plan — an explicit, ordered list of steps — and only then executes each step in turn. Where ReAct decides the next move after every observation, plan-and-execute commits to the path upfront. That makes the agent cheaper and more predictable for tasks whose shape is known, at the cost of adaptability if reality diverges from the plan.

python · plan and execute (runnable — click ▶ Open in terminal)
plan_execute.py# Plan-and-execute: model writes an ordered plan, then we run each step.
# fake_planner() is deterministic; a real LLM would generate the plan text.

STEP_TOOLS = {
    'fetch_orders': lambda: [12, 7, 31],
    'sum_orders':   lambda xs: sum(xs),
    'format':       lambda n: f'Total orders this week: {n}',
}

def fake_planner(goal):
    """Return an ordered plan (list of step names). A real LLM writes this."""
    return ['fetch_orders', 'sum_orders', 'format']

def execute(goal):
    plan = fake_planner(goal)
    print('PLAN:')
    for i, step in enumerate(plan, 1):
        print(f'  {i}. {step}')
    print('EXECUTE:')
    state = None
    for step in plan:
        if step == 'fetch_orders':
            state = STEP_TOOLS[step]()
        elif step == 'sum_orders':
            state = STEP_TOOLS[step](state)
        elif step == 'format':
            state = STEP_TOOLS[step](state)
        print(f'  ran {step} -> {state!r}')
    return state

print('RESULT:', execute('report this week\'s order total'))
PLAN:
  1. fetch_orders
  2. sum_orders
  3. format
EXECUTE:
  ran fetch_orders -> [12, 7, 31]
  ran sum_orders -> 50
  ran format -> 'Total orders this week: 50'
RESULT: Total orders this week: 50
ReActPlan-and-execute
When decisions happenAfter every observation (as you go).All upfront, before any tool runs.
Best forOpen-ended, exploratory tasks where the next step depends on the last result.Known, repeatable workflows with a predictable shape.
WeaknessCan wander; more model calls (one per step).Brittle if reality diverges from the plan; may need a re-plan step.
Hybrids are commonReal systems often re-plan: execute the plan, but if a step fails or an observation contradicts an assumption, hand control back to the planner to revise the remaining steps. That blends plan-and-execute's predictability with ReAct's adaptability.

4 · Reflection — draft, critique, revise

Reflection (self-critique) adds a quality gate: after producing a first attempt, the model critiques its own output against the requirements, then revises. It is the agent equivalent of “read your work before you hand it in.” You reach for it when correctness matters more than latency — the extra pass costs time and tokens but catches mistakes a single shot would ship.

Draft first attempt Critique find the flaw Revise fix it
python · reflection loop (runnable — click ▶ Open in terminal)
reflection.py# Reflection: draft -> critique -> revise. All three 'model' calls are faked
# deterministically here; a real LLM replaces each, control flow unchanged.

REQUIRED = ['cause', 'fix']   # a good incident summary must mention both

def fake_draft(task):
    return 'The checkout service went down.'   # incomplete on purpose

def fake_critique(text):
    """Return a list of missing requirements. A real LLM judges the draft."""
    missing = []
    if 'queue' not in text.lower():
        missing.append('cause')
    if 'restart' not in text.lower() and 'scale' not in text.lower():
        missing.append('fix')
    return missing

def fake_revise(text, missing):
    add = {'cause': ' The queue backed up.',
           'fix': ' Drain the queue and scale the consumer group to fix it.'}
    for m in missing:
        text += add[m]
    return text

def reflect(task, max_rounds=3):
    draft = fake_draft(task)
    print(f'draft: {draft}')
    for r in range(max_rounds):
        missing = fake_critique(draft)
        if not missing:
            print(f'round {r}: looks complete, stopping.')
            break
        print(f'round {r}: missing {missing} -> revising')
        draft = fake_revise(draft, missing)
    return draft

print('final:', reflect('summarize the checkout incident'))
draft: The checkout service went down.
round 0: missing ['cause', 'fix'] -> revising
round 1: looks complete, stopping.
final: The checkout service went down. The queue backed up. Drain the queue and scale the consumer group to fix it.
Cap the reflection loopSelf-critique can loop forever if the critic is never satisfied — always bound it with a max_rounds and stop when the critique comes back clean. A reflection loop with no cap is a common way to burn tokens (and money) on an agent that never returns.

5 · Routing — classify first, then dispatch

When an agent faces heterogeneous requests — a billing question, a bug report, a how-to — you do not want one giant prompt trying to handle everything. Routing puts a lightweight classifier step first: it reads the request, picks the right sub-flow (or tool, or specialized model), and dispatches to it. Each downstream handler stays small and focused, which makes the whole system easier to reason about and cheaper to run.

python · router (runnable — click ▶ Open in terminal)
router.py# Routing: a classifier picks the handler, then we dispatch to it.
# fake_route() is deterministic; a real LLM (or a small classifier) replaces it.

def fake_route(request):
    """Classify the request into an intent. A real model does this step."""
    r = request.lower()
    if any(w in r for w in ('refund', 'charge', 'invoice')):
        return 'billing'
    if any(w in r for w in ('error', 'crash', 'broken', 'down')):
        return 'bug'
    return 'general'

HANDLERS = {
    'billing': lambda q: f'[billing] looking up your account for: {q}',
    'bug':     lambda q: f'[bug] opening a ticket for: {q}',
    'general': lambda q: f'[general] answering: {q}',
}

def agent(request):
    intent = fake_route(request)
    return intent, HANDLERS[intent](request)

for req in ['I want a refund on invoice 88',
            'the checkout page is down',
            'what are your hours?']:
    intent, reply = agent(req)
    print(f'{intent:8} <- {req!r}')
    print(f'         {reply}')
billing  <- 'I want a refund on invoice 88'
         [billing] looking up your account for: I want a refund on invoice 88
bug      <- 'the checkout page is down'
         [bug] opening a ticket for: the checkout page is down
general  <- 'what are your hours?'
         [general] answering: what are your hours?

The router itself is trivial control flow — classify, then look up a handler. The value is architectural: each handler is a small, testable unit, and you can give each one its own tools, prompt, or even a cheaper model. A real classifier is usually a quick model call or a small trained classifier; the dispatch table stays exactly as shown.

6 · Error handling & retries in the loop

Tools fail. A call raises an exception, times out, or returns junk that does not parse. A naive agent crashes or, worse, feeds the garbage forward. The robust move is to catch the failure, feed the error back to the model as an observation, and let it retry — bounded by a maximum attempt count so a persistently broken tool cannot loop forever.

Call tool attempt Error? exception / junk Feed error back as observation Retry (capped) if attempts left Give up when cap hit
python · retry wrapper (runnable — click ▶ Open in terminal)
retries.py# Capped retries: catch the failure, feed the error back, try again up to a limit.
# The 'tool' here fails the first two times, then succeeds -- deterministic, no
# randomness or clock. A real agent feeds the error text back to the LLM.

class ToolError(Exception):
    pass

def flaky_tool(state):
    """Fails until it has been nudged twice; then returns a value."""
    if state['attempts'] < 2:
        raise ToolError(f"transient failure #{state['attempts'] + 1}")
    return 'OK: fetched 42 records'

def call_with_retries(tool, max_attempts=3):
    state = {'attempts': 0}
    last_error = None
    for attempt in range(1, max_attempts + 1):
        try:
            result = tool(state)
            print(f'attempt {attempt}: success -> {result}')
            return result
        except ToolError as e:
            last_error = str(e)
            print(f'attempt {attempt}: caught {last_error!r}; feeding back + retrying')
            state['attempts'] += 1        # what the model would learn from the observation
    print(f'giving up after {max_attempts} attempts; last error: {last_error!r}')
    return None

call_with_retries(flaky_tool)
attempt 1: caught 'transient failure #1'; feeding back + retrying
attempt 2: caught 'transient failure #2'; feeding back + retrying
attempt 3: success -> OK: fetched 42 records

The cap is not optional. Without max_attempts a tool that is genuinely broken (bad credentials, a deleted resource) turns your agent into an infinite loop. Feeding the error text back matters too: a good model reads “invalid date format” and fixes its argument on the next try instead of repeating the same mistake.

Retries are not a fix for a bad toolRetrying masks transient failures (a blip, a rate limit). It does not fix a tool that is wrong every time — that just wastes attempts. Distinguish retryable errors (timeouts, 429s) from terminal ones (auth failure, not-found) and give up immediately on the terminal kind. Tool design that surfaces clear, machine-readable errors is covered in 4.3.

7 · Which pattern when

The patterns are not ranked — each fits a shape of problem. Reach for the simplest one that covers the task, and compose them only when the task genuinely needs it.

SituationReach forWhy
One tool answers itBasic loop (4.1)No structure needed — call the tool, return the result.
Multi-step reasoning, next step depends on the lastReActInterleaving thought/action/observation lets the agent adapt to each result.
A known, repeatable workflowPlan-and-executeCommitting to the path upfront is cheaper and more predictable than deciding each step.
Correctness matters more than speedReflectionA critique-and-revise pass catches mistakes a single shot would ship.
Many different kinds of requestRoutingClassify first so each handler stays small, focused, and independently testable.
Any pattern touching flaky toolsRetries (wrap the tool)Catch, feed the error back, retry with a cap — orthogonal to the pattern above it.
They stackThese are not either/or. A support agent might route to a billing sub-agent, which runs a plan, executes each step with ReAct when a step is open-ended, reflects before sending a refund, and wraps every tool call in retries. The skill is choosing the least structure that makes the task reliable.
✓ Knowledge check

You are building an agent for a fixed nightly report with the same three steps every time. ReAct or plan-and-execute — and why?

Show answer
Plan-and-execute. The workflow is known and repeatable, so committing to the ordered plan upfront is cheaper (fewer model calls) and more predictable than re-deciding the next step after every observation. ReAct's adapt-as-you-go strength is wasted when the path never changes.
✓ Knowledge check

Why must a reflection loop and a retry loop both have a hard cap?

Show answer
Because both can run forever otherwise. A critic that is never satisfied keeps requesting revisions; a tool that is genuinely broken keeps raising the same error. A max_rounds / max_attempts cap guarantees the agent terminates and stops burning tokens and money on a task it cannot complete.

🪜 Practice — from one pattern to a composed agent beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Add a third fact to ReActBeginner

Extend react.py so the goal is the combined population of Paris, Lyon, and a third city. Add the city to the population tool and a matching branch in fake_model.

Show solution Add e.g. 'Nice': 340_000 to the tool dict, then add an if len(obs) == 2 branch in fake_model that returns a Thought+Action for Nice, and shift the finish branch to len(obs) == 3. The loop and everything else stay the same.
Exercise 2 · Make the plan re-planIntermediate

In plan_execute.py, make sum_orders fail on an empty list, and have execute call the planner again to insert a fetch_orders retry step before continuing.

Show solution Catch the failure inside the execute loop; on failure, call fake_planner again (or splice a recovery step into the remaining plan) and resume. This is the re-plan hybrid — plan-and-execute borrowing ReAct's adaptability.
Exercise 3 · Critique against real requirementsIntermediate

Change reflection.py so the critic checks the actual REQUIRED list (cause, fix) by mapping each requirement to a keyword, instead of hard-coding the two checks.

Show solution Build a dict like {'cause': 'queue', 'fix': 'scale'} and loop over REQUIRED, appending any requirement whose keyword is absent. Now adding a new requirement is a one-line change.
Exercise 4 · Route to a real sub-flowAdvanced

In router.py, make the bug handler run the ReAct loop from section 2 (or a small version of it) instead of returning a canned string, so routing dispatches to an actual pattern.

Show solution Import or inline a mini ReAct loop and call it from the bug handler. This is the real shape of composition: the router picks the sub-agent, and the sub-agent runs its own pattern with its own tools.
Exercise 5 · Distinguish retryable from terminal errorsExpert

Extend retries.py with a second exception type TerminalError (e.g. auth failure) that is not retried. Show that a terminal error gives up immediately while a transient one still retries.

Show solution Add class TerminalError(Exception), catch it in its own except that returns/raises without retrying, and keep the ToolError branch as the retryable path. Retrying an auth failure just wastes attempts — fail fast.
Exercise 6 · Compose all fourProfessional

Sketch (in code or clear pseudocode) a single agent that routes a request, runs a plan for one branch, uses ReAct for an open-ended step, and reflects before returning — with every tool call wrapped in retries. Explain where each pattern earns its keep.

Show solution Route at the top; each branch is a handler. A known branch calls execute(plan); an open-ended branch calls react(); wrap the risky output in a reflect() gate; wrap every tool in call_with_retries. Justify each: routing keeps handlers small, plan for predictability, ReAct for adaptability, reflection for quality, retries for resilience.

Context: A teammate has an agent stuck in a loop that keeps calling the same failing tool and never returns, and another that ships low-quality answers on important requests.

Your task: Write a short note (5–8 sentences) recommending which pattern(s) to apply to each problem and what control-flow change fixes it.

Requirements:

  • For the looping agent, name capped retries and the distinction between retryable and terminal errors.
  • For the quality problem, name reflection (draft → critique → revise) with a bounded max_rounds.
  • State clearly that the fake decider in these labs becomes a real model call and the control flow is unchanged.
  • Note that patterns compose, so both fixes can live in one agent.

💡 Hint: You don't need production code — this is about matching a symptom to the right pattern. Robust tool design and observability come in 4.3 and 4.4.

✓ Checkpoint — you can move on when you can…

  • Explain why the basic loop (4.1) needs a reasoning pattern layered on top.
  • Implement ReAct — Thought → Action → Observation, repeating until an answer, bounded by max steps.
  • Implement plan-and-execute and state exactly how it differs from ReAct (plan upfront vs decide-as-you-go).
  • Add a reflection loop (draft → critique → revise) with a hard cap on rounds.
  • Build a router that classifies a request and dispatches to a focused handler.
  • Wrap a tool in capped retries that feed the error back, and tell retryable from terminal failures.
  • Choose the least structure that makes a given task reliable — and compose patterns when it genuinely needs it.