Skip to content

Think terminalizes transient provider/gateway stream errors instead of routing them through bounded chatRecovery — classifyChatError "transient" is a no-op #2085

Closed

Description

@jbreite

Describe the bug

When a provider/gateway stream terminates with an error mid-turn (the upstream ends the SSE stream before the terminal chunk), Think terminalizes the turn instead of routing it through the bounded chatRecovery machinery — even though ChatErrorClassification already reserves "transient" and "rate_limit" for exactly this, and the app's classifyChatError hook could classify it.

In the stream catch path, only ChatStreamStalledError is routed into _routeStallToBoundedRecovery. Every other stream error falls through to the generic terminal path: the resumable stream is errored, a terminal error frame is broadcast, onChatError fires, and the turn dies. The classifyChatError docs confirm the hook "currently drives only context-overflow recovery" and that the other categories are "reserved for future use — returning one today is a no-op."

This produces a perverse asymmetry: if the upstream had hung silently, the stream-stall watchdog would have fired and the turn would have been recovered via the bounded-recovery route. Because the upstream instead failed fast with a clean error, the turn got worse treatment — it terminalized with no retry, leaving a dangling mid-flight tool call (later repaired to output-error with "The tool call was interrupted before a result was recorded") and no final assistant text.

To Reproduce

Steps to reproduce the behavior:

  1. Run a Think (or AIChatAgent) turn with chatRecovery enabled and a multi-step tool loop in progress.
  2. Have the model provider (or an AI gateway in front of it) terminate the response stream before the terminal/finish chunk — e.g. proxy the provider through something that kills the upstream connection mid-generation, ideally while a tool call's input is still streaming.
  3. Observe the turn terminalize: terminal error frame broadcast, onChatError invoked with the stream error, no recovery incident opened, no continuation scheduled.
  4. Compare with the stall case: make the same upstream hang (stay open, send nothing) past chatStreamStallTimeoutMs instead — the watchdog throws ChatStreamStalledError and the turn is recovered via the bounded route.
  5. Note that overriding classifyChatError to return "transient" for the error in step 2 changes nothing (documented no-op).

Expected behavior

When a stream error occurs and the app's classifyChatError returns "transient" (or "rate_limit"), route the interruption through the same bounded recovery path the stall watchdog already uses (_routeStallToBoundedRecovery-style: open/advance an incident, respect maxAttempts + wall-clock window + progress-aware budget resets, schedule a continuation, deliver the configured terminal UX only on exhaustion).

Everything needed already exists — incident dedup, attempt budgets, progress markers, and the transcript-repair idempotency story (settled tool results are durable; the interrupted mid-flight tool call is repaired to a settled error, so only that call re-runs on continuation). This is plumbing the reserved classification categories into an existing route, not new machinery. Errors classified "fatal"/"unknown" (or when the hook is not overridden) should keep today's terminal behavior.

Screenshots

N/A — server-side. The failure mode is the provider (or a gateway proxying it) ending the response stream with a structured "upstream ended before the terminal/finish chunk" error while a step is mid-flight; no usage is reported for the interrupted call.

Version:

  • @cloudflare/think@0.15.1
  • agents@0.20.1
  • ai@6.0.208

Additional context

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions