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 7977d4ef25
commit f1933216f6
13 changed files with 1232 additions and 45 deletions
+10 -1
View File
@@ -1116,11 +1116,20 @@ async def execute_plan(
if chat.kind != KIND_AGENT:
raise HTTPException(status.HTTP_409_CONFLICT, "This chat cannot act on anything.")
plan = message.plan_json
# `message.plan`, the property, so a row written before version 2 comes
# through as one phase. `steps` is flattened from every phase in order and
# is always written, which is why this line needed no change when the shape
# grew findings, objectives and phases.
plan = message.plan
steps = [str(s) for s in (plan.get("steps") or [])]
body = "\n".join(f"{n}. {step}" for n, step in enumerate(steps, start=1))
chat.agent_mode = agent_policy.MODE_EDIT
# The chat is now working to this plan, so the harness puts it in front of
# the model each turn and `plan_update` is offered. Without this the model
# carrying it out cannot see the plan it is carrying out, and could not tick
# anything off if it wanted to.
chat.plan_message_id = message.id
db.commit()
content = (