A plan it can see is a plan it can keep

Plan mode produced a flat list of steps and then forgot it. Nothing told the
model to look before proposing, nothing let it ask when the scope was
ambiguous, and -- worst -- once execution started the plan was not in the prompt
at all, so it could not have kept it current if it had wanted to.

The shape is findings, objectives and phases of tasks now. Findings are the part
people skip and the part that makes a plan worth reading: what is actually
there, what surprised you, what the plan is working around. Plan mode is told to
research first and to ask with ask_user when the scope is genuinely ambiguous,
in one question rather than three.

steps is still always written, flattened from every phase in order. That is the
whole of the compatibility story: execute_plan reads it and needed no change,
and every row already on disk still works. services/plans.py:normalise is the
only place that knows version 1 existed -- a {title, steps} row comes back as
one phase, so the card, the harness and the Execute button have one shape to
deal with rather than two.

Chat.plan_message_id is what puts the plan in front of the model each turn, with
one primary-key lookup rather than a scan for "the newest message carrying a
plan" -- context_variables is synchronous and sits on the request path.
plan_update is offered only once there is a plan, because a tool for changing
something that does not exist costs a round to find out.

It is RISK_READ, and that sits in tension with notes_edit being RISK_WRITE, so:
risk is what a tool does to the world, and the world the four modes govern is
the machine. This cannot touch it. RISK_WRITE would put an approval card on
screen every time a task was ticked off -- four cards to carry out a four-task
plan, each approving a bookkeeping entry -- which is exactly the interruption
batching exists to prevent. A note is a durable artefact of the reader's that
outlives the chat; this is the chat's own record of what it is doing, nearer to
generation.status. An administrator who disagrees puts it in deny_default.

One thing that nearly went wrong quietly. A runner cannot write the message row,
since _persist is the single writer -- so plan_update returns the merged plan on
its event and the loop carries it. Both calls in a round would then have read
the same stale plan from the database and the second would have won. They merge
into AgentContext.plan instead, the snapshot seeded once when the context is
resolved. Both tools write event["plan"] so _persist stays one writer with one
rule; only plan_submit sets plan_final, which is what withdraws the tools.

The card does not re-render in place. The newest bubble carries the current plan
and older ones carry the plan as it was then -- that is what a transcript is
for, it needs no streaming machinery, and it makes "what did it think at step
three" answerable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-03 11:14:34 +02:00
parent bc141eae10
commit 4b8fd6bad2
13 changed files with 1232 additions and 45 deletions
+24 -4
View File
@@ -125,10 +125,16 @@ class Generation:
# A model that fills its own context with build logs has no room left to
# answer with.
output_bytes: int = 0
# A plan proposed in Plan mode: {"title": str, "steps": [str, ...]}. Ends
# the reply and is written onto the message, so the Execute button sends
# exactly what was proposed rather than something parsed back out of prose.
# A plan proposed in Plan mode, or one being kept current while it is
# carried out. See services/plans.py for the shape. Written onto the
# message, so the Execute button sends exactly what was proposed rather than
# something parsed back out of prose.
plan: dict | None = None
# Whether that plan came from `plan_submit`, which ends the turn, rather
# than from `plan_update`, which does not. Both write `plan` so that
# `_persist` stays one writer with one rule; only this decides whether the
# tools are withdrawn for a final round.
plan_final: bool = False
# The queue, seen from the reply's side. `drained` says this reply's ending
# handed the next waiting prompt to a fresh one; `injected_ids` names the
# prompts taken into *this* reply between two rounds of tool calls. Both are
@@ -492,6 +498,13 @@ async def _run(generation: Generation) -> None:
messages.append(tools_service.tool_turn(call, outcome.content))
if outcome.event.get("plan"):
generation.plan = outcome.event["plan"]
# Only `plan_submit` sets this. `plan_update` writes the
# same key -- so `_persist` stays one writer with one
# rule -- but is bookkeeping mid-work and must not end the
# reply, or the turn would stop dead every time a task was
# ticked off.
if outcome.event.get("plan_final"):
generation.plan_final = True
generation.touch()
# Something typed while this reply was working. Taken in here, at a
@@ -517,7 +530,7 @@ async def _run(generation: Generation) -> None:
# though it had nothing to add -- but with the tools withdrawn, so
# "one more round" cannot become three rounds of it changing its
# mind about a plan the reader is being asked to approve.
if generation.plan is not None:
if generation.plan_final:
offered = []
payload.pop("tools", None)
@@ -1221,6 +1234,13 @@ def _persist(generation: Generation, title: str, elapsed: float) -> None:
message.reasoning_ms = generation.reasoning_ms
message.tool_calls_json = generation.tool_events
message.plan_json = generation.plan or {}
if generation.plan:
# This bubble now carries the plan in force, and the chat points
# at it so the harness can find it with one primary-key lookup
# rather than a scan. Older bubbles keep the plan as it was then,
# which is what a transcript is for -- the card is never
# re-rendered in place.
chat.plan_message_id = message.id
message.usage_json = metrics_service.to_json(
metrics_service.from_generation(generation)
)