Skip to content

fix(recovery): revive the reactive-recovery lane, which never ran - #782

Merged
ericleepi314 merged 1 commit into
mainfrom
fix/revive-reactive-recovery
Aug 1, 2026
Merged

fix(recovery): revive the reactive-recovery lane, which never ran#782
ericleepi314 merged 1 commit into
mainfrom
fix/revive-reactive-recovery

Conversation

@ericleepi314

Copy link
Copy Markdown
Collaborator

The lane that is supposed to save a run when a request doesn't fit has been dead since 2026-05. Not degraded — never executing.

1. The gate rejected its own trigger

query.py triggers recovery by constructing PromptTooLongError("withheld during streaming, recovering") — the original provider exception is consumed during streaming, so only the classification needs to survive. The gate it hits, reactive_compact.is_prompt_too_long_error, was a pure substring test for "prompt is too long" / "prompt_too_long" / "context_length_exceeded".

The synthetic message contains none of them. A typed PromptTooLongError failed the PromptTooLong predicate, reactive_compact returned compacted=False on its first line, and nothing downstream ran.

Measured on main, driving the real query loop:

prompt_too_long   terminal=prompt_too_long  provider_calls=1
image count       terminal=image_error      provider_calls=1

One call means no retry ever happened. That also means #781 only relabelled the terminal — its claim to recover was wrong.

After making the gate type-aware: 3 and 2 calls. Both lanes retry.

It stayed hidden because every existing test of the lane stubs reactive_compact itself with a fake returning compacted=True, so the gate was never exercised. The new tests deliberately don't stub it — they count provider calls.

2. Token-shaped recovery can't fix a count violation

reactive_compact's emergency fallback drops the oldest messages and accepts when tokens fall 30%. Image count is never consulted. For the case that motivates the media path — an agent reading frames in a loop, images in the recent tail — dropping old text satisfies the token test while leaving the images. Measured against the compactor directly:

200 messages / 60 images  →  40 messages / 40 images   (compacted=True)

The retry then hits the same cap with the one-shot flag already burned.

Media now strips deterministically via strip_images_from_typed_messages — no summarizer call, and it keeps the text context a full compaction would replace with a summary. Images in each request the provider saw: [60, 0].

Strip-then-fall-back, not strip-only: when there's nothing strippable the general compactor still runs, so nothing regresses. (Caught by a pre-existing test — my first cut gave up instead.)

Upstream models these as distinct operations too (reactiveCompact.ts carries a 'media_unstrippable' outcome); the port had collapsed them.

3. Also from the same review

  • A retryable error mentioning images became a non-retryable terminal. This branch returns a tagged message instead of raising, which takes the request out of the retry lane, so "Rate limit reached for images: too many images generated" went from "back off" to terminal. Now classified on transport/status before prose.
  • Dropped the unanchored too many images pattern added in fix(errors): recover from "too many images" instead of dying on it #781 — it made that collision reachable, and it was speculative; no provider was observed emitting it.
  • "Media too large:""Media rejected:". The operator reads this string, and the rejection is usually a count.
  • tests/test_api_errors.py gains the 15-case classifier table fix(errors): recover from "too many images" instead of dying on it #781's PR body claimed but never committed — every pattern pinned individually (removing three of four at once had left the loop-level tests green), plus case variants and five negative controls.

Verification

  • Both fixes mutation-tested, on copies of the tree rather than in place — reverting the gate fails the PTL/synthetic tests; bypassing the media strip fails the summarizer-down test.
  • The summarizer-down test exists because a simpler one can't tell strip from compact: with a working summarizer both reach zero images.
  • Full suite at the local baseline.

🤖 Generated with Claude Code

The lane that is supposed to save a run when a request does not fit has been
dead since 2026-05. Not degraded — never executing.

`query.py` triggers recovery by constructing
`PromptTooLongError("withheld during streaming, recovering")`: the original
provider exception is consumed during streaming, so only the classification
needs to survive. The gate it hits,
`reactive_compact.is_prompt_too_long_error`, was a pure SUBSTRING test for
"prompt is too long" / "prompt_too_long" / "prompt too long" /
"context_length_exceeded". The synthetic message contains none of them, so a
typed `PromptTooLongError` failed the PromptTooLong predicate,
`reactive_compact` returned `compacted=False` on its first line, and nothing
downstream ran.

Measured on main, driving the real query loop:

    prompt_too_long   terminal=prompt_too_long  provider_calls=1
    image count       terminal=image_error      provider_calls=1

One call means no retry ever happened. That also means #781 (image-count
classification) only RELABELLED the terminal; its claim to recover was wrong.

After making the gate type-aware: 3 and 2 calls. Both lanes retry.

It stayed hidden because every existing test of the lane stubs
`reactive_compact` itself with a fake returning `compacted=True`, so the gate
was never exercised. The new tests deliberately do not stub it — they count
provider calls.

SECOND DEFECT: token-shaped recovery cannot fix a COUNT violation.

`reactive_compact`'s emergency fallback drops the OLDEST messages and accepts
the result when tokens fall 30%. Image count is never consulted. For the case
that motivates the media path — an agent reading frames in a loop, so the
images sit in the RECENT tail — dropping old text satisfies the token test
while leaving the images in place. Measured against the compactor directly:
200 messages / 60 images -> 40 messages / 40 images, returned as
`compacted=True`. The retry then hits the same cap with the one-shot flag
already burned.

Media now strips deterministically (`strip_images_from_typed_messages`),
which needs no summarizer call and keeps the text context a full compaction
would replace with a summary. Images in each request the provider saw:
[60, 0]. Strip-then-FALL-BACK, not strip-only: when there is nothing
strippable the general compactor still runs, so nothing regresses.

Upstream models these as distinct operations too (reactiveCompact.ts carries
a 'media_unstrippable' outcome); the port had collapsed them into one.

ALSO, from the same review:

* A retryable error whose body mentions images became a NON-retryable media
  terminal. This branch RETURNS a tagged message instead of raising, which
  takes the request out of the retry lane entirely, so a 429/5xx like "Rate
  limit reached for images: ..." was converted from "back off and retry" into
  a terminal. Now classified on transport/status BEFORE prose.
* Dropped the unanchored `too many images` pattern added in #781 — it is what
  made that collision reachable, and it was speculative: no provider was
  observed emitting it.
* "Media too large:" -> "Media rejected:". The operator reads this string and
  the rejection is usually a COUNT.
* `tests/test_api_errors.py` gains the 15-case classifier table that #781's PR
  body claimed but never committed: every pattern pinned individually
  (removing three of four at once had left the loop-level tests green), plus
  case variants and five negative controls.

Every fix mutation-tested, on COPIES of the tree rather than in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ericleepi314
ericleepi314 merged commit 98ff6c9 into main Aug 1, 2026
2 checks passed
ericleepi314 added a commit that referenced this pull request Aug 2, 2026
Headline: fusion models (#771) — pair a text-only reasoning model with a
vision-capable one so it can read screenshots, diagrams and code images.
`deepseek-v4-pro` rejects an image content block outright, so a pasted
screenshot used to end the turn; a fusion model describes the image with
the second model first and hands the base model text.

Verified end to end on Terminal-Bench 2.1's `code-from-image` — transcribe
handwritten pseudocode from a PNG and reproduce its output — with
`deepseek-v4-flash` + `openai:gpt-5.6-luna` (#787). The base model alone
returns a 400 on the same image, so the pass is attributable to the fusion
path rather than the base coping.

Also in 1.4.0: GPT-5.6 Sol/Terra/Luna (#773); groq, cerebras, baseten and
xai take the provider registry to 30 (#784); `/mode` becomes
`/permissions` with a three-level picker (#768); `AskUserQuestion` renders
a real picker instead of returning JSON to the model (#774); the OpenAI
provider picks its wire protocol from the model rather than the auth mode
(#783); cached prompt tokens bill at the cache rate (#785, #786); headless
runs stop reporting a cut-short run as success (#777#782).

Version bumped in all five spots (pyproject, install.sh INSTALLER_VERSION,
gatewayClient CLAWCODEX_VERSION, src/__init__.py fallback, uv.lock).
CHANGELOG `[Unreleased]` covered only through #773 and was backfilled with
#774#787; PR citations added to the pre-existing entries so coverage is
checkable. #766 is docs-only and deliberately uncited.

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