Think wraps chat turns in recoverable fibers by default (chatRecovery = true). If the Durable Object is evicted mid-stream, Think reconstructs any buffered chunks, persists partial output, and schedules either a continuation of the assistant turn or a retry of the unanswered user turn.
When chatRecovery is true, WebSocket turns, sub-agent chat() turns, durable submitMessages() executions, auto-continuations, saveMessages(), and continueLastTurn() are wrapped in runFiber.
A stream-stall watchdog abort (chatStreamStallTimeoutMs) is treated as just another interruption: when chatRecovery is on, a stall routes into this same bounded path — the settled partial is preserved and a continuation is scheduled — so a transient hang recovers automatically. A persistently hanging provider exhausts the budget and terminalizes through the same exhaustion handling as a deploy or eviction interruption: onExhausted fires, the chat:recovery:exhausted event is emitted, and the configured terminalMessage is shown (not a raw stall error).
Configure bounded recovery by setting chatRecovery to an object:
export class MyAgent extends Think {
chatRecovery = {
maxAttempts: 6,
stableTimeoutMs: 10_000,
terminalMessage: "The assistant was interrupted and could not recover.",
async onExhausted(ctx) {
console.warn("Chat recovery exhausted", ctx.incidentId);
},
};
getModel() {
/* ... */
}
}export class MyAgent extends Think<Env> {
override chatRecovery = {
maxAttempts: 6,
stableTimeoutMs: 10_000,
terminalMessage: "The assistant was interrupted and could not recover.",
async onExhausted(ctx) {
console.warn("Chat recovery exhausted", ctx.incidentId);
},
};
getModel() {
/* ... */
}
}The same recovery events are available through agents/observability on the chat channel; transcript repairs are emitted on the transcript channel. Refer to Observability.
Override onChatRecovery when you need provider-specific recovery, such as retrieving a stored OpenAI Responses result instead of issuing a new model call:
export class MyAgent extends Think {
chatRecovery = {
maxAttempts: 10,
terminalMessage: "The assistant was interrupted. Please try again.",
};
async onChatRecovery(ctx) {
console.log("Recovering chat turn", ctx.incidentId, ctx.attempt);
return {}; // persist partial output and continue/retry when possible
}
}import type {
ChatRecoveryContext,
ChatRecoveryOptions,
} from "@cloudflare/think";
export class MyAgent extends Think<Env> {
override chatRecovery = {
maxAttempts: 10,
terminalMessage: "The assistant was interrupted. Please try again.",
};
override async onChatRecovery(
ctx: ChatRecoveryContext,
): Promise<ChatRecoveryOptions> {
console.log("Recovering chat turn", ctx.incidentId, ctx.attempt);
return {}; // persist partial output and continue/retry when possible
}
}| Field | Type | Description |
|---|---|---|
incidentId |
string |
Stable ID for this recovery incident |
attempt |
number |
Current attempt number for this incident, starting at 1 |
maxAttempts |
number |
Configured attempt cap before terminal exhaustion |
recoveryKind |
"retry" | "continue" |
Whether recovery will retry an unanswered user turn or continue a partial assistant turn |
streamId |
string |
The stream ID of the interrupted turn |
requestId |
string |
The request ID of the interrupted turn |
partialText |
string |
Text generated before the interruption |
partialParts |
MessagePart[] |
Parts accumulated before the interruption |
recoveryData |
unknown | null |
Data from this.stash() during the turn |
messages |
UIMessage[] |
Current conversation history |
lastBody |
Record<string, unknown>? |
Body from the interrupted turn |
lastClientTools |
ClientToolSchema[]? |
Client tools from the interrupted turn |
createdAt |
number |
Epoch milliseconds when the turn started |
| Field | Type | Description |
|---|---|---|
persist |
boolean? |
Whether to persist the partial assistant message |
continue |
boolean? |
Whether to auto-continue with a new turn via continueLastTurn() |
With persist: true, the partial message is saved. With continue: true, Think calls continueLastTurn() after the agent reaches a stable state.
For pre-stream interruptions, where ctx.streamId === "" and ctx.partialText === "" but the latest persisted message is still the unanswered user message, Think retries that turn automatically unless continue is false.
onChatRecovery(ctx: ChatRecoveryContext): ChatRecoveryOptions {
if (!ctx.streamId && !ctx.partialText) {
console.log("Recovering a pre-stream interruption");
}
return {};
}Use ctx.createdAt to skip stale recoveries. For example, if the interrupted turn is older than a few minutes, return { continue: false } so the partial response is preserved without starting an old continuation.
Instead of chatRecovery = true, assign an object to tune how long recovery is allowed to run and when it is given up on. A turn that keeps making forward progress is never terminated by the framework on its own — duration is not a bound. Recovery is only sealed by one of the limits in the following table.
export class MyAgent extends Think {
chatRecovery = {
maxAttempts: 10,
noProgressTimeoutMs: 5 * 60 * 1000,
maxRecoveryWork: Infinity,
terminalMessage: "The assistant was interrupted and could not recover.",
// Consulted from the second recovery attempt onward. Return false to stop.
// Called as `config.shouldKeepRecovering(ctx)`, so it is NOT bound to the
// agent instance — track real token/cost spend in your own store keyed by
// `ctx.recoveryRootRequestId`.
async shouldKeepRecovering(ctx) {
return (await getSpendForTurn(ctx.recoveryRootRequestId)) < MAX_SPEND;
},
async onExhausted(ctx) {
console.warn("Recovery exhausted", ctx.incidentId, ctx.reason);
},
};
}export class MyAgent extends Think<Env> {
override chatRecovery = {
maxAttempts: 10,
noProgressTimeoutMs: 5 * 60 * 1000,
maxRecoveryWork: Infinity,
terminalMessage: "The assistant was interrupted and could not recover.",
// Consulted from the second recovery attempt onward. Return false to stop.
// Called as `config.shouldKeepRecovering(ctx)`, so it is NOT bound to the
// agent instance — track real token/cost spend in your own store keyed by
// `ctx.recoveryRootRequestId`.
async shouldKeepRecovering(ctx) {
return (await getSpendForTurn(ctx.recoveryRootRequestId)) < MAX_SPEND;
},
async onExhausted(ctx) {
console.warn("Recovery exhausted", ctx.incidentId, ctx.reason);
},
};
}| Field | Default | Description |
|---|---|---|
maxAttempts |
10 |
Attempt cap. Resets on forward progress, so it catches a tight no-progress alarm loop, not a healthy long turn. |
stableTimeoutMs |
10_000 |
How long an attempt waits for the isolate to reach stable state before rescheduling. |
noProgressTimeoutMs |
300_000 (5 min) |
Primary stuck-turn bound: max time without forward progress before sealing. Resets on every progress-bearing attempt. |
maxRecoveryWork |
Infinity |
Runaway-loop guard: max produced content/tool units since the incident opened before a still-progressing turn is sealed. No cap by default. |
shouldKeepRecovering |
— | Caller policy consulted from the second attempt onward. Return false to stop recovery. The hook point for a token/cost budget (ctx.work is a coarse segment count, not tokens). |
terminalMessage |
generic message | Message shown to the user when recovery is given up on. |
onExhausted |
— | Called once when recovery is given up on. Inspect ctx.reason. |
ctx.reason on the exhausted hook is one of: no_progress_timeout (stuck), max_attempts_exceeded (no-progress alarm loop), work_budget_exceeded (runaway), recovery_aborted (your shouldKeepRecovering returned false), or stable_timeout (extreme churn). Refer to Stream recovery for the full shared reference — Think and @cloudflare/ai-chat use the same recovery configuration.
When a turn is interrupted mid-flight, the transcript can contain a tool call with no settled result. Before the next provider call, Think repairs each such call so the model does not silently re-run it and the provider does not reject the transcript with AI_MissingToolResultsError. The default flips the interrupted call to an errored tool result, so the record survives and conversion still has a tool result for it.
Override repairInterruptedToolPart to customize the repaired shape. The common case is a client-resolved tool — for example an ask_user question that has no server execute and is normally answered by the user's next message. Converting it to a plain text part lets the model treat it as ordinary conversation rather than a tool error, and keeps the question verbatim through compaction:
export class MyAgent extends Think {
repairInterruptedToolPart(part) {
const record = part;
if (record.type === "tool-ask_user") {
const input = record.input;
if (input?.prompt) {
return { type: "text", text: input.prompt };
}
}
return super.repairInterruptedToolPart(part);
}
}import type { UIMessage } from "ai";
export class MyAgent extends Think<Env> {
protected override repairInterruptedToolPart(
part: UIMessage["parts"][number],
): UIMessage["parts"][number] {
const record = part as Record<string, unknown>;
if (record.type === "tool-ask_user") {
const input = record.input as { prompt?: string } | undefined;
if (input?.prompt) {
return { type: "text", text: input.prompt };
}
}
return super.repairInterruptedToolPart(part);
}
}This runs during transcript repair — before the repaired transcript is persisted and sent to the model — so the conversion shapes the current turn, not just the next one. The input is already normalized to a valid object. A returned tool part must carry a settled result (output-available, output-error, or output-denied); returning a non-tool part such as text is also fine.
Compaction is checked between turns — compactAfter() runs after each appendMessage(). But a single long, tool-heavy turn grows the prompt step by step inside one streamText loop and can exceed the model 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 would otherwise die terminally.
Think recovers from this with two opt-in, provider-agnostic layers, both configured through the contextOverflow property. Both are off by default, so existing behavior is unchanged. Both reuse your session's compaction function, so they require a configureSession() with onCompaction() configured. Both require classifyChatError to tell Think which errors are overflows — Think ships no provider-specific matching in core.
1. Reactive backstop — contextOverflow.reactive. When a turn fails with an error you classify as "context_overflow", Think discards the truncated partial, runs session.compact(), and re-runs the turn from the compacted history. The partial is not persisted: the turn restarts from scratch, so keeping the cut-off assistant message would orphan it beside the recovered answer. It is bounded by contextOverflow.maxRetries (default 1); if compaction cannot shorten history or the budget is spent, the overflow surfaces terminally through onChatError with classification: "context_overflow" — it never loops or ends silently.
import { Think, defaultContextOverflowClassifier } from "@cloudflare/think";
export class MyAgent extends Think {
contextOverflow = { reactive: true };
// The bundled classifier covers the common providers (Anthropic, OpenAI,
// Google, Bedrock, …). Assign it directly, or write your own.
classifyChatError = defaultContextOverflowClassifier;
}import { Think, defaultContextOverflowClassifier } from "@cloudflare/think";
export class MyAgent extends Think<Env> {
override contextOverflow = { reactive: true };
// The bundled classifier covers the common providers (Anthropic, OpenAI,
// Google, Bedrock, …). Assign it directly, or write your own.
override classifyChatError = defaultContextOverflowClassifier;
}2. Proactive guard — contextOverflow.proactive. Heads off the provider error before it happens. Before each step, Think reads the previous step's model-reported usage.inputTokens (provider-agnostic) and, if it crosses maxInputTokens * (headroom ?? 0.9), compacts in place and feeds the recompacted history into the upcoming step. If a provider omits inputTokens, it falls back to usage.totalTokens (a safe over-approximation — it compacts slightly early rather than missing the threshold). It compacts at most proactive.maxCompactions times per turn (default 1) — independent of the reactive maxRetries budget — so a history that cannot shorten does not compact on every step.
import { Think, defaultContextOverflowClassifier } from "@cloudflare/think";
export class MyAgent extends Think {
contextOverflow = {
reactive: true,
// Compact mid-turn once a step approaches 90% of a 200K window.
proactive: { maxInputTokens: 200_000 },
};
classifyChatError = defaultContextOverflowClassifier;
}import { Think, defaultContextOverflowClassifier } from "@cloudflare/think";
export class MyAgent extends Think<Env> {
override contextOverflow = {
reactive: true,
// Compact mid-turn once a step approaches 90% of a 200K window.
proactive: { maxInputTokens: 200_000 },
};
override classifyChatError = defaultContextOverflowClassifier;
}Use either layer alone, or both together: the proactive guard avoids most overflows, and the reactive backstop catches any that still slip through (for example, a turn that starts already over budget, or a single tool result so large that compaction cannot help — in which case it terminalizes cleanly). Both apply to every turn entry path (WebSocket, sub-agent chat(), and programmatic saveMessages() / submitMessages()), and both emit a chat:context:compacted observability event.
For a runnable demo against a real Workers AI model, refer to the context-overflow-recovery example ↗.
Think provides methods to check if the agent is in a stable state — no pending tool results, no pending approvals, no active turns.
Returns true if any assistant message has pending tool calls (tools without results or pending approvals).
protected hasPendingInteraction(): booleanReturns a promise that resolves to true when the agent reaches a stable state, or false if the timeout is exceeded.
const stable = await this.waitUntilStable({ timeout: 30_000 });
if (stable) {
await this.saveMessages([
{
id: crypto.randomUUID(),
role: "user",
parts: [{ type: "text", text: "Now that you are done, summarize." }],
},
]);
}const stable = await this.waitUntilStable({ timeout: 30_000 });
if (stable) {
await this.saveMessages([
{
id: crypto.randomUUID(),
role: "user",
parts: [{ type: "text", text: "Now that you are done, summarize." }],
},
]);
}