Files
LLeMbas/src/lembas/services/plans.py
T
Jaroslav BenešandClaude Opus 5 f1933216f6 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>
2026-08-03 11:14:34 +02:00

343 lines
12 KiB
Python

"""A plan, as a structure rather than a list of sentences.
Plan mode used to produce `{title, steps}` and then forget it. That is enough to
propose something and useless for carrying it out: there is nowhere to record
what was found, nothing to tick off, and — worst — the plan was not in the
prompt at all once execution started, so a model could not have kept it current
if it had wanted to.
Version 2 is findings, objectives and phases of tasks. Three rules hold it up.
**`steps` is always written.** Flattened from every phase's tasks, in order. It
is what `execute_plan` reads, so nothing downstream had to learn version 2 and
every row already on disk keeps working.
**`normalise` is the only reader.** A `{title, steps}` row becomes one phase
called "Plan" whose tasks are those steps, so the card, the harness and the
Execute button have exactly one shape to deal with rather than two.
**Ids are generated here and never chosen by the model.** They appear in
`render_block` so the model can quote one back to `plan_update`; letting it name
them would mean validating names it made up, and a collision would silently
re-tick a different task.
"""
from __future__ import annotations
from typing import Any
VERSION = 2
# Bounds. A plan is read by a person and injected into every request while the
# work is going on, so "as many as you like" costs the window forever and buries
# the four items that mattered.
MAX_PHASES = 8
MAX_TASKS = 12
MAX_OBJECTIVES = 8
MAX_FINDINGS = 20
MAX_TEXT = 300
MAX_TITLE = 120
# The ceiling on the block put in front of the model each turn.
MAX_PLAN_CHARS = 2000
TASK_STATUSES = ("todo", "doing", "done", "dropped")
OBJECTIVE_STATUSES = ("open", "done", "dropped")
PHASE_STATUSES = ("pending", "active", "done")
_DONE = {"done", "dropped"}
def _text(value: Any, limit: int = MAX_TEXT) -> str:
return " ".join(str(value or "").split())[:limit]
def _status(value: Any, allowed: tuple[str, ...], fallback: str) -> str:
wanted = str(value or "").strip().lower()
return wanted if wanted in allowed else fallback
def _listed(value: Any) -> list[Any]:
"""A list, from a list or from the one thing a model sent instead.
The same tolerance `generation._questions_in` shows, for the same reason: a
small model sends something close to the schema rather than the schema, and
refusing costs a whole round trip to say so.
"""
if value is None:
return []
if isinstance(value, list):
return value
return [value]
# --- Reading -------------------------------------------------------------------
def normalise(raw: dict[str, Any] | None) -> dict[str, Any]:
"""Any stored plan, as version 2.
A `{title, steps}` row -- which is every row that exists today -- becomes one
phase called "Plan" whose tasks are the steps. Everything downstream then has
one shape, and the version-1 branch lives here and nowhere else.
"""
raw = raw or {}
if not raw:
return {}
title = _text(raw.get("title"), MAX_TITLE) or "A plan"
findings = [
{"id": f"f{n}", "text": _text(item.get("text") if isinstance(item, dict) else item)}
for n, item in enumerate(_listed(raw.get("findings"))[:MAX_FINDINGS], start=1)
]
findings = [f for f in findings if f["text"]]
objectives = []
for n, item in enumerate(_listed(raw.get("objectives"))[:MAX_OBJECTIVES], start=1):
source = item if isinstance(item, dict) else {"text": item}
text = _text(source.get("text"))
if text:
objectives.append(
{
"id": f"o{n}",
"text": text,
"status": _status(source.get("status"), OBJECTIVE_STATUSES, "open"),
}
)
phases = _phases(raw)
if not phases:
# Version 1, or a model that sent only steps. One phase, so the rest of
# the codebase never sees the older shape.
tasks = [_text(step) for step in _listed(raw.get("steps"))]
phases = [
{
"id": "p1",
"title": "Plan",
"status": "pending",
"tasks": [
{"id": f"t{n}", "text": text, "status": "todo", "note": ""}
for n, text in enumerate([t for t in tasks if t][:MAX_TASKS], start=1)
],
}
]
plan = {
"version": VERSION,
"title": title,
"summary": _text(raw.get("summary")),
"findings": findings,
"objectives": objectives,
"phases": phases,
}
plan["steps"] = flatten(plan)
return plan
def _phases(raw: dict[str, Any]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
counter = 0
for n, item in enumerate(_listed(raw.get("phases"))[:MAX_PHASES], start=1):
source = item if isinstance(item, dict) else {"title": item}
tasks = []
for entry in _listed(source.get("tasks"))[:MAX_TASKS]:
got = entry if isinstance(entry, dict) else {"text": entry}
text = _text(got.get("text"))
if not text:
continue
counter += 1
tasks.append(
{
"id": f"t{counter}",
"text": text,
"status": _status(got.get("status"), TASK_STATUSES, "todo"),
"note": _text(got.get("note")),
}
)
title = _text(source.get("title"), MAX_TITLE)
if not title and not tasks:
continue
out.append(
{
"id": f"p{n}",
"title": title or f"Phase {n}",
"status": _status(source.get("status"), PHASE_STATUSES, "pending"),
"tasks": tasks,
}
)
return out
def flatten(plan: dict[str, Any]) -> list[str]:
"""Every task, in order, as plain sentences.
This is `steps`, and it is why version 2 needed no migration: `execute_plan`
reads it and does not know the rest exists.
"""
return [task["text"] for phase in plan.get("phases", []) for task in phase.get("tasks", [])]
# --- Writing --------------------------------------------------------------------
def build(**raw: Any) -> dict[str, Any]:
"""A plan from what `plan_submit` was given."""
return normalise(raw)
def merge(plan: dict[str, Any], patch: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
"""The plan with one update applied, and what changed, in words.
Returns the words as well as the plan because the model gets them back as
the tool's result -- "t3 is done, t4 is now doing" is what tells it the
bookkeeping landed, and a silent success reads as a call that did nothing.
"""
plan = normalise(plan)
if not plan:
return {}, []
changed: list[str] = []
tasks = {task["id"]: task for phase in plan["phases"] for task in phase["tasks"]}
objectives = {item["id"]: item for item in plan["objectives"]}
for entry in _listed(patch.get("task_status")):
got = entry if isinstance(entry, dict) else {"id": entry}
task = tasks.get(_text(got.get("id"), 32))
if task is None:
continue
task["status"] = _status(got.get("status"), TASK_STATUSES, task["status"])
if got.get("note") is not None:
task["note"] = _text(got.get("note"))
changed.append(f"{task['id']} is {task['status']}")
for entry in _listed(patch.get("objective_status")):
got = entry if isinstance(entry, dict) else {"id": entry}
objective = objectives.get(_text(got.get("id"), 32))
if objective is None:
continue
objective["status"] = _status(
got.get("status"), OBJECTIVE_STATUSES, objective["status"]
)
changed.append(f"{objective['id']} is {objective['status']}")
for raw in _listed(patch.get("findings")):
text = _text(raw.get("text") if isinstance(raw, dict) else raw)
if not text or len(plan["findings"]) >= MAX_FINDINGS:
continue
plan["findings"].append({"id": f"f{len(plan['findings']) + 1}", "text": text})
changed.append("a finding was recorded")
counter = max((int(t["id"][1:]) for t in tasks.values() if t["id"][1:].isdigit()), default=0)
for entry in _listed(patch.get("add_tasks")):
got = entry if isinstance(entry, dict) else {"text": entry}
text = _text(got.get("text"))
if not text:
continue
phase = _phase_for(plan, _text(got.get("phase"), 32))
if phase is None or len(phase["tasks"]) >= MAX_TASKS:
continue
counter += 1
phase["tasks"].append(
{"id": f"t{counter}", "text": text, "status": "todo", "note": ""}
)
changed.append(f"t{counter} was added")
if patch.get("summary") is not None:
plan["summary"] = _text(patch.get("summary"))
_restate_phases(plan)
plan["steps"] = flatten(plan)
return plan, changed
def _phase_for(plan: dict[str, Any], wanted: str) -> dict[str, Any] | None:
"""The named phase, or the one work is currently in."""
for phase in plan["phases"]:
if phase["id"] == wanted:
return phase
for phase in plan["phases"]:
if phase["status"] == "active":
return phase
for phase in plan["phases"]:
if any(task["status"] not in _DONE for task in phase["tasks"]):
return phase
return plan["phases"][-1] if plan["phases"] else None
def _restate_phases(plan: dict[str, Any]) -> None:
"""A phase's status follows from its tasks, so it cannot disagree with them.
Asking the model to keep both current would mean a plan that says "phase 1:
done" over four tasks marked todo, which is worse than either alone.
"""
started = False
for phase in plan["phases"]:
if not phase["tasks"]:
continue
if all(task["status"] in _DONE for task in phase["tasks"]):
phase["status"] = "done"
continue
# The first phase with anything left in it is the one being worked on;
# everything after it is still to come. There is exactly one active
# phase by construction, which is what stops the render showing three.
phase["status"] = "pending" if started else "active"
started = True
# --- For the prompt ---------------------------------------------------------------
def render_block(plan: dict[str, Any] | None, budget: int = MAX_PLAN_CHARS) -> str:
"""The plan as the model sees it each turn, within a budget.
Budgeted rather than dumped, exactly like the project listing: a finished
phase collapses to one line, the phase being worked on is shown in full, and
the ids are visible because they are what `plan_update` takes.
"""
plan = normalise(plan)
if not plan or budget <= 0:
return ""
lines = [f"**{plan['title']}**"]
if plan["summary"]:
lines.append(plan["summary"])
if plan["objectives"]:
lines.append("")
lines.append("What it is for:")
for item in plan["objectives"]:
mark = "x" if item["status"] == "done" else "-" if item["status"] == "dropped" else " "
lines.append(f"- [{mark}] {item['id']} {item['text']}")
if plan["findings"]:
lines.append("")
lines.append("What was found:")
for item in plan["findings"][-MAX_FINDINGS:]:
lines.append(f"- {item['text']}")
lines.append("")
for phase in plan["phases"]:
done = sum(1 for task in phase["tasks"] if task["status"] in _DONE)
if phase["status"] == "done" and phase["tasks"]:
lines.append(f"✓ {phase['title']} ({len(phase['tasks'])} tasks, done)")
continue
lines.append(f"{phase['title']} ({done}/{len(phase['tasks'])})")
for task in phase["tasks"]:
mark = {"done": "x", "doing": ">", "dropped": "-"}.get(task["status"], " ")
note = f" — {task['note']}" if task["note"] else ""
lines.append(f" [{mark}] {task['id']} {task['text']}{note}")
text = "\n".join(lines).strip()
if len(text) <= budget:
return text
cut = text[:budget]
at = cut.rfind("\n")
if at > budget // 2:
cut = cut[:at]
return f"{cut.rstrip()}\n… (the rest is in the plan card above)"
__all__ = [
"MAX_PLAN_CHARS",
"VERSION",
"build",
"flatten",
"merge",
"normalise",
"render_block",
]