Plan-Execute Control Flow
A plan that cannot survive a failure is a script. A script that can replan is an agent. Build the replanner first.
Lesson 22 of 85Build~90 minutesPython
Before this: Lessons 1 to 7 of Phase 13, lesson 1 of Phase 14
On this page
Learning Objectives
- Represent a plan as an ordered list of typed steps so the executor can reason about progress and outcome.
- Execute steps sequentially with a controlled failure handoff back to the planner.
- Replan from the current cursor with the prior error in the context so the next plan is informed.
- Emit a plan diff on each revision so a downstream tracer or UI can show why the plan changed.
- Enforce two budgets: a hard step ceiling and a hard replan ceiling.
cg-plan-replan
Plan and execute, not chain-of-thought
A chain-of-thought agent emits tokens and lets the loop guess where the tool call ends. A plan-and-execute agent emits a structured plan first, then executes each step deterministically. The plan is data the harness can introspect. The execution is the harness running that data through a dispatcher.
Two pieces. A planner that produces a plan. An executor that runs the plan. The interesting work is what happens when the executor hits a failure. Three options:
1. Abort (return failed, surface the error)
2. Skip (mark step failed, continue with the rest)
3. Replan (hand the error to the planner, get a new plan from the cursor)
Replan is the one that turns a script into an agent.
The Step shape
Step
id : int (monotonic within a plan revision)
tool_name : str
args : dict
expected_outcome: str (planner's stated success condition)
result : Any | None
error : str | None
expected_outcome is a short sentence the planner emits alongside the step. It is not enforced by the executor. It is for two things: the replanner reads it when revising the plan; the event stream emits it so a tracer can show "this step was supposed to do X."
The planner shape
def planner(goal: str, history: list[Step], last_error: str | None) -> list[Step]:
...
A pure function. goal is the user goal. history is the steps already executed (with results and errors filled in). last_error is None on the first call and the most recent failure message on every subsequent call. The planner returns the next plan starting from the cursor.
The planner does not know about the executor. It does not know about retries. It does not know about timeouts. It produces a plan. That is all.
The executor
The executor is a small state machine. Each step runs through the dispatcher. The outcome is one of three things: success, failure-replannable, failure-fatal. Replannable failures hand back to the planner. Fatal failures (budget exceeded, replan ceiling hit) return a FAILED session result.
Plan diffs on revision
When the planner returns a new plan after a failure, the executor emits a plan.diff event with three fields.
removed: list of step ids that were in the old plan and are not in the new
added : list of step ids in the new plan that were not in the old
revised: list of step ids whose tool_name or args changed
A tracer or UI can render this as a strikethrough on the removed steps and a highlight on the added ones. The point is not the diff format. The point is that revision is a visible event, not a silent rewrite.
Two budgets, both hard
max_steps caps total step executions across the whole session, including replans. Default is twelve. Failed attempts count too. A five-step plan that fails on its last step has spent five executions. If each replan retries that step and adds three new ones, and the last step of the first revision fails again, the count is nine, and the second revision needs four more: thirteen. The executor runs the twelfth, refuses the thirteenth, and returns FAILED with reason step_budget.
max_replans caps the number of times the planner is called after the first plan. Default is five. This is the more important limit. A planner that returns the same broken plan five times in a row would otherwise loop until the step budget catches it. Capping replans makes the failure faster and the reason clearer.
The deterministic planner in this lesson
We do not call a model in this lesson. The lesson ships a deterministic planner that picks a plan based on last_error.
last_error is None -> emit a four-step plan
last_error matches X -> emit a three-step plan that routes around X
last_error matches Y -> emit a two-step plan that gives up gracefully
otherwise -> return [] (signals nothing to replan)
This is enough to test the executor's behavior on every transition path: success, replan-once, replan-twice, replan-exhaustion, and step-budget exhaustion.
Result shape
SessionResult
status : "completed" | "failed"
reason : str ("goal_met" | "step_budget" | "replan_budget" | "no_plan")
history : list[Step]
revisions : list[PlanDiff]
events : list[Event]
The harness loop from lesson twenty can read this directly. The dispatcher from lesson twenty-three is what executes each step. The registry from lesson twenty-one validates each step's args. The transport from lesson twenty-two would surface this whole flow over JSON-RPC to a model client.
How to read the code
code/main.py defines PlanExecuteAgent, Step, PlanDiff, SessionResult, and the deterministic planner. The executor is a single run(goal) method that returns a SessionResult. The plan diff is computed by comparing step ids and (tool_name, args) tuples.
code/tests/test_agent.py covers a linear success, a mid-plan failure that replans once, replan exhaustion that returns failed:replan_budget, step-budget exhaustion, and the plan-diff event format.
Going further
Two extensions you will want once you wire this to a real model. First, partial-plan caching: when a plan succeeds for the first three of six steps and then fails, you do not want to re-run the first three. The executor already keeps history; the planner just needs to read it. Second, parallel branches: the current executor is strictly sequential. A planner that emits an independent branch (gather_step instead of next_step) can run two tool calls concurrently through the dispatcher.
Both add real complexity. Both are easier to add once the linear executor is pinned. That is what this lesson does.
Check yourself
Quick quiz
What is the planner's responsibility in a plan-execute agent?
- To handle the JSON-RPC transport
- To produce an ordered list of typed steps the executor will run
- To enforce timeouts
- To call the dispatcher on each step
Show the answer
B. To produce an ordered list of typed steps the executor will run Planner emits structured plans. The executor runs them. Mixing the two is the chain-of-thought trap this lesson is moving away from.
Why is expected_outcome part of the Step shape even though the executor does not check it?
- Because it gives the replanner and tracers a stated success condition to read
- Because the model trains on it
- Because the dispatcher uses it as a cache key
- Because the registry requires it
Show the answer
A. Because it gives the replanner and tracers a stated success condition to read The replanner reads it when revising. The tracer renders it on the timeline. It is a human-readable contract on the step.
When a step fails and the replanner returns a new plan, what does the executor emit on the event stream?
- session.complete with status=failed
- tool.error followed by session.start
- plan.diff with removed/added/revised step ids
- Nothing — replan is silent
Show the answer
C. plan.diff with removed/added/revised step ids plan.diff makes the revision visible. A silent rewrite is the bug we are avoiding.
Why have both max_steps and max_replans as separate budgets?
- Because the dispatcher rejects unbounded plans
- Because the registry counts replans
- Because asyncio requires both
- Because they measure different units. Steps cap total execution; replans cap how many times the planner is called.
Show the answer
D. Because they measure different units. Steps cap total execution; replans cap how many times the planner is called. max_replans catches a planner that keeps returning the same broken plan. max_steps catches a runaway plan that keeps growing.
Which reason is returned when the planner returns an empty list on the first call?
- replan_budget
- step_budget
- no_plan
- goal_met
Show the answer
C. no_plan No plan, no execution. The session ends with reason=no_plan and status=failed.
What is in PlanDiff.revised?
- Step ids the planner asked to skip
- Step ids whose tool_name or args changed between revisions
- Step ids in the new plan that were not in the old
- Step ids that were in the old plan and are not in the new
Show the answer
B. Step ids whose tool_name or args changed between revisions revised is the set of ids whose (tool_name, args) signature changed across the revision.
When the executor exhausts max_replans, what is the resulting SessionResult.reason?
- step_budget
- replan_budget
- no_plan
- goal_met
Show the answer
B. replan_budget replan_budget. The status is failed. History carries every step that ran, including the failures that triggered replans.
Questions people ask
What is a plan-and-execute AI agent?
It is an agent that first writes a list of steps, then performs them one at a time. After each step it checks the result. If something fails, it updates the plan for the remaining work. This makes long tasks easier to follow and debug.
Why do AI agents need to replan?
Real tasks rarely go as planned. A file may be missing, an API may fail or a step may return something unexpected. Replanning lets the agent adjust the remaining steps to the new facts instead of repeating a broken step or finishing with wrong output.
Lessons from AI Engineering from Scratch by Rohit Ghumare and contributors, MIT license. Notes for owners by Lozo. License