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.
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.
Plan and execute, not chain-of-thought
A plan-and-execute agent emits a structured plan first, then runs each step deterministically through a dispatcher. The plan is data the harness can inspect, and execution is the harness walking that data. A chain-of-thought agent works differently: it emits tokens and lets the loop guess where the tool call ends.
That gives you two pieces. A planner produces the plan and an executor runs it. The interesting work starts when the executor hits a failure, because there are three things it can do:
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
A step is a typed record. The executor fills in result or error as it runs.
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 | Noneexpected_outcome is a short sentence the planner emits alongside the step, stating what success looks like. The executor does not enforce it. It has two readers: the replanner, which uses it when revising the plan, and the event stream, which emits it so a tracer can show "this step was supposed to do X."
The planner shape
The planner is a pure function from the goal, the execution history, and the last error to a plan.
def planner(goal: str, history: list[Step], last_error: str | None) -> list[Step]:
...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 call after that. The planner returns the next plan starting from the cursor.
It knows nothing about the executor, retries, or timeouts. It produces a plan and stops there.
The executor
The executor is a small state machine. Each step runs through the dispatcher and ends in one of three outcomes: success, failure-replannable, or failure-fatal. Replannable failures go back to the planner. Fatal failures (budget exceeded, replan ceiling hit) return a FAILED session result.
Plan diffs on revision
A plan diff is the executor's record of how a revised plan differs from the one it replaces. 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 changedA tracer or UI can render this as a strikethrough on removed steps and a highlight on added ones. The diff format matters less than the principle: revision is a visible event, not a silent rewrite.
Two budgets, both hard
max_steps caps total step executions across the whole session, replans included. The default is twelve. Say a linear five-step plan fails at its last step and replans twice, and each revision retries the failed step and adds three more. That is 5 + 4 + 4 = 13 executions, because failed attempts count, so with the default budget of twelve the executor stops with FAILED (step_budget).
max_replans caps how many times the planner is called after the first plan. The default is five, and this is the more important limit. Without it, a planner that returns the same broken plan five times in a row would loop until the step budget stopped it. Capping replans makes the failure faster and the reason clearer.
The deterministic planner in this lesson
This lesson calls no model. It 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)That is enough to exercise every transition path in the executor: success, replan-once, replan-twice, replan-exhaustion, and step-budget exhaustion.
Result shape
A session returns a SessionResult, which carries the outcome, the reason, and the full trail of what happened.
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 executes each step, and the registry from lesson twenty-one validates each step's args. The transport from lesson twenty-two would carry this whole flow over JSON-RPC to a model client.
I'd build the replanner before the happy path. A plan that cannot survive a failure is a script, and the replan branch is what makes this an agent, so that is where I'd put my first tests.
I'd also set max_replans before max_steps. The lesson calls it the more important limit because capping replans makes a failure faster and its reason clearer. My bet is that the plan.diff event pays off in debugging: revision is a visible event, not a silent rewrite, and that is what I'd want in a trace when a planner keeps changing its plan.
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. Plan diffs come from 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 become worth building once you wire this to a real model. The first is partial-plan caching. Suppose a plan succeeds on its first three of six steps and then fails: you do not want to re-run those three. The executor already keeps history, so the planner only needs to read it. The second is parallel branches. The current executor is strictly sequential, but a planner that emits an independent branch (gather_step instead of next_step) can have two tool calls run concurrently through the dispatcher.
Both add real complexity, and both are easier to add once the linear executor is pinned down. That is the job of this lesson.
Code
- main.py (python, 276 lines)
- tests/test_agent.py (python, 157 lines)