Repository navigation
fix(think,agents): opt-in proactive + reactive recovery for mid-turn context-window overflow - #1662
Merged
Merged
Conversation
🦋 Changeset detectedLatest commit: 87457d8 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
threepointone
force-pushed
the
fix/think-context-overflow-recovery
branch
from
June 3, 2026 08:21
70c6d28 to
4bccb6e
Compare
Compaction (`Session.compactAfter`) is only checked between turns, on each
`appendMessage`. A single long, tool-heavy turn grows the prompt step by step
inside one `streamText` loop and can exceed the model's context window
mid-turn, before the next pre-turn check. The provider then rejects the request
("prompt is too long" / context_length_exceeded) and the turn dies terminally.
Think shipped no provider-specific error matching, so it could neither detect
nor recover from this.
This adds opt-in, provider-agnostic recovery, configured through a single
`contextOverflow` property on `Think`. All layers default off — no behavior
change unless enabled — and all reuse the session's existing compaction
function.
- classifyChatError(error, ctx): app maps a raw error (or the in-stream error
string) to a ChatErrorClassification ("context_overflow" | "rate_limit" |
"transient" | "fatal" | "unknown"). Same framework-owns-the-mechanism /
app-owns-the-provider-knowledge split as `tokenCounter`. The classification is
threaded to onChatError and observers via ChatErrorContext.classification. The
bundled, exported defaultContextOverflowClassifier covers the common providers
(Anthropic, OpenAI, Google, Bedrock, ...) for apps that do not need custom
classification.
- contextOverflow.reactive + .maxRetries: when a turn fails with a
context_overflow, persist the partial assistant message, run session.compact(),
and re-run the turn (bounded) from the compacted history instead of dying. A
no-op compaction or a spent budget surfaces terminally through onChatError with
classification "context_overflow" — never a silent end, never an infinite loop.
Wired into the WebSocket, chat()/RPC, and programmatic (saveMessages /
submitMessages) turn paths.
- contextOverflow.proactive: a { maxInputTokens, headroom? } pre-step guard. When
the previous step's model-reported usage.inputTokens crosses
maxInputTokens * (headroom ?? 0.9), compact in place and feed the recompacted
history into the upcoming step, heading off the provider rejection before it
happens. Keys off model-reported usage (every provider reports it), not
provider error strings. Bounded per step loop by maxRetries (floored at 1).
Adds a chat:context:compacted observability event (agents), emitted once per
compaction on both the proactive and reactive paths.
Notes:
- Provider context-overflow errors always surface as in-stream error parts
(verified against the AI SDK: streamText re-enqueues even top-level rejections
as { type: "error" } fullStream parts, and toUIMessageStream passes them
through without throwing), so the in-stream seam catches them on every path;
the thrown-error catch path needs no separate wiring.
- A one-time warning fires if contextOverflow.reactive is enabled but
classifyChatError was never overridden (recovery would otherwise be a silent
no-op).
- Recovery effectiveness depends on the app's compaction config — a no-op
compaction cannot rescue an over-budget turn (handled gracefully: terminal,
not a loop).
Tests cover reactive success, opt-in-off terminal, no-op-compaction terminal
(via onChatError, classified), budget-bounded persistent overflow, the
WebSocket and programmatic paths, the proactive guard (capped per run), and
defaultContextOverflowClassifier. Docs (think/index, lifecycle-hooks,
observability, sessions), the @cloudflare/think README, and the assistant
example are updated.
Co-authored-by: Cursor <cursoragent@cursor.com>
Three follow-ups from review: - Proactive guard baseline: `_turnModelMessageBaseline` was captured from the assembled messages BEFORE `beforeTurn` ran, so a `beforeTurn` that overrides `messages` with a different length left the baseline stale — the guard's `event.messages.slice(baseline)` tail splice could then duplicate head messages or drop steps. Capture the baseline (and the active tool set) from the FINAL messages/tools, after beforeTurn, so the splice stays correct. - Overflow retry semantics: the reactive backstop re-ran `_runInferenceLoop` with `continuation: attempt > 0`, conflating an overflow retry with an auto-continuation. An app whose `beforeTurn` gates one-time setup on `!ctx.continuation` would skip it on the retry. An overflow retry re-runs the SAME user turn from compacted history, so it now passes `continuation: false` on every attempt (all three turn paths). The flag only ever fed `TurnContext.continuation`, so this has no other effect. - classifyChatError docs/JSDoc overstated the contract: it is consulted ONLY for context-overflow recovery (when `contextOverflow.reactive` is enabled), and `ChatErrorContext.classification` is populated ONLY as `"context_overflow"` on the terminal `onChatError` of an unrecoverable overflow. Other categories are reserved (returning them is a no-op) and are NOT forwarded to onChatError. Corrected the JSDoc, the Think README, and the lifecycle-hooks doc (hook summary, classifyChatError section, and the ChatErrorContext.classification row) to match. New test asserts both attempts of a recovered turn see `beforeTurn` `continuation: false`. Full check + 502 think tests pass. Co-authored-by: Cursor <cursoragent@cursor.com>
Review follow-up. The proactive context guard reads `usage.inputTokens` and falls back to `usage.totalTokens` when a provider omits the former. Document that fallback (a safe over-approximation — compacts slightly early rather than missing the threshold; no-op when neither is reported) on the `ContextOverflowConfig.proactive` JSDoc and in the Think docs, so the `maxInputTokens` name vs. total-token comparison does not surprise users. No behavior change. (The other two review notes were already addressed: classifyChatError docs were corrected in 5c4deb2; the RPC overflow early-return clears `_streamingAssistant` via the method's existing `finally`.) Co-authored-by: Cursor <cursoragent@cursor.com>
…fectiveness
Review follow-up. The overflow mock previously overflowed on call 1 and
succeeded on call 2 regardless of the prompt, so the recovery tests verified the
retry plumbing but not that compaction actually shortened what was sent — a
retry that resent the full (uncompacted) history would still have passed.
The mock now overflows while a seeded marker ("earlier question", part of the
messages compaction collapses) is still present in the prompt, and only succeeds
once compaction has removed it. The agent records, per model call, whether that
marker was still in the prompt, and the reactive / programmatic / WebSocket
recovery tests now assert `promptIncludedSeedMarker === [true, false]` — proving
the retry's prompt is genuinely shorter and that the refreshed in-memory message
cache (via `session.compact()`'s awaited `compact` change event →
`_syncMessages()`) reaches the retry. This also guards against a regression
where the cache is not refreshed (the retry would resend the marker and overflow
forever). `alwaysOverflow` still forces an overflow on every call to model the
"single remaining message exceeds the window" terminal case.
No production code change. Full check + 509 think tests pass.
Co-authored-by: Cursor <cursoragent@cursor.com>
…le + docs
Review hardening of the mid-turn context-overflow recovery feature: fixes a
correctness bug, decouples two coupled knobs, improves abort behavior, expands
test coverage across all turn paths, and adds a runnable example verified live
against Workers AI.
think.ts
- Discard the truncated partial assistant message on a reactive retry instead
of persisting it. Persisting orphaned a cut-off bubble beside the recovered
answer (and risked duplicating re-issued tool work). Regression test added.
- Decouple the proactive cap from the reactive budget: maxRetries now bounds
ONLY reactive compact-and-retries; add proactive.maxCompactions (default 1)
for the per-turn proactive cap. The two are independent.
- Skip compaction + retry when the turn is aborted: add `!abortSignal?.aborted`
guards to all three reactive driver loops (WebSocket, chat()/RPC, programmatic
saveMessages). An aborted turn no longer runs an expensive compaction or retry.
- Thread requestId into classifyChatError's context at the overflow stream seam
({ stage: "stream", requestId }) so a classifier can correlate/cancel the
in-flight turn.
- Tighten defaultContextOverflowClassifier: anchor the vaguest fragment
("reduce the length" -> "reduce the length of") while keeping recall for the
other provider patterns.
tests (packages/think/src/tests)
- New mock models + harness for: top-level doStream rejection (proves the AI SDK
re-enqueues it as an in-stream error part the recovery seam catches), combined
proactive+reactive recovery in one turn, and abort-during-recovery.
- Abort-during-recovery tested on all three turn paths.
- Strengthened the multi-fire proactive test to assert spliced-prompt structure
(tool-call/result pairing intact + recompacted head present), not just a clean
finish.
- Assert the chat:context:compacted payload contract (reason/shortened/
requestId/attempt).
- Negative + positive cases for the classifier regex change.
example (examples/context-overflow-recovery)
- New full-stack Think + Workers AI demo of recovery. Verified LIVE against
@cf/meta/llama-3.1-8b-instruct (~7968-token window): the model rejects an
oversized prompt with a classifiable error, and an overflowing turn recovers
(compaction summarizes stale history, the turn completes).
- The "Add background document" action inflates stored HISTORY (not the
triggering message) so compaction can reclaim room — the realistic, reliable
shape. Sending the bulk as the triggering message is the path where keep-last
compaction can't shrink below it.
docs
- docs/think/index.md: proactive.maxCompactions, decoupling note, link to the
example.
- docs/think/lifecycle-hooks.md: document the requestId now in
classifyChatError's context.
- packages/think/README.md: clarify the two independent caps.
- README.md: list the new example.
- .changeset updated to reflect the partial-discard and decoupling.
threepointone
force-pushed
the
fix/think-context-overflow-recovery
branch
from
June 4, 2026 16:16
4658560 to
87457d8
Compare
Merged
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.
Summary
Compaction (
Session.compactAfter) is only checked between turns — on eachappendMessage. But a single long, tool-heavy turn grows the prompt step by step inside onestreamTextloop and can exceed the model's context window mid-turn, before the next pre-turn check. The provider then rejects the request ("prompt is too long"/context_length_exceeded) and the turn dies terminally. Think shipped no provider-specific error matching, so it could neither detect nor recover from this.This PR adds opt-in, provider-agnostic recovery, configured through a single
contextOverflowproperty onThink. All layers default off (no behavior change unless enabled) and all reuse the session's existing compaction function.What's added
1.
classifyChatError(error, ctx)— the classification seamMaps a raw error (or the in-stream error string) to a
ChatErrorClassification:"context_overflow" | "rate_limit" | "transient" | "fatal" | "unknown". The classification is threaded toonChatErrorand observers viaChatErrorContext.classification. During recovery the context also carries{ stage: "stream", requestId }, so a classifier can correlate the error with the in-flight turn (e.g. tocancelChat(requestId)). For the common case, a bundled, exporteddefaultContextOverflowClassifiercovers Anthropic / OpenAI / Google / Bedrock / Mistral patterns:2.
contextOverflow.reactive(+maxRetries) — reactive backstopWhen a turn fails with a
context_overflow, Think discards the truncated partial assistant message, runssession.compact(), and re-runs the turn (bounded bymaxRetries, default1) from the compacted history. The partial is intentionally not persisted: the retry restarts the turn from scratch, so keeping the cut-off message would orphan it beside the recovered answer (and duplicate any tool work the retry re-issues). A no-op compaction or a spent budget surfaces terminally throughonChatErrorwithclassification: "context_overflow"— never a silent end, never an infinite loop. Wired into the WebSocket,chat()/RPC, and programmatic (saveMessages/submitMessages) turn paths.3.
contextOverflow.proactive— proactive guard (the root-cause fix)A
{ maxInputTokens, headroom?, maxCompactions? }pre-step guard. Before each step, Think reads the previous step's model-reportedusage.inputTokensand, if it crossesmaxInputTokens * (headroom ?? 0.9), compacts in place and feeds the recompacted history into the upcoming step — heading off the provider rejection before it happens. Keys off model-reported usage (every provider reports it), not provider error strings. Bounded per turn by its ownmaxCompactions(default1), independent of the reactivemaxRetriesbudget, so it cannot thrash and a no-op can't compact on every step.4. Abort responsiveness
All three reactive driver loops now skip compaction + retry when the turn's
abortSignalis already aborted, falling through to the existing terminal delivery. An aborted turn no longer runs an expensive compaction (an LLM summarization) or a wasted retry.Observability
chat:context:compactedon theagents:chatchannel —{ reason: "proactive" | "reactive", shortened, requestId?, attempt? }— emitted once per compaction.Why only the in-stream seam (investigation, now test-locked)
Recovery hooks the in-stream error-part seam (
action.type === "error"), not the thrown-errorcatch.streamTextre-enqueues even top-level request rejections as{ type: "error" }fullStream parts, andtoUIMessageStreampasses them through without throwing — so provider context-overflow 400s always reach the in-stream seam on every path. This is no longer just asserted: a test drives a model whosedoStreamrejects (top-level throw) and confirms recovery still fires, so a future AI-SDK change would fail the suite rather than silently regress.Edge cases / safety
onChatError, classified. Not a loop._streamResultpreviously leftdoneSent = false, firing a spurious terminaldoneframe mid-recovery — now suppressed (regression-guarded).contextOverflow.reactiveis enabled butclassifyChatErrorwas never overridden.Verified live against Workers AI
The new example was run end-to-end against
@cf/meta/llama-3.1-8b-instruct(~7968-token window):5021: ...exceeded this model context window limit (7968), anddefaultContextOverflowClassifierclassifies it ascontext_overflow(bothErrorand string forms).status: "completed", one compaction event).Test plan
npm run check(sherif + export checks + oxfmt + oxlint + typecheck) — all 91 projects clean@cloudflare/thinkfull suite — 544 tests passassistant-agent-loop.test.ts): discard-partial regression; reactive success; opt-in-off terminal; no-op-compaction terminal; budget-bounded overflow; WebSocket / chat() / programmatic paths; abort-during-recovery on all three paths; combined proactive+reactive in one turn; top-leveldoStreamrejection recovery; multi-fire proactive splice integrity (tool pairing + recompacted head);chat:context:compactedpayload contract;defaultContextOverflowClassifiermatches + non-matchesnpx nx affected -t testDocs & example
docs/think/index.md(config + Context-window overflow recovery section,proactive.maxCompactions, example link),docs/think/lifecycle-hooks.md(classifyChatError+requestIdin context),docs/observability.md(chat:context:compacted)packages/think/README.md(the two independent caps), rootREADME.md(example listed)examples/context-overflow-recovery— a focused, full-stack Think + Workers AI demo (live-verified). Inflates history (not the triggering message) so compaction can reclaim room — the realistic, reliable shape.examples/assistantalso wires the opt-in.Changeset
@cloudflare/think(patch) +agents(patch) — additive, opt-in; theagentsbump is the new observability event type.Notes for review
contextOverflow/ContextOverflowConfig(incl.proactive.maxCompactions),classifyChatError/ChatErrorClassification,ChatErrorContext.classification,defaultContextOverflowClassifier.