Skip to content

fix(headless): stop reporting a cut-short run as a success - #778

Merged
ericleepi314 merged 1 commit into
mainfrom
fix/surface-early-stop-in-headless-result
Aug 1, 2026
Merged

fix(headless): stop reporting a cut-short run as a success#778
ericleepi314 merged 1 commit into
mainfrom
fix/surface-early-stop-in-headless-result

Conversation

@ericleepi314

Copy link
Copy Markdown
Collaborator

When the agent loop ends a run itself, the model never finished the task. Headless reported subtype: "success", is_error: false anyway — so a batch runner recorded a clean completion that merely happened to score zero.

That is why the tool-failure-loop guard's 31-second kills (fixed in #777) went unnoticed on terminal-bench until someone read the raw trajectories: the harness said the runs were fine. The eval data wasn't just missing a signal, it carried a wrong one.

What changes

Every terminal reason where the loop cut the run short now maps to the reference's result subtypes (QueryEngine.ts:891 error_max_turns, :1142 error_during_execution), on both json and stream-json, with is_error: true:

terminal reason subtype
tool_failure_loop error_during_execution
blocking_limit error_during_execution
prompt_too_long error_during_execution
image_error error_during_execution
max_turns error_max_turns

blocking_limit and prompt_too_long were the worst of the set: their explanation goes to last_api_error_text, which the compat layer only surfaces for tool_failure_loop — so those runs reported success with an empty result. A clean completion carrying no evidence at all.

hook_stopped / stop_hook_prevented are deliberately left as success: a hook refusing to continue is the operator's own policy working as configured, not the harness cutting a run short. Neither can fire without an installed hook emitting those signals. Both are named in the map's docstring and pinned by a test, so the exclusion reads as a decision rather than an oversight.

Like the reference, the event keeps its full metrics block — usage, num_turns, and the stop explanation as result. Eval adapters read token counts off it, so suppressing the event would have traded a silent-success bug for a missing-data bug.

⚠️ The exit code deliberately stays 0

A documented divergence from the reference, which exits is_error ? 1 : 0 (print.ts:1069-1070).

Not an ordering constraint — the emission gate is movable. The reason is what a non-zero code means downstream: Harbor raises NonZeroAgentExitCodeError, the trial records an exception.txt, and eval/harbor/compare_trajectories.py then treats that trial as "killed by harness" and excludes it from the step means.

Flipping the code would right-censor every guard trip and every max_turns run straight out of the comparison — deleting precisely the trials this change exists to reveal, and reintroducing the denominator corruption that once inverted a whole iteration's metrics.

The trade: clawcodex -p ...; echo $? cannot detect an early stop and must read the result event instead. The text format gets a stderr line so it isn't silent either.

Sticky across multi-prompt runs

One terminal result event covers a whole stream-json run and every other field on it is cumulative, so an early stop on prompt N must not be erased by prompt N+1 completing normally — otherwise the explanation sits in result while subtype claims success. The original bug wearing a disguise.

Placement

EARLY_STOP_SUBTYPES lives in query/transitions.py beside the TerminalReason literal it's keyed on, not in the entrypoint — more than one surface has to agree on it, and importing an entrypoint for one dict would invert the layering.

The adapter now reads it

eval/harbor/clawcodex_agent.py previously read only usage/cost, so without this the stream log would have become honest while the trial output stayed silent — the exact workflow that failed. It now logs a warning and records metadata["clawcodex_stop_subtype"], which lands in the trial's result.json. Deliberately not raised as an exception, for the censoring reason above.

Verification

  • Unit tests over both output formats and every mapped reason.
  • A multi-prompt case asserting both prompts ran before checking the subtype, so it can't pass vacuously.
  • The subtype/is_error derivation extracted as a pure function and pinned as a cross-product table — the cancelled + early-stop collision is otherwise reachable only through a /goal continuation.
  • A real end-to-end guard trip against a local SSE server: error_during_execution, is_error: true, usage intact, explanation in result; and the text format printing [clawcodex] run stopped early (tool_failure_loop).
  • All mutation-tested.

Reviewed by the critic subagent across two rounds. It caught the multi-prompt erasure and the five unmapped terminals with runtime probes, and corrected my stated rationale for the exit code — my reason was a self-imposed ordering artifact; the real one (censoring) is much stronger and is now recorded in the code so nobody "finishes the job" and silently drops the data.

Follow-ups, deliberately not bundled

  • The empty assistant turn -> re-prompting (3/3) path also ends in Terminal(completed) and reports success. Needs a new terminal reason, touching a taxonomy the TUI and agent-server both read.
  • agent_server.py hardcodes subtype="success" regardless of terminal reason, so on the TUI path a guard-killed turn is fed to the /goal judge as a successful one.

🤖 Generated with Claude Code

When the agent loop ends a run ITSELF, the model never finished the task.
Headless reported `subtype: "success", is_error: false` anyway, so a batch
runner recorded a clean completion that merely happened to score zero.

That is why the tool-failure-loop guard's 31-second kills went unnoticed on
terminal-bench until someone read the raw trajectories: the harness said the
runs were fine. The eval data was not just missing a signal, it carried a
wrong one.

Now every terminal reason where the loop cut the run short maps to the
reference's result subtypes (QueryEngine.ts:891 `error_max_turns`, :1142
`error_during_execution`), on both the `json` and `stream-json` outputs, with
`is_error: true`:

  tool_failure_loop  -> error_during_execution
  blocking_limit     -> error_during_execution
  prompt_too_long    -> error_during_execution
  image_error        -> error_during_execution
  max_turns          -> error_max_turns

`blocking_limit` and `prompt_too_long` were the worst of the set: their
explanation goes to `last_api_error_text`, which the compat layer only
surfaces as response_text for `tool_failure_loop`, so those runs reported
success with an EMPTY result — a clean completion carrying no evidence at
all.

`hook_stopped` and `stop_hook_prevented` are DELIBERATELY left as success: a
hook refusing to continue is the operator's own policy working as configured,
not the harness cutting a run short. Neither can fire without an installed
hook that emits those signals. They are listed in the map's docstring and
pinned by a test so the exclusion reads as a decision rather than an
oversight.

Like the reference, the event keeps its FULL metrics block — usage,
num_turns, and the stop explanation as `result`. Eval adapters read token
counts off it, so suppressing the event, or replacing it with a bare error,
would have traded a silent-success bug for a missing-data bug.

THE EXIT CODE DELIBERATELY STAYS 0 — a documented divergence from the
reference, which exits `is_error ? 1 : 0` (print.ts:1069-1070). Not an
ordering constraint: the emission gate is movable. The reason is what a
non-zero code means downstream. Harbor raises NonZeroAgentExitCodeError on
it, the trial records an exception.txt, and
eval/harbor/compare_trajectories.py then treats that trial as "killed by
harness" and EXCLUDES it from the step means. Flipping the code would
right-censor every guard trip and every max_turns run straight out of the
comparison — deleting precisely the trials this change exists to reveal, and
reintroducing the denominator corruption that once inverted a whole
iteration's metrics. The trade: a shell caller doing `clawcodex -p ...; echo
$?` cannot detect an early stop and must read the result event instead. The
`text` format gets a stderr line so it is not silent either.

The stop reason is STICKY across a multi-prompt stream-json run. One
terminal result event covers the whole run and every other field on it is
cumulative, so an early stop on prompt N must not be erased by prompt N+1
completing normally — otherwise the explanation sits in `result` while
`subtype` claims success, which is the original bug wearing a disguise.

`EARLY_STOP_SUBTYPES` lives in query/transitions.py beside the TerminalReason
literal it is keyed on, not in the entrypoint: more than one surface has to
agree on it (the agent-server's turn outcome hardcodes success today and is
the next fix), and importing an entrypoint for one dict would invert the
layering.

Also wires the adapter (eval/harbor/clawcodex_agent.py) to READ the subtype —
it previously read only usage/cost, so without this the stream log would have
become honest while the trial output stayed silent, which is the exact
workflow that failed. It logs a warning and records
`metadata["clawcodex_stop_subtype"]`, which lands in the trial's result.json.
Deliberately not raised as an exception, for the censoring reason above.

Verified: unit tests over both output formats and every mapped reason; a
multi-prompt case asserting both prompts ran before checking the subtype; the
subtype/is_error derivation extracted as a pure function and pinned as a
cross-product table (the cancelled + early-stop collision is otherwise
reachable only through a /goal continuation); and a REAL end-to-end guard trip
against a local SSE server producing error_during_execution, is_error true,
usage intact, and the explanation in `result`. All mutation-tested.

Follow-ups deliberately not bundled: the "empty assistant turn ->
re-prompting (3/3)" path also ends in Terminal(completed) and reports
success, which needs a NEW terminal reason and touches a taxonomy the TUI and
agent-server both read; and agent_server.py hardcodes `subtype="success"`
regardless of terminal reason, so on the TUI path a guard-killed turn is fed
to the /goal judge as a successful one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ericleepi314
ericleepi314 merged commit 94b9314 into main Aug 1, 2026
2 checks passed
ericleepi314 added a commit that referenced this pull request Aug 1, 2026
…779)

#778 made HEADLESS report a non-success result subtype when the agent loop
cut a run short. It named two gaps. This closes both.

A. THE AGENT-SERVER TURN OUTCOME WAS HARDCODED TO SUCCESS.

`_AgentSession._run_turn` emitted `subtype="success"` regardless of why the
loop stopped, so on the TUI / VS Code path a guard-killed turn was
indistinguishable from a completed one. Three consumers gate on that field:
`_maybe_continue_goal` fed such a turn to the /goal judge as evidence of
progress, `_maybe_review_memories` learned from it, and the cron loop rearmed
on it.

It now derives the subtype from `result.terminal.reason` through the SAME
`EARLY_STOP_SUBTYPES` map headless uses, so the two surfaces cannot drift.
The two remaining hardcoded successes in that function are UserPromptSubmit
hook blocks, which never run the loop — left deliberately, consistent with
excluding `hook_stopped` / `stop_hook_prevented` from the map (operator
policy working as configured, not the harness cutting a run short).

/goal AUTO-CONTINUES past an early stop rather than dying. Before the subtype
was derived, such a turn arrived as "success" with "[Max tool turns reached]"
as its text, was judged not-done, and the loop simply retried; bailing out
would have silently killed loops that used to recover. So the judge is
skipped — there is no output to judge, and asking a model whether
"[Stopped: …]" satisfies the goal only invites a wrong answer — and a
synthetic `continue` is applied instead. That is also `judge_goal`'s own
fail-open verdict, so it is a path the loop already handles.

Deliberately still routed through `apply_verdict` rather than enqueuing a
continuation directly: that call is what ticks `turns_used` and lets the
goal's cap decide. Short-circuiting it would let a turn that keeps stopping
early retry forever.

B. A DEGENERATE TURN FELL THROUGH TO `completed`.

`query.py` nudges an assistant turn with no tool calls, no text and nothing
in the outbox. Once the nudge budget was spent the WHOLE block was skipped —
including the DETECTION — so a still-degenerate turn reached
`Terminal(reason="completed")` and was reported as a clean success with an
empty result.

The last-text / outbox computation is hoisted into an `is_degenerate` flag
and a new `Terminal(reason="empty_response")` fires when the budget is spent
and it is still degenerate. `agent_loop_compat` surfaces an explanation as
response_text, worded to stay true on BOTH routes in (budget spent, and
max_turns blocking the retries) rather than claiming prompting that may not
have happened.

Two correctness fixes fell out of building it:

* `spoke_via_outbox` read the SESSION-LIFETIME outbox, which is created once
  per session and never cleared — so one AskUserQuestion / Brief /
  SendUserMessage anywhere in a session permanently disabled the check for
  every prompt after it, and the silent empty success came straight back. Now
  scoped to the current query (a watermark taken before the loop) AND to
  SendUserMessage, the only tool whose entry `agent_loop_compat` promotes to
  response_text — an AskUserQuestion entry used to suppress the check while
  contributing nothing to the answer.

* The empty-turn and continuation-signal arms shared one budget, so a few
  continuation nudges could spend it before the first empty turn arrived. The
  empty turn then got no retry at all and the run hard-failed without the one
  round trip that recovers it (measured at 3/89 terminal-bench trials). They
  now have separate counters and the empty-turn arm runs ahead of the shared
  gate.

THE TAXONOMY. `TerminalReason` gains `empty_response`, which TS has no
counterpart for — the arm that detects it is itself port-only (TS's
continuation nudge gates on non-empty text, so it never sees the case). The
parity test moves from set-EQUALITY against the TS snapshot to "TS is a
SUBSET and every extra is declared in `PYTHON_ONLY_TERMINAL_REASONS`". That
keeps both teeth that matter — a dropped TS reason still fails, an undeclared
extra still fails — and a companion test requires every declared extra to be
in `EARLY_STOP_SUBTYPES`, so the escape hatch cannot reintroduce a silent
success.

DOWNSTREAM. The VS Code chat pane matched `subtype === 'error'` exactly, so
the new subtypes fell through to "Completed (N turns)"; it now checks
`is_error` and falls back to `msg.result` when nothing streamed. The TUI
already keyed on `is_error` but printed the explanation twice — once as the
turn text, once as a red error line — because an early stop carries its
explanation in `result` and sets no separate `error` field; it now emits a
short "run stopped early (<subtype>)" instead of echoing.

Verified end to end against a local SSE server returning a genuinely empty
assistant turn: `error_during_execution`, `is_error: true`, num_turns 4
(1 + 3 retries), explanation in `result`. Every fix mutation-tested,
including against the exact mutation that proved an earlier version of the
agent-server test was a placebo (compute the subtype, then discard it).

Co-authored-by: Claude Opus 5 <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