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
+8 -3
View File
@@ -64,9 +64,14 @@ MODE_GUIDANCE = {
),
MODE_PLAN: (
"You are in **Plan** mode: read and explore freely, but change nothing. "
"Anything that writes or runs will be stopped for approval, so do not "
"rely on it. Finish by setting out what you would do, as steps, so it "
"can be carried out afterwards."
"Research before you propose anything — read the files, run the "
"read-only commands, look at what is actually there rather than at what "
"is usually there. If the scope is genuinely ambiguous, and only then, "
"ask with ask_user before planning rather than planning for the wrong "
"thing; put everything you need into one question. Then finish with "
"plan_submit: what you found, what the work is for, and the work itself "
"as phases of concrete tasks. Anything that writes or runs will be "
"stopped for approval, so do not rely on it."
),
}
+28
View File
@@ -78,6 +78,14 @@ class AgentContext:
# requiring a re-read in the reply that edits is asking for something it
# needs anyway.
read_paths: set[str] = field(default_factory=set)
# The plan currently in force, seeded from `chat.plan_message_id` when this
# is resolved. Mutable and read/written in place by `plan_update`, for a
# reason that is not obvious: a runner cannot write the message row --
# `_persist` is the single writer -- so it returns the merged plan on its
# event and the loop carries it. Two updates in one reply would then both
# read the same stale plan from the database and the second would lose the
# first. This snapshot is what they actually merge into.
plan: dict[str, Any] = field(default_factory=dict)
def executor(self) -> Executor:
return ssh_service.SshExecutor(self.spec, self.project_dir)
@@ -90,6 +98,25 @@ class AgentContext:
return replace(self, approved=True)
def _plan_of(db: DBSession, chat: Chat) -> dict[str, Any]:
"""The plan this chat is working to, or an empty dict.
One `db.get` by primary key -- the column exists to avoid a scan for "the
newest message carrying a plan", because this runs while a request is
waiting. The id is validated here rather than constrained in the schema, for
the reason the column's comment gives.
"""
from lembas.db.models import Message
from lembas.services import plans
if not chat.plan_message_id:
return {}
message = db.get(Message, chat.plan_message_id)
if message is None or message.chat_id != chat.id:
return {}
return plans.normalise(message.plan_json)
def profile_for(db: DBSession, chat: Chat, user: User | None) -> SshProfile | None:
"""The connection this chat is pointed at, if it is still usable.
@@ -135,6 +162,7 @@ def resolve(db: DBSession, chat: Chat, user: User | None) -> AgentContext | None
return AgentContext(
chat_id=chat.id,
label=profile.label,
plan=_plan_of(db, chat),
project_dir=chat.project_dir or profile.default_dir or "",
profile_id=profile.id,
mode=chat.agent_mode if chat.agent_mode in policy.MODES else policy.MODE_MANUAL,
+225 -20
View File
@@ -22,6 +22,7 @@ import logging
import posixpath
from typing import Any
from lembas.services import plans
from lembas.services.agent import index, patch, policy
from lembas.services.agent.base import ExecError, ExecRequest
from lembas.services.agent.session import AgentContext
@@ -382,36 +383,106 @@ async def _run_plan(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
"""Record a plan and stop.
Writes nothing and runs nothing, which is why it is `RISK_READ` and works in
Plan mode without asking. The loop notices the event and ends the reply
Plan mode without asking. The loop notices `plan_final` and ends the reply
there: a plan followed by three more rounds of the model changing its mind
is not a plan.
`steps` is still accepted alongside the structure. A small model sends it,
`plans.normalise` turns it into one phase, and refusing would cost a whole
round trip to say so.
"""
agent = _agent(context)
title = str(args.get("title") or "").strip() or "A plan"
steps = [str(s).strip() for s in (args.get("steps") or []) if str(s).strip()]
steps = steps[:MAX_STEPS]
plan = plans.build(
title=args.get("title"),
summary=args.get("summary"),
findings=args.get("findings"),
objectives=args.get("objectives"),
phases=args.get("phases"),
steps=args.get("steps"),
)
if not steps:
if not plan or not plan["steps"]:
return ToolOutcome(
"A plan needs at least one step. Say what you would actually do.",
"A plan needs at least one task. Say what you would actually do, as "
"phases of concrete tasks — or as a flat list of steps if there is "
"only one phase of work.",
{"name": "plan_submit", "kind": "plan", "status": "error",
"error": "No steps.", "results": []},
"error": "No tasks.", "results": []},
)
if agent is not None:
agent.plan = plan
return ToolOutcome(
"Plan recorded. Stop here — they will read it and decide whether to "
"carry it out. Do not start doing it.",
{
"name": "plan_submit",
"kind": "plan",
"label": agent.label if agent else "",
"query": title,
"detail": agent.label if agent else "",
"query": plan["title"],
"status": "ok",
"results": [],
# Read back by the loop, which puts it on the message so the
# Execute button sends exactly what was proposed rather than an
# approximation parsed out of the prose.
"plan": {"title": title, "steps": steps},
"plan": plan,
# Only `plan_submit` sets this, and it is what withdraws the tools
# for the last round. `plan_update` is bookkeeping in the middle of
# work and must not end the reply.
"plan_final": True,
},
)
async def _run_plan_update(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
"""Tick something off, or record something found.
`RISK_READ`, and the reasoning is worth stating because it sits in tension
with `notes_edit` being `RISK_WRITE`. Risk is about what a tool does to *the
world*, and the world the four modes govern is the machine -- this cannot
touch it. Practically, `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 one approving a bookkeeping entry, which is exactly the interruption
that batching approvals exists to prevent. The distinguishing line against
`notes_edit` is that a note is a durable artefact of the reader's that
outlives the chat, while this is the chat's own record of what it is doing.
An administrator who disagrees puts `plan_update` in the deny list.
It reads and writes `agent.plan` rather than the database, because a runner
cannot write the message row -- and because two updates in one reply would
otherwise both read the same stale plan and the second would lose the first.
"""
agent = _agent(context)
if agent is None or not agent.plan:
return ToolOutcome(
"There is no plan for this conversation yet, so there is nothing to "
"update.",
{"name": "plan_update", "kind": "plan", "status": "error",
"error": "No plan.", "results": []},
)
plan, changed = plans.merge(agent.plan, args)
if not changed:
return ToolOutcome(
"Nothing in the plan changed. Quote a task or objective id from the "
"plan above — they look like t1 and o1.",
{"name": "plan_update", "kind": "plan", "status": "error",
"error": "Nothing matched.", "results": []},
)
agent.plan = plan
return ToolOutcome(
"Plan updated: " + ", ".join(changed) + ". Carry on with the work.",
{
"name": "plan_update",
"kind": "plan",
"detail": agent.label,
"query": plan["title"],
"status": "ok",
"results": [],
"plan": plan,
"text": "\n".join(changed),
},
)
@@ -545,33 +616,167 @@ def tool_defs(context: AgentContext | None = None) -> list[ToolDef]:
name="plan_submit",
family=FAMILY_AGENT,
description=(
"Set out what you would do, as an ordered list of steps, and "
"stop. Use this to finish when you have been asked to plan "
"rather than to act: they will read it and decide whether to "
"carry it out. Each step should be one thing, concrete enough "
"to follow — name the files and the commands."
"Set out what you would do, and stop. Use this to finish when you "
"have been asked to plan rather than to act: they will read it "
"and decide whether to carry it out.\n"
"\n"
"Say what you FOUND while looking, what the work is FOR, and then "
"the work itself as PHASES of concrete tasks. A task should be "
"one thing, specific enough to follow — name the files and the "
"commands. If the work is short enough that phases would be "
"ceremony, send `steps` instead and it becomes one phase.\n"
"\n"
"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."
),
parameters={
"type": "object",
"properties": {
"title": {**_STRING, "description": "What the plan achieves, in a line."},
"summary": {
**_STRING,
"description": "One line on the approach. Optional.",
},
"findings": {
"type": "array",
"items": _STRING,
"description": (
"What you established while looking: what is there, "
"what constrains the work, what you ruled out."
),
},
"objectives": {
"type": "array",
"items": _STRING,
"description": "What this is for. What has to be true at the end.",
},
"phases": {
"type": "array",
"description": "The work, in order.",
"items": {
"type": "object",
"properties": {
"title": {**_STRING, "description": "What this phase does."},
"tasks": {
"type": "array",
"items": _STRING,
"description": "One thing each, in order.",
},
},
"required": ["title", "tasks"],
},
},
"steps": {
"type": "array",
"items": _STRING,
"description": "The steps, in order.",
"description": (
"Instead of phases, when the work is one phase. "
"Becomes a single phase."
),
},
},
"required": ["title", "steps"],
"required": ["title"],
},
run=_run_plan,
# It writes nothing and runs nothing, so it needs no approval --
# which is the point: Plan mode has to be able to finish.
risk=RISK_READ,
),
ToolDef(
name="plan_update",
family=FAMILY_AGENT,
description=(
"Keep the plan current while you carry it out. Call it when a "
"task finishes, when something you find changes what needs doing, "
"and when a task turns out to be unnecessary — as you go, not at "
"the end. The plan is what somebody reads to see where you are.\n"
"\n"
"Quote the ids from the plan in your prompt: tasks are t1, t2 and "
"so on, objectives are o1. This does not end your turn; carry on "
"with the work afterwards."
),
parameters={
"type": "object",
"properties": {
"task_status": {
"type": "array",
"description": "Tasks whose state has changed.",
"items": {
"type": "object",
"properties": {
"id": {**_STRING, "description": "The task id, e.g. t3."},
"status": {
**_STRING,
"description": "todo, doing, done or dropped.",
},
"note": {
**_STRING,
"description": "A short note about it. Optional.",
},
},
"required": ["id", "status"],
},
},
"objective_status": {
"type": "array",
"description": "Objectives whose state has changed.",
"items": {
"type": "object",
"properties": {
"id": {**_STRING, "description": "The objective id, e.g. o1."},
"status": {
**_STRING,
"description": "open, done or dropped.",
},
},
"required": ["id", "status"],
},
},
"findings": {
"type": "array",
"items": _STRING,
"description": "Anything new you have established.",
},
"add_tasks": {
"type": "array",
"description": "Work the plan did not anticipate.",
"items": {
"type": "object",
"properties": {
"text": {**_STRING, "description": "The task."},
"phase": {
**_STRING,
"description": (
"Which phase it belongs to, e.g. p2. "
"Defaults to the one in progress."
),
},
},
"required": ["text"],
},
},
"summary": {**_STRING, "description": "Where things stand, in a line."},
},
"required": [],
},
run=_run_plan_update,
# See `_run_plan_update`: it cannot touch the machine, and asking
# about it would mean an approval card per ticked-off task.
risk=RISK_READ,
),
]
if context is not None and context.mode != policy.MODE_PLAN:
return [tool for tool in defs if tool.name != "plan_submit"]
return defs
if context is None:
return defs
# `plan_submit` in Plan mode and nowhere else; `plan_update` everywhere
# else, and only once there is a plan to update. Offering it with no plan
# would be the skills asymmetry again -- a tool for changing something that
# does not exist, which costs a round to find out.
drop = {"plan_submit"} if context.mode != policy.MODE_PLAN else {"plan_update"}
if not context.plan:
drop.add("plan_update")
return [tool for tool in defs if tool.name not in drop]
__all__ = ["FAMILY_AGENT", "MAX_DIFF_LINES", "MAX_EVENT_CHARS", "tool_defs"]