Skip to content

fix: the agent loop screens what a shell runs, and stops when the money does - #217

Merged
thedancingdeveloper merged 2 commits into
mainfrom
test/agent-loop-hardening
Aug 6, 2026
Merged

fix: the agent loop screens what a shell runs, and stops when the money does#217
thedancingdeveloper merged 2 commits into
mainfrom
test/agent-loop-hardening

Conversation

@thedancingdeveloper

Copy link
Copy Markdown
Contributor

Eight wiring bugs in adapters/minisweagent.py were found by live runs, several
at ten minutes and real money each. A ninth arrived the same way, and this PR is
that one plus six found without a network.

The measured one

A real rdpapp item: status: LimitsExceeded, 40 model calls, 15 of the 40
turns refused by CommandGuard
. The loop was working — it created files — and
it ran out of steps being told no.

_segments split a shell line by replacing &&, ||, ;, | and newlines
with a delimiter and calling shlex.split on the pieces. That is not a shell,
and it was wrong in both directions at once.

False positives — the expensive ones. A heredoc body was chopped into argv
and screened as commands, so writing a source file was refused for containing
&&, |, or a bare /. That last one is the dominant mechanism and it is
worth stating plainly: let mean = total / count; tokenises to a /, which
resolves to the filesystem root, which is outside the worktree. Ordinary
arithmetic was reaching outside the tree.

False negatives. The same splitter could not see $(...), backticks,
env / xargs / nohup / timeout, find -exec, or a subshell — so
echo $(rm -rf .) and env rm -rf . both ran with a refusal list naming rm.

Measured over a corpus reconstructed from the shapes in that run (the 15
commands themselves were not in the report, so this is the shapes, not the log):

corpus n refused before after
heredoc file writes 9 6 0
genuinely outside the worktree 3 3 3
genuinely dangerous 7 3 7
ordinary work 7 0 0

The guard is not widened to get there. A heredoc fed to an interpreter
(bash <<EOF) is still screened line by line; only a body going into a file is
treated as data. A redirection outside the worktree is still refused — the
refusal message now names the tree, states that the rule is permanent, and
counts the refusals so far, so the next turn can comply rather than retry
variants. That is message quality, not policy.

Six more, each with a test that fails before the fix

  • HarnessModel.cost was never written to. The loop reads a per-call cost
    off extra and stops itself at cost_limit; nothing ever put one there, so
    cost was 0.0 for the life of every run and the only bound on an agent was
    its step count. The cost now comes from client.usage_for, the same reading
    the audit rollup uses, and build() takes a Budget — so an item's declared
    wall-clock and spend ceilings reach the one stage long enough to need them.
    budgets.py's rule is kept: an unpriced call is counted as unpriced, not as
    free, so the ceiling is a lower bound that can fail to fire and can never fire
    early.
  • subprocess.TimeoutExpired was unhandled. It left execute, and the loop
    does not catch it: DefaultAgent.run records an exit message and re-raises.
    One hung test command ended the whole item with a traceback and no submission.
    The agent is now told, as return code 124 with exception_info, and picks
    something cheaper.
  • Truncation kept the last 32k, unmarked. Which end matters depends on the
    command — a compiler puts its first error at the top, a test runner puts its
    summary at the bottom — so both ends are kept with a count of what went. And
    the submit marker is looked for in the whole output: a command that said it
    had finished and then printed anything sizeable had its own marker truncated
    away, which is bug D5: GUI stack #5 reached by a different road.
  • The assistant's own turns reached the wire empty. The action lives in
    tool_calls, which _for_the_wire strips (correctly — a tool reply needs
    the id it strips). Nothing replaced it, so a model replying with a tool call
    and no prose saw thirty of its own turns as empty strings and could not tell
    which output answered which command. The command is folded back into the
    assistant's content.
  • A 200 with an error body was blamed on the model. _message_of swallowed
    every exception and returned an empty message, which is indistinguishable from
    a model that would not format an action: the loop asked again, got the same
    error, and ended as RepeatedFormatError after three calls against a broken
    endpoint. It now says what arrived, redacted, on the first call.
  • The format-error template was the wrong protocol's. Hand-copied, it told a
    model on the tool-call path to "provide EXACTLY ONE action in triple
    backticks" and discarded {{error}} — bug D8: Gate plugin interface #8 surviving in the one message
    that only appears when things have already gone wrong. It and the observation
    template now come from the same config the prompts do, so they cannot drift
    apart again. finish_reason is passed with it, because their template uses it
    to tell a format mistake from a token-limit truncation.

A refusal is also durable now: the rule that fired goes into the trajectory, and
an optional on_refusal callback lets a deployment point it at an event stream
without core learning this adapter's name. Fifteen refusals in a real run
existed only in a list that died with the process.

What was checked and found sound

  • _for_the_wire dropping tool_calls does not orphan a tool_call_id
    no tool role is ever sent, so no provider is left waiting for one. The bug
    was the lost content, not the lost pairing.
  • Redirection to a path outside the tree (echo x > /etc/passwd) was already
    refused, by the path boundary, incidentally and correctly.
  • VAR=x cmd stripping, the unparseable-line fallback, 2>&1, and the
    worktree-relative path checks all behave as documented.
  • The step limit does bound a loop that will not stop; the refusal-inside-a-loop
    semantics (answer, don't terminate) are deliberate and unchanged.

Verification

uv run ruff format ., uv run ruff check ., uv run mypy ., uv run pytest
all pass. Every new test was run against the pre-fix adapter and fails there.

🤖 Generated with Claude Code

sprooty and others added 2 commits August 6, 2026 01:10
…ey does

Eight wiring bugs in this adapter were found by live runs. A ninth arrived the
same way: a real rdpapp item came back `LimitsExceeded` after 40 model calls
with 15 of the 40 turns refused by `CommandGuard`. The loop was working — it
created files — and it ran out of steps being told no.

`_segments` split a shell line by replacing its separators, which is not a
shell. Two consequences, opposite in direction and both real:

  * a heredoc *body* was chopped into argv and screened as commands, so
    writing a source file — the most common thing a coding agent does — was
    refused for containing `&&`, `|`, or a bare `/` (division resolves to `/`
    and trips path confinement). Over the shapes of that run: 6 of 9
    file-writes refused before, 0 after;
  * and it could not see `$(...)`, backticks, `env`/`xargs`/`nohup`/`timeout`,
    `find -exec` or a subshell, so `echo $(rm -rf .)` ran with a refusal list
    naming `rm`. 3 of 7 genuinely dangerous lines refused before, 7 of 7 after.

The guard is not widened. A heredoc fed to an interpreter is still screened
line by line; a redirection outside the worktree is still refused — the
refusal now names the tree so the next turn can comply instead of guessing.

Six more, each with a test that fails before it:

  * `HarnessModel.cost` was never written to, so the loop's own spend ceiling
    could not fire and a per-item `Budget` reached nothing that runs long. Cost
    comes from `usage_for`, the same reading the audit uses; unpriced calls
    stay unpriced, so the ceiling is a lower bound and never fires early.
  * `subprocess.TimeoutExpired` was unhandled: one hung command ended the item
    with a traceback rather than a turn.
  * Output truncation kept the last 32k, unmarked — throwing away the head of
    every compiler error, and the finish marker itself when the agent said it
    was done and then printed anything.
  * The assistant's own turns reached the wire empty, because the action lived
    only in the `tool_calls` this layer strips: thirty outputs, no way to tell
    which command produced which.
  * A gateway answering HTTP 200 with an error body was reported as the model
    failing to format an action, three calls later.
  * And the correction handed back when no tool call arrives was the *text*
    protocol's — "provide EXACTLY ONE action in triple backticks" — which is
    bug #8 surviving in the one message that only appears when things have
    already gone wrong. It, and the observation template, now come from the
    same config the prompts do, so they cannot drift apart again.

A refusal is also recorded now: the rule that fired, in the trajectory, and
through an optional callback a deployment can point at its event stream.
Fifteen refusals in a real run existed only in a list that died with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant