Skip to content

14 min read

A plan step is a tool, its arguments and a postcondition

On this page

A planner–executor split is an agent design in which one model call, the planner, reads the task and writes down the whole sequence the run will take before anything is touched, and a second stage, the executor, works through that sequence a step at a time and makes the tool calls. Deciding what to do and doing it happen in separate calls, with separate contexts. The shape it is defined against is the ordinary fused agent loop, in which one model call chooses each action after seeing the result of the last one, so the sequence exists only as it happens and no artefact of it survives anywhere. Committing the sequence to writing first buys two things the fused shape cannot offer: a plan somebody can read, price or approve before any of it reaches a real system, and a statement of what the rest of the run was supposed to be at the moment a step fails in the middle. You meet the pattern as plan-and-execute in agent frameworks such as LangGraph, and in-house wherever a run touches billing, provisioning or customer records and somebody wants to see the sequence before it commits.

This chapter is about the form a single step takes, which is the decision that determines whether any of the rest of the topic is available. The example is an entitlement agent that reads support tickets and carries out plan changes on customer accounts, and the plan it produced for one ticket looked like this — which is how most plan-and-execute systems start.

json
{
  "goal": "downgrade Northwind Traders to Starter at end of period",
  "plan": [
    "look up the customer's plan and check whether they are eligible for the downgrade",
    "confirm no annual commitment is in force",
    "apply the plan change effective at the end of the billing period",
    "issue any prorated credit the change entitles them to",
    "email the billing contact confirming the change",
    "close the ticket with a note"
  ]
}

The decision that lost the money was never written down

Take the first sentence and count what the executor has to supply before it can act on it. "Look up the customer's plan and check whether they are eligible" names no tool, so the executor picks one from a list of forty. It names no arguments, so the executor invents them. And it gives no test, so the executor decides for itself what result would count as the step having worked. Three decisions, taken inside one model call, and none of them recorded as a decision — the trace records only the consequence, which is a tool call.

For this ticket the consequence was customer.search(query="Northwind Traders"). Follow what happened to it. The search returned two results, because two different companies share that name: Northwind Traders GmbH and Northwind Traders Ltd, separate accounts with separate contracts in separate countries, joined only by a shared Salesforce parent record. The executor took the first result. The first result was the GmbH, because the search endpoint ranks by annual contract value — a sensible ranking for a human typing into a search box, and a ranking that has nothing whatever to do with which account a ticket is about. Taking [0] from a ranked list silently converts an ordering decision made for one purpose into an identity claim used for another, and nothing in the run marks the conversion.

The account identifier acct_5512b then travelled through every remaining step without the executor being asked to justify the choice once. The eligibility check in step two passed, and it passed honestly: it was evaluated against a subscription that genuinely had no commitment recorded on it. The commitment sat on the Ltd account, which nothing in the run ever touched.

That absence is what makes attribution impossible rather than merely difficult, and the reason is structural. To say the planner was at fault you need a statement of the request that is precise enough to be wrong. To say the executor was at fault you need the same statement, so you can show the call did not match it. Attribution needs two artefacts and prose steps produce one — the transcript — which is written by the party you are trying to assess. "Eligible" is the sharpest version of this: the planner had one meaning in mind, the executor evaluated another, and both meanings live only in model outputs that were never required to agree.

The remedy is to make each step a commitment that the executor cannot reinterpret — the tool it will call, the arguments it will call it with, and the checkable statement about the world that means it succeeded. Topic 10.5 established what makes a postcondition real rather than decorative, that it has to be a read against the system rather than an assertion drawn from the plan's own text. What matters here is a different property of the same field: which side of the boundary writes it. When the planner writes the postcondition, vague requirements have to be reduced to something checkable at plan time, and the reduction is where the ambiguity becomes visible.

A step the executor cannot reinterpret

Here are the first two steps of the same plan as structured commitments.

json
{"steps": [
  {"n": 0,
   "tool": "customer.resolve",
   "args": {"email": "a.kohl@northwind-traders.co.uk", "include_children": true},
   "post": {"probe": "result_count", "equals": 1},
   "binds": {"account_id": "$.results[0].id"},
   "on_unmet": "replan"},

  {"n": 1,
   "tool": "billing.get_subscription",
   "args": {"account_id": "${account_id}", "as_of": "2026-03-04"},
   "post": {"probe": "field_in", "path": "$.commitment", "values": ["none", "monthly"]},
   "binds": {"sub_id": "$.id", "period_end": "$.current_period_end"},
   "on_unmet": "replan"}
]}

Four things changed and each removes one specific piece of discretion from the executor. The tool field draws from a closed set, so the executor selects nothing. The args are fully bound before dispatch, either as literals the planner wrote or as references to a value an earlier step produced, which means the executor is never in the position of inventing an identifier. The post field states what the world must look like afterwards for the step to count as done, so success is not the executor's opinion about the result. And binds names the values this step contributes to the run, so the provenance of account_id is a recorded fact rather than something you reconstruct from a search result buried in a transcript.

Step 0 is the entire incident, rewritten. Resolving the customer used to be implicit inside "look up the customer's plan"; now it is a step of its own with a postcondition, and the postcondition says exactly one account may match. Two matches means the step is unmet, which routes to a re-plan rather than to a coin flip weighted by contract value.

It is worth separating what is a requirement here from what is a design decision, because they get discussed as though they were the same kind of thing. The requirement is that the run touches the account the ticket is about. Everything else in step 0 is a choice somebody made: resolving by the requester's email address rather than by the company name, demanding exactly one result rather than taking the best one, including child accounts, and routing an unmet postcondition to replan rather than to halt. Each of those is arguable, each could have gone another way, and each is now visible in a JSON object that a reviewer can disagree with before the run happens. The planner might come back with a resolve keyed on the ticket's originating domain, or with a step that asks the requester which account they mean. Either is a decision somebody can review before it costs €4,180. The ambiguity did not disappear, and it acquired a location.

Question raised in the post-mortemWith prose stepsWith structured steps
Which account was the run supposed to touch?Nowhere in the plan; "the customer"args.email on step 0, then ${account_id} everywhere
Who chose acct_5512b, and on what basis?Inferable only from a tool call the executor made unpromptedStep 0's binding, recorded with the result it was extracted from
What did "eligible" mean on this run?Whatever the executor decided, differently each timecommitment in {none, monthly}, fixed when the plan was written
Where does the fix go?"Improve the planner prompt"Either the request was wrong (planner, schema, or postcondition) or a well-formed request was not satisfied (tool, arguments, or the world)
What test catches a regression?None that anyone could specifyAssert the plan for a two-match resolve routes to replan

Read the middle column down and every cell has the same shape: nowhere, inferable only, whatever the executor decided, improve the prompt, none that anyone could specify. That is not five separate weaknesses. It is one weakness — there is no artefact stating the request — appearing in five questions, which is why fixing it by writing a better prompt cannot work: a better prompt produces better sentences and still produces no second artefact.

The last row is the practical payoff. A failure mode that reduces to "the model should have been smarter" produces no regression test; a failure mode that reduces to "step 0's postcondition was absent" produces one you can run in a few milliseconds against a stored plan, with no model call at all.

What crosses the boundary

Separating the roles is only worth something if the channel between them is narrow and explicit. In a fused loop every tool result lands in one growing transcript, and the same context that decides the next action also holds every byte of every previous result. Splitting the roles gives you the chance to decide what execution is allowed to tell planning, and what planning is allowed to tell execution.

Flowchart: ticket → planner call; planner call → validator schema · tools · permissions (plan object); validator schema · tools · permissions → executor (bound step); executor → tools (tool call); tools → run state completed · bindings · failures (result); run state completed · bindings · failures → executor (next step + bindings); run state completed · bindings · failures → re-planner (completed + remaining + failure); re-planner → validator schema · tools · permissions (revised plan).

The run state is the only path by which execution informs planning, which is what makes the re-planning call in the next chapter a tractable thing to design. The validator sits where it does because a step names a tool and fully bound arguments before anything executes, so authorization becomes a function of the step rather than a judgement about intent.

Failures cross that channel as records rather than as narration. A tool error becomes {"step": 3, "kind": "tool_error", "code": "record_archived", "retryable": false}; an unmet postcondition becomes the expected probe alongside what was actually observed. Neither is a sentence the executor composed about what it thinks went wrong, which matters because the re-planner will be reasoning about that record. A re-planner reading "I tried to update the record but it seems the record may have been archived, so I moved on" has been handed an interpretation to trust rather than a fact to work from, and it has no way to tell the difference. The same discipline applies in the other direction: the executor receives the step and the bindings it references, and it does not receive the planner's reasoning, because reasoning in the executor's context is an invitation to improvise around a step it finds unconvincing.

python
POLICY = {
    "customer.resolve":         {"scopes": {"crm:read"}},
    "billing.get_subscription": {"scopes": {"billing:read"}},
    "billing.change_plan":      {"scopes": {"billing:write"}, "approval": "human"},
    "billing.issue_credit":     {"scopes": {"billing:write"}, "max": {"amount_eur": 500}},
    "email.send":               {"scopes": {"comms:write"}},
}

def authorize(step, args, actor):
    rule = POLICY.get(step["tool"])
    if rule is None:
        return Deny(f"step {step['n']}: tool {step['tool']} not in policy")
    missing = rule["scopes"] - actor.scopes
    if missing:
        return Deny(f"step {step['n']}: actor lacks {sorted(missing)}")
    for field, ceiling in rule.get("max", {}).items():
        if args.get(field, 0) > ceiling:
            return Deny(f"step {step['n']}: {field}={args[field]} exceeds {ceiling}")
    if rule.get("approval") == "human":
        return NeedsApproval(step, args)
    return Allow()

This check runs twice over the plan's lifetime, and the two runs catch different things because they have different information available. Over the plan as written, before any side effect, args holds only what the planner literally typed, so the pass catches an unknown tool, a missing scope, and any ceiling breach on a literal argument. That is cheap and it happens once. Over each step at dispatch, after ${...} references are resolved, args holds the values earlier steps produced, so this pass catches the ceilings that could not be evaluated earlier.

The credit step is the case that decides the difference. billing.issue_credit carries a ceiling of 500 euro, and the credit this run issued was €4,180, so the ceiling would have denied it — but only at dispatch, because the amount is computed from a subscription the run had not yet read at the moment the plan was written. Claiming a plan-time authorization pass catches everything is the common overstatement here. What it licenses is precise and worth having: every tool the run may call, and every actor scope it needs, are decidable before anything happens, so a plan that touches something the actor cannot touch never starts. What it leaves is every argument whose value is a reference, which on a typical six-step plan is about half of them, and the ceilings over those are enforced mid-run with earlier steps already committed.

One more property of that function is worth reading off the code rather than inferring from the design. authorize has three possible verdicts and the executor below distinguishes two of them, so a step needing human approval takes the same path as a step that was refused.

python
def execute(plan, state, actor):
    for step in plan["steps"]:
        args = bind(step["args"], state)            # "${account_id}" -> "acct_8841k"
        verdict = authorize(step, args, actor)
        if not isinstance(verdict, Allow):
            return state.halt(step, verdict)
        try:
            result = TOOLS[step["tool"]](**args)
        except ToolError as e:
            return state.fail(step, kind="tool_error", code=e.code, retryable=e.retryable)
        if not check(step["post"], result):
            return state.fail(step, kind="postcondition_unmet",
                              expected=step["post"], observed=summarise(result))
        state.complete(step, result, binds=step.get("binds"))
    return state.done()

No model call appears in that loop. For a step like billing.get_subscription none is needed, and the executor is a dispatcher with a validator bolted on. Steps that genuinely need generation say so — the confirmation email body is {"tool": "llm.compose", "args": {"template": "plan_change_confirm", "facts": "${...}"}} with its own postcondition on the output — which means model calls are spent where judgement is required and nowhere else. That is a cost property the fifth chapter will need.

The prose plan was not an oversight, and the history explains why it looked adequate. Plan-and-Solve prompting, which is where the split-then-execute idea entered the mainstream, produced its plan as text inside a single prompt. Text was enough there because the only consumer of the plan was the same model call that wrote it, with its own reasoning still in context — the sentences were notes to self, and a note to self does not have to be unambiguous to anybody else. Once the planner and the executor are separate calls with separate contexts, the second one has no access to the first one's reasoning, and prose stops carrying enough. The step schema is what a plan has to become when it must survive a context boundary.

What structure does not buy

A well-formed step can be confidently wrong. Resolving by the requester's email address is a plan-time choice, and if both Northwind entities share a domain the resolve returns two results again — at which point the postcondition fires and the run stops, which is a better outcome than the March one and is not a correct downgrade. Structure moves failures from silent to attributable. It does not reduce how often the planner is mistaken about the world, and a reader who takes the schema as a correctness mechanism has substituted one over-broad belief for another.

This is also not the same idea as having the planner emit code. A plan expressed as a Python function is more expressive and collapses the boundary, because the validator would have to reason about arbitrary control flow to answer "does this run touch billing.issue_credit, and with what ceiling" — a question that is decidable by inspection over a list and undecidable in general over a program. The step list is deliberately weaker than a program so that questions about it stay answerable by reading it.

And structured steps are not the same commitment as running two model calls. Nothing in the schema requires a separate planner; a single loop can emit exactly this record before each tool call and get most of the inspectability, which is an argument the last chapter takes seriously. What the split adds is that the sequence is decided once, in one place, by something that can be reviewed before any of it happens.

The remaining gap is what happens when a step in the middle does not hold. Step 0 failing is easy, since nothing has happened yet. The March plan's real hazard is step three failing after steps zero through two have already run and left state behind, with three more steps queued that were written on the assumption step three would succeed. The executor above returns state.fail and stops, which is honest and incomplete, because the work still has to get done.

Planner–executor — read the current version at https://daviddominguez.dev/learn/en/planner-executor/a-plan-step-is-a-tool-its-arguments-and-a-postcondition

Back to top