Chapter 6 of 6 · 9 min read
Inspectability does not require a planner to buy it
On this page
The #supply-weekly bot pulls the vendor scorecard from the analytics API at 09:00 on Mondays, writes six lines of summary, and posts it to a Slack channel. It is a fused loop with three tools and roughly forty lines of glue around one model call. The pull request that landed in April replaced it with a planner and an executor, and the justification in the description was a single sentence: with an explicit plan we can see what the agent decided to do.
The diff was 610 added lines across five files, because a plan needs a schema, the schema needs a validator, the validator needs the tool registry wired to it, the run state needs somewhere to live, and the recovery path needs a policy and a budget even if it never fires. Median wall time went from 4.1 seconds to 6.0, the difference being one planner call against a 1,400-token prompt. In the ninety days before the PR the bot had failed eleven times: seven Slack 429s that the retry policy absorbed, three 502s from the analytics gateway that the retry policy also absorbed, and one Monday where the upstream ETL job, the nightly extract that loads the scorecard tables, had not finished and the scorecard came back empty, so the bot posted a summary of nothing. That last one is a real defect and its fix is a schedule change plus a precondition on row count, both of which fit inside the loop that already exists.
None of the eleven involved a failed step that invalidated later steps. None required a remainder to be re-derived from completed work. The machinery from the previous four chapters exists to handle a class of failure this bot has never produced, and the PR was reviewed on the strength of an argument that had nothing to do with failures at all.
The trace was the thing wanted, and the plan was one way to get it
What the PR author wanted is legitimate. On a Monday when the summary reads oddly, the question is which scorecard the bot fetched, with which week parameter, and what it expected back — and a transcript of assistant messages interleaved with raw tool results is a poor way to answer that, because the only record of intent is whatever the model happened to narrate. A plan object answers it directly, since each step names a tool, its bound arguments and a postcondition before anything runs.
A fused loop can produce the same record. The loop already knows the tool name and the arguments a fraction of a second before it dispatches them; what it does not do by default is write them down as an intention separate from the call. Adding two required fields to every tool schema and one durable write before dispatch closes that gap.
# every tool schema gains two required parameters
INTENT_FIELDS = {
"why": {"type": "string", "description": "what this call is for, one sentence"},
"expects": {"type": "string", "description": "what a successful result looks like"},
}
def call_tool(name, args, *, run_id, seq):
why, expects = args.pop("why"), args.pop("expects")
log.emit("agent.intent", run=run_id, seq=seq, tool=name,
args=redact(args), why=why, expects=expects, at=now())
verdict = authorize(name, args, actor=RUN_ACTOR) # same policy table as a split
if not isinstance(verdict, Allow):
log.emit("agent.denied", run=run_id, seq=seq, reason=str(verdict))
return {"error": "denied", "detail": str(verdict)}
try:
result = TOOLS[name].fn(**args)
except ToolError as e:
log.emit("agent.result", run=run_id, seq=seq, ok=False, code=e.code)
raise
log.emit("agent.result", run=run_id, seq=seq, ok=True,
summary=summarise(result), ms=elapsed())
return resultThe expects field is the part that does the work, because it forces a claim the result can be checked against, and the check is available afterwards to a person reading the log or to an assertion running over stored traces. It is weaker than the postcondition chapter one described, since the model writes it in prose and it is evaluated by eye rather than by a probe, and for a Monday morning report bot that is an appropriate amount of rigour.
09:00:02 r_5f21 seq 0 analytics.scorecard(week="2026-W15", group="tier1")
why "get last week's tier-1 vendor scores"
expects "one row per tier-1 vendor, about 14 rows"
-> ok, 14 rows, 820 ms
09:00:04 r_5f21 seq 1 llm.summarise(rows=<14>, style="six_lines")
why "condense the table for a channel post"
expects "six lines, worst three vendors named"
-> ok, 61 words, 1,910 ms
09:00:06 r_5f21 seq 2 slack.post(channel="#supply-weekly", text=<61w>)
why "deliver the summary"
expects "a message ts returned"
-> ok, ts=1744099206.4471, 240 msThat is thirty lines of code, no additional model call, no second prompt and no boundary to recover across. Against the 610-line PR it answers the same question for the same reader.
| What the plan artefact was wanted for | Fused loop with an intent log | What the split adds beyond that |
|---|---|---|
| Which tool ran, with which arguments | Recorded before dispatch, in the same record as the result | Nothing |
| Why that action was chosen | The why field, written by the model at the moment of choosing | The planner's reasoning, covering the sequence rather than the step |
| Whether the result matched the intention | The expects field, checked by eye or by an assertion over stored traces | A machine-checked postcondition written by a different component |
| Seeing the whole sequence before any of it runs | Not available — the loop chooses step n after step n−1 returns | This is the actual purchase |
| Permission and ceiling checks before dispatch | Yes; the policy table is a property of the tool, not of the architecture | The literal-valued arguments can be checked once, over the plan |
| Re-deriving the remaining work after a failure | Not available — nothing in the loop states what the remainder was | Run state, completed work, and a re-planning call |
Two rows in that table are empty on the left, and they are the two things worth paying for. Everything else in the column is available for thirty lines and no latency.
What has to be true before the sequence needs deciding in advance
The split earns its cost when something needs to happen to the sequence as a whole, before or after execution, rather than to the individual calls.
Committing in advance is the first case. A plan that can be shown to a person, priced against a budget, or checked for whether it touches a particular tool at all is a plan that has to exist before execution begins, and a fused loop has no such artefact to offer, since it decides each action from the results of the last one. When the question is "will this run issue a credit, and how large", only a plan answers it in time to matter.
Recovery is the second, and it is the one the previous chapters built. It matters when a mid-sequence failure leaves the remainder void and re-deriving that remainder is a different operation from repeating the failed call — which requires the sequence to be long enough for a remainder to exist, and its steps to leave state behind that the next attempt has to reckon with. A three-step read–summarise–post has neither property, because failing at any of the three leaves nothing behind that a re-plan would need to route around.
The third is cost shape rather than correctness. Chapter five's token numbers came from a pipeline where most executor steps were dispatch rather than generation, and that only happens when the step count is high enough for the fused loop's transcript growth to dominate. On a three-step task the loop's transcript never gets large and the planner call is pure overhead.
A rough gate covers most cases: if the run has four or fewer steps, if no step leaves an effect that a later attempt must account for, and if the failures observed in production have been transient rather than plan-invalidating, the machinery has nothing to do. The #supply-weekly bot fails that gate on all three counts, which is the answer the PR review should have reached in a few minutes.
Two refund agents that want different architectures
Refunds are where the "high stakes, therefore split" argument shows up most often, and the stakes are not what decides it. Consider two agents that both process refund requests.
The consumer app's refund agent handles order-level refunds under €50 against a single payment provider. A run looks up the order, evaluates a policy that is four rules in code rather than in a prompt, calls payments.refund once, and sends a notification. It is a fused loop with four tools, an approval gate above €50, and an idempotency key on the refund call. Nothing about the sequence needs deciding in advance, because there is one ordering and it is fixed.
The enterprise billing team's refund agent handles a prorated refund across two to five invoices in a contract's current term, which means computing the unused amount per invoice, issuing a credit note in the ERP, adjusting the tax position for the jurisdiction, cancelling any dunning in progress, updating the CRM, and notifying both the customer and the account manager. The step count runs from six to nine and depends on the contract. Half the steps write to systems that reject re-entry, the amounts derive from reads taken during the run, and finance requires that the full set of amounts is approved before anything posts to the ledger. That approval requirement alone settles it, because there is no version of a fused loop that shows the whole set of postings before making the first one.
The distinguishing property in that pair is that the sequence itself is contested — its shape varies per contract, it must be agreed as a unit before any of it commits, and a failure in the middle leaves a partially refunded customer whose remaining steps have to be recomputed from what actually posted. The euro value of the operations is the same kind of thing in both agents.
| Safety property | Mechanism that provides it | Needs a separate planner |
|---|---|---|
| No refund over €500 without a human | Approval gate declared on the tool | No |
| A repeated call cannot double the refund | Idempotency key derived from run, step and canonical arguments | No |
| The agent cannot touch accounts outside its scope | Scopes on the actor, checked at dispatch | No |
| A wrong refund can be reversed | Compensating credit note declared on the tool | No |
| Someone approves the whole set of postings before the first one | A plan artefact that exists before execution | Yes |
| The remainder is recomputed after a mid-sequence failure | Run state, completed work, a re-planning call | Yes |
Every mechanism in the first four rows is a property of the tool and its policy, and each one is available to a single loop without changing its architecture, because the permission table and the tool registry from the earlier chapters were never coupled to the split. The split is about who decides the sequence and when that decision becomes reviewable.
The middle position and where it gets uncomfortable
There is an arrangement between the two that gets less attention than it deserves: a single loop that emits its intended sequence as a structured list on its first turn, then executes inside the same loop with the same context. It costs no extra model call, and it produces an artefact that can be shown for approval before the first tool runs, which covers the first of the two purchases without any of the second's machinery.
The weakness is enforcement, since nothing stops the loop from deviating from its own list once results start arriving, and a plan the agent may silently abandon is worse than no plan because it reads as a commitment. Making it binding means the dispatcher rejects any call not present in the announced list, at which point the announced list is a plan, the dispatcher is a validator, and a deviation has to route somewhere. That somewhere is a re-planning call with completed and remaining work, which is the split arriving through the back door. The middle position is stable when approval is the whole requirement and recovery genuinely is not, and it collapses toward the split as soon as deviation has to be handled properly.
The variants worth knowing about when you do build the split are collected in LangGraph's planning-agents material, which covers plan-and-execute alongside ReWOO, where the plan uses variable substitution to avoid re-reading context, and LLMCompiler, which schedules independent steps in parallel from the same dependency information. Both are answers to costs this topic measured, and both assume the plan artefact already exists.
The first thing to instrument on an existing agent is the rate at which a final step failure invalidates work that was still queued, counted over a month of real runs, alongside the count of steps whose effects a second attempt has to reckon with. Those two numbers decide the architecture more reliably than any argument made in a review, and neither requires building anything to obtain. Start the loop fused, log the intent before every call, and let a specific failure that the fused shape cannot answer be the thing that buys the planner.
Planner–executor — read the current version at https://daviddominguez.dev/learn/en/planner-executor/inspectability-does-not-require-a-planner-to-buy-it