3. Planning¶
Intermediate · 12 min read
A ReAct agent decides one step at a time. That's flexible, but on longer tasks it wanders: it repeats searches, forgets sub-goals, and spends many model calls reasoning about trivial next steps. Planning separates deciding what to do from doing it. A planner writes the steps once, an executor runs them, and the planner is only called again when something goes wrong.
3.1 Step-by-step vs plan-first¶
| ReAct (step by step) | Plan-and-execute | |
|---|---|---|
| Model calls | One big-model call per step | One planning call, then cheap execution (small model or plain code) |
| Adapts to surprises | Immediately | At re-plan points |
| Keeps the overall goal in view | Can drift on long tasks | The plan holds the goal |
| Parallel steps | Hard | Natural: independent steps run together |
| Best for | Short, exploratory tasks | Longer tasks with a clear structure: research, reports, migrations |
flowchart LR
G[Goal] --> P[Planner LLM<br/>writes the steps]
P --> E[Executor<br/>runs steps, tools]
E -->|step failed| R[Re-planner] --> E
E -->|all done| S[Synthesiser<br/>writes the answer]
3.2 A plan with dependencies¶
Ask the planner for structured output: a list of steps, each with an ID, a tool, its input and the steps it
depends on. Steps with no unmet dependencies can run in parallel. The standard library's graphlib works out the
order:
import json
from graphlib import TopologicalSorter
# What a planner model returned for: "Compare our Q3 revenue in India and the US and suggest one action."
plan = json.loads("""[
{"id": "s1", "tool": "sql", "input": "Q3 revenue, India", "after": []},
{"id": "s2", "tool": "sql", "input": "Q3 revenue, US", "after": []},
{"id": "s3", "tool": "search_docs", "input": "Q3 marketing campaigns", "after": []},
{"id": "s4", "tool": "compare", "input": "s1 vs s2", "after": ["s1", "s2"]},
{"id": "s5", "tool": "write", "input": "summary + one action", "after": ["s3", "s4"]}
]""")
graph = TopologicalSorter({step["id"]: step["after"] for step in plan})
graph.prepare()
batch = 1
while graph.is_active():
ready = list(graph.get_ready())
print(f"batch {batch}: run in parallel -> {ready}")
graph.done(*ready)
batch += 1
batch 1: run in parallel -> ['s1', 's2', 's3']
batch 2: run in parallel -> ['s4']
batch 3: run in parallel -> ['s5']
Five steps finish in three rounds instead of five. Validate the plan before running it: unknown tools, missing
dependencies and cycles (graphlib raises CycleError) should go back to the planner as an error, just like a tool
error in ReAct.
3.3 Executing, failing and re-planning¶
Real steps fail: an API is down, a table doesn't exist, a search finds nothing. Don't restart the whole task. Send the failure and the results so far to the planner, and let it revise only the remaining steps:
def run_tool(step: dict) -> str:
if step["input"] == "Q3 revenue, US":
raise RuntimeError("table revenue_us does not exist") # a realistic failure
return f"<result of {step['tool']}({step['input']})>"
def replan(failed: dict, error: str, done: dict) -> list[dict]:
"""Stand-in for a planner call that sees the error and the results so far."""
print(f" re-plan: {failed['id']} failed ({error}); planner suggests the regional table instead")
return [{"id": failed["id"] + "b", "tool": "sql", "input": "Q3 revenue from revenue_by_region WHERE region='US'",
"after": failed["after"]}]
def execute(plan: list[dict], max_replans: int = 2) -> dict:
results, queue, replans = {}, list(plan), 0
while queue:
step = queue.pop(0)
deps = [d for d in step["after"] if d not in results]
if deps: # wait for dependencies (sequential here for clarity)
queue.append(step)
continue
try:
results[step["id"]] = run_tool(step)
print(f" ok {step['id']}: {step['tool']}({step['input']})")
except Exception as err:
if replans == max_replans:
raise
replans += 1
new_steps = replan(step, str(err), results)
replacement = new_steps[-1]["id"]
for s in queue: # later steps now wait for the replacement step
s["after"] = [replacement if d == step["id"] else d for d in s["after"]]
queue = new_steps + queue
return results
results = execute([dict(s) for s in plan])
print(f"\n{len(results)} steps completed:", list(results))
ok s1: sql(Q3 revenue, India)
re-plan: s2 failed (table revenue_us does not exist); planner suggests the regional table instead
ok s2b: sql(Q3 revenue from revenue_by_region WHERE region='US')
ok s3: search_docs(Q3 marketing campaigns)
ok s4: compare(s1 vs s2)
ok s5: write(summary + one action)
5 steps completed: ['s1', 's2b', 's3', 's4', 's5']
Two rules keep re-planning safe: cap the number of re-plans (an agent can otherwise re-plan forever), and give the planner the error message and the results so far, not just "it failed".
3.4 Reflection: critique and revise¶
Reflection adds a check before the result is accepted. One call drafts, another critiques against explicit criteria, and the draft is revised until the critic is satisfied or a round limit is reached:
CRITERIA = ["mentions both countries", "gives exactly one action", "under 60 words"]
def critique(draft: str) -> list[str]:
"""Stand-in for a critic LLM; returns the criteria the draft fails."""
problems = []
if not ("India" in draft and "US" in draft):
problems.append(CRITERIA[0])
if draft.count("Action:") != 1:
problems.append(CRITERIA[1])
if len(draft.split()) > 60:
problems.append(CRITERIA[2])
return problems
drafts = [ # what the writer model produced in each round
"India revenue grew 18% while the US fell 4%.",
"India revenue grew 18% while the US fell 4%. Action: move 10% of US ad spend to India.",
]
for round_no, draft in enumerate(drafts, start=1):
problems = critique(draft)
print(f"round {round_no}: {'accepted' if not problems else 'revise: ' + ', '.join(problems)}")
if not problems:
break
Reflection works best when the critique checks specific, verifiable criteria (format, required facts, tests passing). "Is this good?" critiques tend to approve their own work. For code, the strongest critic is not an LLM at all: run the tests.
3.5 ReWOO: plan once with placeholders¶
ReWOO (Reasoning WithOut Observation) goes further: the planner writes every step up front, using placeholders
(#E1, #E2) for results it hasn't seen yet. Workers fill them in with no extra LLM reasoning, and a final call
writes the answer. It uses far fewer tokens than ReAct, but can't adapt mid-way.
rewoo_plan = [("#E1", "geocode_city", "Bengaluru"),
("#E2", "get_weather", "#E1"),
("#E3", "llm_answer", "Will it rain? Use #E2")]
evidence = {}
fake_tools = {"geocode_city": lambda x: "12.97,77.59",
"get_weather": lambda x: f"80% rain at 18:00 for {x}",
"llm_answer": lambda x: f"answer from: {x}"}
for var, tool, arg in rewoo_plan:
for ref, value in evidence.items(): # substitute earlier results into this step's input
arg = arg.replace(ref, value)
evidence[var] = fake_tools[tool](arg)
print(f"{var} = {tool}({arg!r}) -> {evidence[var]!r}")
#E1 = geocode_city('Bengaluru') -> '12.97,77.59'
#E2 = get_weather('12.97,77.59') -> '80% rain at 18:00 for 12.97,77.59'
#E3 = llm_answer('Will it rain? Use 80% rain at 18:00 for 12.97,77.59') -> 'answer from: Will it rain? Use 80% rain at 18:00 for 12.97,77.59'
3.6 Choosing a planning style¶
| Task | Style |
|---|---|
| 2–5 steps, exploratory | ReAct |
| Clear structure, many steps, independent parts | Plan-and-execute with parallel batches |
| Quality bar is checkable (format, tests, facts) | Add reflection |
| Fixed shape, cost-sensitive | ReWOO, or just a workflow |
| Fully predictable | Don't plan with an LLM: write the workflow in code |
Interview questions¶
What is plan-and-execute, and why use it over ReAct?
A planner LLM writes the steps up front (often as structured JSON with dependencies), an executor runs them, and the planner is called again only to re-plan after failures. It keeps long tasks on track, uses fewer expensive reasoning calls, and lets independent steps run in parallel. ReAct adapts faster to surprises but can drift on long tasks.
How should an agent handle a failed step in a plan?
Give the planner the error and the results so far, and ask it to revise only the remaining steps; update the dependencies of later steps; cap the number of re-plans; and escalate to a human or fail gracefully when the cap is hit.
What is reflection in agents, and when does it help?
A critique step evaluates a draft against explicit criteria and the agent revises until it passes or a round limit is reached. It helps when quality is checkable (format, required facts, tests). Vague self-critique tends to approve its own output; external checks such as tests or validators are better critics.
Practice¶
- Add a cycle to the plan in 3.2 (
s1afters5) and catchgraphlib.CycleError. - Make the replacement step in 3.3 fail too and watch
max_replansstop the run. - Add a fourth criterion to
critiqueand a third draft that passes it.
Next: 4. Memory Back to: AI Agents overview · Notes overview