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.
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.
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.
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.
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.
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.
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
| ReAct | Plan-and-execute | |
|---|---|---|
| When decisions happen | After every observation (as you go). | All upfront, before any tool runs. |
| Best for | Open-ended, exploratory tasks where the next step depends on the last result. | Known, repeatable workflows with a predictable shape. |
| Weakness | Can wander; more model calls (one per step). | Brittle if reality diverges from the plan; may need a re-plan step. |
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.
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.
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.
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.
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.
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.
| Situation | Reach for | Why |
|---|---|---|
| One tool answers it | Basic loop (4.1) | No structure needed — call the tool, return the result. |
| Multi-step reasoning, next step depends on the last | ReAct | Interleaving thought/action/observation lets the agent adapt to each result. |
| A known, repeatable workflow | Plan-and-execute | Committing to the path upfront is cheaper and more predictable than deciding each step. |
| Correctness matters more than speed | Reflection | A critique-and-revise pass catches mistakes a single shot would ship. |
| Many different kinds of request | Routing | Classify first so each handler stays small, focused, and independently testable. |
| Any pattern touching flaky tools | Retries (wrap the tool) | Catch, feed the error back, retry with a cap — orthogonal to the pattern above it. |
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
Why must a reflection loop and a retry loop both have a hard cap?
Show answer
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.
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.
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, callfake_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.
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.
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 thebug 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.
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
Addclass 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.
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 callsexecute(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.