fix(recovery): revive the reactive-recovery lane, which never ran - #782
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.pytriggers recovery by constructingPromptTooLongError("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
PromptTooLongErrorfailed the PromptTooLong predicate,reactive_compactreturnedcompacted=Falseon its first line, and nothing downstream ran.Measured on
main, driving the real query loop: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_compactitself with a fake returningcompacted=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: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.tscarries a'media_unstrippable'outcome); the port had collapsed them.3. Also from the same review
"Rate limit reached for images: too many images generated"went from "back off" to terminal. Now classified on transport/status before prose.too many imagespattern 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.pygains 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
🤖 Generated with Claude Code