Novel agent architectures
ReAct is the floor, not the ceiling. When a single reason–act loop stalls, the frontier moves to search (tree/graph exploration with backtracking), reflection (self-critique loops), planner–executor splits, richer memory architectures, and multi-agent debate. This lesson builds each as a mechanism, models a small tree-search agent loop offline in pure Python, and reasons about when the extra structure earns its cost.
Learning objectives
- State the limits of the ReAct loop and the failure modes that motivate richer architectures.
- Explain tree/graph search agents (branch, score, backtrack) and model a search loop offline.
- Use reflection (generate → critique → revise) and know when it helps vs loops uselessly.
- Separate planner from executor, and design a memory architecture (working / episodic / semantic).
- Run multi-agent debate and reason about when consensus beats a single agent — and its cost.
1 · The ReAct ceiling
The ReAct loop — Thought → Action → Observation, repeat — is the workhorse of tool-using agents, and for many tasks it is enough. But it is fundamentally greedy and linear: it commits to one line of reasoning, takes an action, and if that action was a mistake it has no principled way to undo it. Three failure modes recur. It cannot backtrack when a branch dead-ends. It has no global plan, so on long-horizon tasks it loses the thread. And it cannot check its own work — a confidently wrong step propagates. Every richer architecture below is a targeted fix for one of these.
| ReAct limit | Architecture that fixes it |
|---|---|
| Can't undo a bad step | Tree/graph search — branch and backtrack |
| No self-correction | Reflection — generate, critique, revise |
| No long-horizon plan | Planner–executor — plan first, then act |
| Forgets across steps/sessions | Memory architecture — working/episodic/semantic |
| Single fallible viewpoint | Multi-agent debate — independent agents reconcile |
2 · Tree & graph search agents
Instead of committing to one reasoning path, a search agent treats the task as exploring a tree (or graph) of states: from a state it branches into candidate next actions, scores each resulting state with a heuristic (or a value from the model), expands the most promising, and backtracks when a branch dead-ends. This is Tree-of-Thoughts / graph-of-thoughts in spirit, and it is just classic search (BFS/DFS/best-first) with the LLM as the branch generator and the state evaluator.
This buys the one thing ReAct lacks: the ability to recover from a wrong turn by returning to a frontier and trying another branch. The cost is real — each branch is more model calls — so search pays off on tasks with a checkable goal and expensive-to-reverse mistakes (planning, puzzles, multi-step tool chains), not on simple lookups.
3 · Lab · a best-first search agent, offline
Here is the search loop as a mechanism, in pure Python. The 'LLM' is stubbed by a deterministic branch generator and a heuristic so it runs offline and you see the search behavior — the same control flow a real agent uses, minus the model calls. The task: reach a target string by appending characters, with a heuristic guiding the frontier.
search_agent.pyimport heapq
TARGET = "HELLO"
ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
def heuristic(state):
# chars still wrong vs the target prefix of the same length + chars remaining
wrong = sum(1 for i, c in enumerate(state) if i < len(TARGET) and c != TARGET[i])
return wrong + (len(TARGET) - len(state))
def successors(state):
# branch: append the correct next char AND two distractors (LLM stand-in)
if len(state) >= len(TARGET):
return []
good = TARGET[len(state)]
return [state + c for c in (good, "X", "Z")]
def search():
start = ""
frontier = [(heuristic(start), start)] # best-first: lowest heuristic first
expansions = 0
seen = set()
while frontier:
h, state = heapq.heappop(frontier)
if state in seen:
continue
seen.add(state)
if state == TARGET:
print(f" GOAL reached: '{state}' in {expansions} expansions (vs 26^5 blind)")
return
expansions += 1
for nxt in successors(state):
heapq.heappush(frontier, (heuristic(nxt), nxt))
if len(state) < len(TARGET):
print(f" expanded {state!r:8} (h={h}, frontier={len(frontier)})")
print(f"target={TARGET!r}")
search()
print("best-first search reaches the goal by scoring the frontier, not brute force")
target='HELLO'
expanded 'H' (h=4, frontier=3)
expanded 'HE' (h=3, frontier=5)
expanded 'HEL' (h=2, frontier=7)
expanded 'HELL' (h=1, frontier=9)
GOAL reached: 'HELLO' in 5 expansions (vs 26^5 blind)
best-first search reaches the goal by scoring the frontier, not brute force
The agent reaches the goal in a handful of expansions by always growing the most promising node — and crucially it could backtrack to a shelved frontier node if a branch dead-ended. Swap the stub branch/score functions for LLM calls and this is a Tree-of-Thoughts agent.
4 · Reflection & planner–executor
Reflection adds a self-critique loop: the agent generates an answer, then a critic pass (often the same model with a different prompt) evaluates it against the goal and produces a revision. Repeat until the critic is satisfied or a budget runs out. Reflection shines when errors are detectable from the output (a failing test, a schema violation, a contradiction) — the critic has real signal. It loops uselessly when there's no external check, because the model critiques with the same blind spots that produced the error. Always bound the loop and, where you can, ground the critic in an external signal (tests, a validator, a tool result).
Planner–executor splits the agent in two: a planner decomposes the task into an ordered plan of sub-goals once, and an executor carries out each step (often a plain ReAct loop). This gives the long-horizon coherence ReAct lacks — the plan is the global thread — while keeping each step simple. The planner can re-plan when the executor reports a step failed.
reflection.pydef external_check(answer):
# a real critic grounded in an external signal (here: format rules)
if "kg" not in answer:
return False, "missing units"
if "work" not in answer and "*" not in answer:
return False, "show work"
return True, "ok"
def generate(attempt, critique):
# stubbed model: each revision addresses the last critique
drafts = ["answer is 42", "answer is 42 kg", "work: 6*7; answer is 42 kg"]
return drafts[min(attempt, len(drafts) - 1)]
def reflect(max_attempts=5):
critique = ""
for attempt in range(max_attempts):
answer = generate(attempt, critique)
ok, critique = external_check(answer)
verdict = "PASS" if ok else f"FAIL ({critique})"
print(f"attempt {attempt+1}: {answer!r} -> critic: {verdict}")
if ok:
print(f"converged in {attempt+1} attempts (bounded at {max_attempts}) "
f"-- external check gave real signal")
return
print("budget exhausted without convergence")
reflect()
attempt 1: 'answer is 42' -> critic: FAIL (missing units)
attempt 2: 'answer is 42 kg' -> critic: FAIL (show work)
attempt 3: 'work: 6*7; answer is 42 kg' -> critic: PASS
converged in 3 attempts (bounded at 5) -- external check gave real signal
5 · Memory architectures
A ReAct agent's only memory is its context window — which overflows and forgets. Real agents use a tiered memory architecture, borrowed from cognitive models: working memory (the current context — small, fast, volatile), episodic memory (a log of past interactions/events, retrieved by recency and relevance), and semantic memory (distilled facts and preferences, the durable knowledge base). The agent writes important events to episodic memory, periodically consolidates them into semantic memory, and retrieves from both to refill working memory each turn.
| Tier | Holds | Lifetime | Retrieved by |
|---|---|---|---|
| Working | current turn's context | this turn | always in context |
| Episodic | past events / turns | session or longer | recency + similarity |
| Semantic | distilled facts / prefs | durable | similarity / lookup |
6 · Multi-agent debate
A single agent has one viewpoint and one set of blind spots. Multi-agent debate runs several agents on the same question — sometimes with different roles or personas — lets them see each other's answers and critiques, and iterates until they converge or a judge decides. The wins are real on tasks with diverse valid approaches and where errors are idiosyncratic: independent agents cancel each other's mistakes, much like an ensemble. The costs are equally real — N× the tokens and latency, plus the risk of convergence on a confident wrong answer if the agents share the same bias. This lab models a simple debate-to-consensus.
debate.pyfrom collections import Counter
def agent_answer(agent_id, round_no, peer_answers):
# stubbed agents: agent 2 is wrong in round 1, revises after seeing peers
if round_no == 1:
return {0: 4, 1: 4, 2: 6}[agent_id]
# round 2: revise toward the majority of peers if you were an outlier
majority = Counter(peer_answers).most_common(1)[0][0]
mine = {0: 4, 1: 4, 2: 6}[agent_id]
return majority if mine != majority else mine
def debate(n_agents=3, max_rounds=3):
answers = [None] * n_agents
for rnd in range(1, max_rounds + 1):
peer = list(answers)
answers = [agent_answer(i, rnd, peer) for i in range(n_agents)]
counts = Counter(answers)
top, agree = counts.most_common(1)[0]
note = "no consensus" if agree < n_agents else "consensus"
print(f"round {rnd} answers: {answers} ({note})")
if agree == n_agents:
print(f"consensus: {top} after {rnd} rounds (agreement {agree}/{n_agents})")
return
print("no full consensus within budget")
debate()
print("debate cancels idiosyncratic errors -- at N x the token cost")
round 1 answers: [4, 4, 6] (no consensus)
round 2 answers: [4, 4, 4] (saw peers, one revised)
consensus: 4 after 2 rounds (agreement 3/3)
debate cancels idiosyncratic errors -- at N x the token cost
An agent using a plain ReAct loop keeps taking a wrong early action and then thrashing. Which architecture most directly addresses this, and why not just add reflection?
Show answer
A team adds multi-agent debate everywhere and sees cost triple with little quality gain on their fact-lookup tasks. What went wrong?
Show answer
✓ Checkpoint — you can move on when you can…
- Name the ReAct failure modes and which architecture targets each.
- Describe a search agent (branch/score/expand/backtrack) and trace the offline loop.
- Explain when reflection helps (external check) vs loops uselessly (no signal).
- Design a tiered memory architecture (working/episodic/semantic) and its retrieve/consolidate flow.
- Reason about when multi-agent debate beats a single agent and what it costs.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Each richer architecture is a targeted fix for a specific ReAct weakness. Picking the wrong one adds cost without addressing the failure.
Your task: Write a lookup that maps a ReAct failure mode to the architecture that most directly fixes it.
Requirements:
- Can't undo a bad step → tree/graph search
- No self-correction → reflection
- No long-horizon plan → planner–executor
- Forgets across turns → memory architecture
- Print each failure with its fix
💡 Hint: Backtracking is search; self-critique is reflection; a global thread is a planner.
Show solution
Map each failure to its targeted fix. Runnable:
FIX = {
"can't undo a bad step": "tree/graph search (branch + backtrack)",
"no self-correction": "reflection (generate, critique, revise)",
"no long-horizon plan": "planner-executor (plan, then act)",
"forgets across turns": "memory architecture (working/episodic/semantic)",
"single fallible view": "multi-agent debate",
}
for failure, fix in FIX.items():
print(f"{failure:24} -> {fix}")
Each architecture exists to fix one ReAct weakness. Picking by failure mode — backtracking needs search, self-critique needs reflection, a global thread needs a planner — keeps you from paying for structure that doesn't address your actual problem.
Context: ReAct is Thought -> Action -> Observation repeated. Modeling one step as a pure function makes the loop and its greediness explicit.
Your task: Implement a single ReAct step that, given state and a tool, produces (thought, action, observation) and the next state, and run it to a goal or a step budget.
Requirements:
- A step returns thought, action, observation, and the updated state
- Loop until a goal predicate holds or a max-steps budget is hit
- Use a stubbed tool so it runs offline
- Show it stops at the goal or the budget
💡 Hint: The loop is just: while not done and steps left: state = step(state).
Show solution
One ReAct step as a pure function, looped to a budget. Runnable:
def tool(query): # stubbed tool
return {"weather": "sunny", "time": "noon"}.get(query, "unknown")
def react_step(state):
goal = state["goal"]
thought = f"I need {goal}"
action = goal
obs = tool(action)
state = {**state, "known": {**state["known"], goal: obs}}
return thought, action, obs, state
def run(goal, max_steps=5):
state = {"goal": goal, "known": {}}
for step in range(max_steps):
t, a, o, state = react_step(state)
print(f"step {step+1}: thought={t!r} action={a!r} obs={o!r}")
if state["known"].get(goal) not in (None, "unknown"):
print("goal satisfied"); return
print("budget exhausted")
run("weather")
The loop is just while not done and steps left: state = step(state). Modeling a step as a
pure function makes ReAct's greediness explicit — it commits to each action with no way to revisit it,
which is exactly what search fixes.
Context: A search agent scores a frontier and expands the best node, backtracking when a branch dead-ends — the recovery ReAct lacks.
Your task: Implement a best-first search over a toy state space (append a char to reach a target) using a heuristic-ordered frontier.
Requirements:
- Maintain a frontier ordered by a heuristic (distance to target)
- Expand the best node, branch into successors, push them
- Stop at the goal; count expansions
- Show it reaches the goal in far fewer expansions than blind search
💡 Hint: A heapq keyed by the heuristic gives you best-first; the heuristic is the number of chars still wrong.
Show solution
Best-first search with a heuristic-ordered frontier. Runnable:
import heapq
TARGET = "HELLO"
def h(state):
wrong = sum(1 for i, c in enumerate(state) if i < len(TARGET) and c != TARGET[i])
return wrong + (len(TARGET) - len(state))
def succ(state):
if len(state) >= len(TARGET): return []
return [state + c for c in (TARGET[len(state)], "X", "Z")]
def search():
frontier, seen, exp = [(h(""), "")], set(), 0
while frontier:
_, s = heapq.heappop(frontier)
if s in seen: continue
seen.add(s)
if s == TARGET:
print(f"goal in {exp} expansions (blind would be 26^5)"); return
exp += 1
for n in succ(s): heapq.heappush(frontier, (h(n), n))
search()
The heuristic-ordered frontier grows the most promising node first and reaches the goal in a handful of expansions instead of brute force. Because shelved nodes stay on the frontier, the agent can backtrack to them if a branch dead-ends — the recovery a linear ReAct loop can't do.
Context: Reflection converges only when the critic has real signal; without an external check it loops. Bounding the loop and grounding the critic are the safeguards.
Your task: Implement a reflection loop that generates, critiques against an external check, and revises, bounded by a max-attempts budget.
Requirements:
- Generate an answer, then critique it with an external check function
- On failure, revise using the critique; repeat
- Bound the loop with a max-attempts budget
- Report convergence (or budget exhaustion) and why the external check matters
💡 Hint: The external check (a test/validator) is what stops it looping on its own blind spots.
Show solution
Reflection bounded by a budget and grounded in an external check. Runnable:
def check(ans):
if "kg" not in ans: return False, "missing units"
if "*" not in ans: return False, "show work"
return True, "ok"
def generate(attempt): # each revision fixes the last critique
return ["42", "42 kg", "6*7 = 42 kg"][min(attempt, 2)]
def reflect(max_attempts=5):
for attempt in range(max_attempts):
ans = generate(attempt)
ok, why = check(ans)
print(f"attempt {attempt+1}: {ans!r} -> {'PASS' if ok else 'FAIL: '+why}")
if ok:
print(f"converged in {attempt+1} (bounded at {max_attempts})"); return
print("budget exhausted")
reflect()
The external check gives the critic real signal, so the loop converges instead of endlessly
re-endorsing its own blind spots. The max-attempts bound is the safeguard for when there is no signal —
without both, reflection can loop forever or waste budget.
Context: Agent memory is working/episodic/semantic with retrieve and consolidate operations. The skill is what to retrieve and what to forget, not just storing.
Your task: Implement a tiered memory: write events to episodic memory, retrieve top-k by relevance to refill working memory, and consolidate frequent episodic facts into semantic memory.
Requirements:
- Episodic store appends events; semantic store holds distilled facts
- Retrieve returns the top-k episodic events by a relevance score
- Consolidate promotes repeated episodic facts into semantic memory
- Show retrieval fills working memory and consolidation shrinks the episodic log
💡 Hint: Relevance can be a simple keyword overlap; consolidation promotes facts seen >= a threshold.
Show solution
A tiered memory with retrieve and consolidate. Runnable:
from collections import Counter
class Memory:
def __init__(self):
self.episodic, self.semantic = [], {}
def write(self, event):
self.episodic.append(event)
def retrieve(self, query, k=2):
scored = sorted(self.episodic,
key=lambda e: -len(set(e.split()) & set(query.split())))
return scored[:k]
def consolidate(self, threshold=2):
counts = Counter(self.episodic)
for fact, n in counts.items():
if n >= threshold:
self.semantic[fact] = n
self.episodic = [e for e in self.episodic if e not in self.semantic]
m = Memory()
for e in ["user likes tea", "user likes tea", "booked flight to NYC"]:
m.write(e)
print("retrieve 'tea':", m.retrieve("likes tea"))
m.consolidate()
print("semantic:", m.semantic, " episodic left:", m.episodic)
Retrieval refills working memory with the most relevant episodic events; consolidation promotes repeated facts into durable semantic memory and shrinks the episodic log. The hard part isn't storing — it's choosing what to surface and what to forget, which is exactly what retrieve and consolidate encode.
Context: Given a task's properties and a cost budget, the right architecture is a decision: search for reversible-mistake planning, debate for open-ended multi-path work, plain ReAct when cost is tight.
Your task: Write a router that, from (has_checkable_goal, reversible_mistakes_costly, open_ended_multipath, tight_budget), recommends an agent architecture and justifies it.
Requirements:
- Costly-to-reverse mistakes + checkable goal → tree/graph search
- Open-ended with diverse valid paths and budget to spare → multi-agent debate
- Long-horizon but single-path → planner–executor
- Tight budget / simple task → plain ReAct
- Return a recommendation with a one-line reason
💡 Hint: Match each architecture to the failure it fixes, and gate debate/search behind the budget.
Show solution
Route by task properties, gating cost behind the budget. Runnable:
def route(has_checkable_goal, reversible_mistakes_costly, open_ended_multipath, tight_budget):
if tight_budget and not reversible_mistakes_costly:
return "plain ReAct", "budget is tight and mistakes are cheap to redo"
if reversible_mistakes_costly and has_checkable_goal:
return "tree/graph search", "costly-to-reverse mistakes + a checkable goal"
if open_ended_multipath and not tight_budget:
return "multi-agent debate", "diverse valid paths; budget to cancel errors"
return "planner-executor", "long-horizon single-path task needs a global plan"
cases = [
(True, True, False, False), # search
(True, False, True, False), # debate
(False, False, False, False), # planner-executor
(True, False, False, True), # ReAct
]
for c in cases:
arch, why = route(*c)
print(f"{arch:18} <- {why}")
Each architecture is matched to the failure it fixes, and the expensive options (search, debate) are gated behind the cost budget. The router encodes the real design decision: don't pay for structure the task doesn't need, and don't starve a task that needs backtracking or diversity.