Skip to content

fix(harness): recover trials lost to empty turns, transport drops, and a headless-only tool - #747

Merged
ericleepi314 merged 1 commit into
mainfrom
fix/harness-robustness-from-tb21-diff
Jul 26, 2026
Merged

fix(harness): recover trials lost to empty turns, transport drops, and a headless-only tool#747
ericleepi314 merged 1 commit into
mainfrom
fix/harness-robustness-from-tb21-diff

Conversation

@ericleepi314

Copy link
Copy Markdown
Collaborator

Found by diffing two full terminal-bench 2.1 runs at identical model, effort, dataset and concurrency (claude-opus-5, xhigh):

mean errored crashes
clawcodex 0.742 20 3
official Claude Code 0.843 17 0

62 tasks both solved, 13 CC-only, 4 clawcodex-only. Of the 13-task deficit, 3 were crashes and 3 were trials that did nothing at all — none of it model capability. Per-step latency is basically identical (19.3s vs 21.5s median), so the gap is dominated by trials that died or no-oped, not by the model being slower.

All four fixes are general robustness. Nothing here is conditional on an eval — no env checks, no branches, no wording aimed at a grader.

1. An empty assistant turn ended the run as "completed"

break-filter-js-from-html, crack-7z-hash, vulnerable-secret: one output token, ~3 seconds, empty result, subtype: success, scored 0. Claude Code solved all three.

No text and no tool calls is a degenerate response, not an answer. The continuation nudge couldn't catch it — it gates on the text being truthy — so an empty turn fell straight through to Terminal(reason="completed"). Empty turns are now re-prompted, bounded by the existing MAX_CONTINUATION_NUDGES cap.

Carve-out for SendUserMessage: a model that speaks through the outbox legitimately ends with empty assistant text, and agent_loop_compat already treats the outbox as the response text. The existing parity test caught that case when I first missed it.

2. A transport blip killed a 24-minute trial

regex-chess died on peer closed connection without sending complete message body (incomplete chunked read) seconds after a passing 1500-position fuzz run.

Transport drops now take the same bounded retry the idle watchdog already takes, with the original exception re-raised on exhaustion so the log names the real failure. Gated two ways:

  • Status-bearing errors (401/529/400) and aborts fail fast — retrying a revoked token just burns budget on guaranteed 401s.
  • Skipped once any text reached the caller. A re-attempt replays the response from scratch, so retrying after partial output would emit those chunks twice — exactly what query.py:1635 forbids. That check sits above this layer and can't see a retry taken here, so the gate has to live here too. Free for the eval: harbor runs without --include-partial-messages, so the callback is None in a scored trial.

3. AskUserQuestion was reachable in --print

fix-git called it twice, then ended with "let me explain what I found" instead of finishing; the verifier found the files untouched. It isn't in the initial toolset — the model reached it via ToolSearch.

Nothing headless can answer it (StreamJsonReader parses only user messages; there's no can_use_tool protocol), so it's unregistered rather than left registered with a stubbed responder. Parity, not special-casing — the TS reference's AskUserQuestionTool already reports itself disabled when nobody's at the keyboard. Removal happens before the allow/deny filters so --allowed-tools can't resurrect it, and it propagates to subagents (shared registry instance).

Test doubles gained remove_tool rather than the production call gaining a getattr guard — a silent no-op there is the bug class that left --allowed-tools dead for months (#739).

4. The idle-timeout message was written for a grader

It appended "Connection timed out", with a comment stating the phrasing was deliberate so eval harnesses would classify the failure as retryable. That optimizes for the scoreboard rather than the agent — and it was untrue, since an idle stream is not a connection timeout, so it pointed debuggers at the wrong subsystem. Removed; the test now pins the honest wording and asserts the old clause stays gone.

Diagnosed, deliberately NOT fixed

Two trials died on 401 OAuth access token has been revokednot expiry (one died 4.7 minutes in against a 2h runway) but refresh rotation: containers hold a refresh-token-free copy, so anything rotating the host credential revokes the in-flight token. An in-adapter retry would hand clawcodex attempts the official harness wouldn't give another agent, so it's documented in the adapter with a recommendation to use an API key for scored runs.

Also unfixed and model-shaped rather than harness-shaped: on torch-tensor-parallelism neither agent could find a Python interpreter, but Claude Code stopped searching and wrote the deliverable (1.0) while clawcodex kept globbing until the budget expired. That's prompt-shaping and wants an A/B, not a guess.

Verification

  • 8841 passed, 0 failed (1 deselected: test_connection_error_re_raises, a pre-existing hang confirmed on clean main).
  • Every fix is revert-sensitive — each line deleted, intended test confirmed failing, file restored.
  • Critic-reviewed; it caught the double-emit bug in fix: fix i18n readme #2, which is why the emitted-gate exists.

Estimated recovery if these hold: 3 (empty turns) + 1 (regex-chess) + 1 (fix-git) ≈ 5 tasks. That's an estimate until a rerun confirms it — and note both runs recorded n_retries: 0, so it rests on the fixes rather than on any harness retry behavior.

🤖 Generated with Claude Code

…d a headless-only tool

Four defects found by diffing two full terminal-bench 2.1 runs at
identical model/effort/dataset (claude-opus-5, xhigh): clawcodex 0.742
vs the official Claude Code harness 0.843. Of the 13 tasks Claude Code
solved and clawcodex did not, 3 were crashes and 3 were trials that did
nothing at all — none of it model capability.

All four fixes are general robustness. Nothing here is conditional on an
eval: no env checks, no branches, no wording aimed at a grader.

## An empty assistant turn ended the run as "completed"

Three trials — break-filter-js-from-html, crack-7z-hash,
vulnerable-secret — produced ONE output token in ~3 seconds, returned an
empty result, reported `subtype: success`, and scored 0. Claude Code
solved all three.

No text and no tool calls is a degenerate response, not an answer. The
continuation nudge could not catch it because it gates on the text being
truthy, so an empty turn fell straight through to
`Terminal(reason="completed")`. Empty turns are now re-prompted, bounded
by the same MAX_CONTINUATION_NUDGES cap as every other nudge.

Carve-out: a model that speaks through SendUserMessage (documented as the
primary visible output channel) legitimately ends with empty assistant
text, and agent_loop_compat already treats the outbox as the response
text. The existing parity test caught that case.

## A transport blip killed a 24-minute trial

regex-chess died on `peer closed connection without sending complete
message body (incomplete chunked read)` seconds after a passing
1500-position fuzz run. Transport drops now take the same bounded retry
the idle watchdog already takes, and the ORIGINAL exception is re-raised
on exhaustion so the log names the real failure instead of an idle
timeout.

Gated two ways. Status-bearing errors (401/529/400) and aborts fail fast
— retrying a revoked token just burns the budget on guaranteed 401s. And
the retry is skipped once any text has been handed to the caller: a
re-attempt replays the response from scratch, so retrying after partial
output would emit those chunks twice, which is exactly what query.py:1635
forbids ("never after partial output"). That check sits above this layer
and cannot see a retry taken here, so the gate has to live here too.

## AskUserQuestion was reachable in --print

fix-git called it twice, then ended with "let me explain what I found"
instead of finishing; the verifier found the files untouched. It is not
in the initial toolset — the model reached it through ToolSearch.

Nothing on the headless surface can answer it (StreamJsonReader parses
only `user` messages; there is no can_use_tool protocol), so it is now
unregistered rather than left registered with a stubbed responder. This
is parity, not special-casing: the TS reference's AskUserQuestionTool
already reports itself disabled when nobody is at the keyboard. Removal
happens before the allow/deny filters so --allowed-tools cannot resurrect
it, and it propagates to subagents, which share the registry instance.

Test doubles gained `remove_tool` rather than the production call gaining
a getattr guard — a silent no-op there is the bug class that left
--allowed-tools dead for months (#739).

## The idle-timeout message was written for a grader

It appended "Connection timed out" with a comment saying the phrasing was
deliberate so eval harnesses would classify the failure as retryable.
That optimizes for the scoreboard rather than the agent, and it was also
untrue — an idle stream is not a connection timeout, so it pointed
debuggers at the wrong subsystem. Removed; the test now asserts the
honest wording and that the old clause stays gone.

## Diagnosed, deliberately NOT fixed

Two trials died on `401 OAuth access token has been revoked` — not
expiry (one died 4.7 minutes in against a 2h runway) but refresh
rotation: containers hold a refresh-token-free copy, so anything rotating
the host credential revokes the in-flight token. An in-adapter retry
would hand clawcodex attempts the official harness would not give another
agent, so this is documented in the adapter with a recommendation to use
an API key for scored runs.

Tests: 8841 passed. Every fix is revert-sensitive — each line was deleted
and the intended test confirmed failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ericleepi314
ericleepi314 merged commit 3527395 into main Jul 26, 2026
2 checks passed
ericleepi314 added a commit that referenced this pull request Jul 28, 2026
… wire (#757)

A connection that dies mid-response carries no HTTP status and no
salvageable output, so re-issuing is the right call. The Anthropic provider
has done this since #747. The OpenAI-compatible provider had no equivalent
-- no retry, no attempt loop -- so one dropped chunk ended the whole agent
run.

Measured cost, terminal-bench 2.1 with deepseek-v4-pro at effort=max
(2026-07-27, eval/harbor/jobs/tb21-deepseek-max-1, score 0.449 vs the same
harness scoring 0.809 on opus-5):

    peer closed connection without sending complete message body
    (incomplete chunked read)

ended 8 of 89 trials -- 16% of every failure. regex-chess died this way
after 391 seconds of successful work. Identical benchmark, identical error
string, identical harness; only the wire differed.

What changed
------------------------------------------------------------------
* src/providers/stream_retry.py (new) holds the classifier and its marker
  list, moved out of anthropic_provider. Dependency-free so
  openai_compatible can import it without pulling in the Anthropic SDK or
  creating a cycle. anthropic_provider re-exports it under the original
  private names, so its call sites and test_stream_watchdog keep resolving.
* OpenAICompatibleProvider.chat_stream_response is now a retry wrapper over
  _stream_attempt (the previous body, unchanged).

Retry discipline, mirroring #747:
* Bounded at one re-issue.
* NEVER retries anything carrying an HTTP status. That guard matters here
  specifically: the same DeepSeek run has a real image_url 400 (9 trials,
  a separate bug), and retrying it would just buy a second identical
  rejection.
* Skipped once any text or reasoning has reached the caller, or the
  transcript would show that prefix twice. Harbor runs --print
  --output-format stream-json without --include-partial-messages, so both
  callbacks are None in a scored trial and the retry always fires.
* Re-raises the ORIGINAL exception on the last attempt, so callers see the
  real cause rather than a retry wrapper.

Not claimed: that this recovers all 8 trials. One retry covers a blip, not
a connection that reliably dies at the same point -- if drops persist
through the retry, that points at deepseek-v4-pro's max_output_tokens=8_192
against DeepSeek's documented "reasoning_effort=max needs a longer output
window", which is the next thing to check.

Tests: 12 new, covering retry-then-succeed, bounded re-raise of the
original, status-bearing errors never retried, and both callback paths
suppressing the retry. Revert-sensitivity verified: max_attempts=1 fails
exactly the two retry tests. Full suite 8853 passed, 0 failures.
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