Skip to content

Latest commit

 

History

History
852 lines (667 loc) · 47.9 KB

File metadata and controls

852 lines (667 loc) · 47.9 KB

Lifecycle Hooks

Think owns the streamText call and provides hooks at each stage of the chat turn. Hooks fire on every turn regardless of entry path — WebSocket chat, sub-agent chat(), saveMessages(), durable submitMessages() execution, continueLastTurn(), and auto-continuation after tool results.

Hook Summary

Hook When it fires Return Async
configureSession(session) Once during onStart Session yes
beforeTurn(ctx) Before streamText TurnConfig or void yes
beforeStep(ctx) Before each model step StepConfig or void yes
beforeToolCall(ctx) When model calls a tool ToolCallDecision or void yes
afterToolCall(ctx) After tool execution void yes
onStepFinish(ctx) After each step completes void yes
onChunk(ctx) Per streaming chunk void yes
onChatResponse(result) After turn completes and message is persisted void yes
onChatError(error, ctx?) On error during a turn error to propagate no
classifyChatError(error, ctx?) On a turn error, when contextOverflow.reactive is enabled ChatErrorClassification or void no

Execution Order

For a turn with two tool calls:

configureSession()          ← once at startup, not per-turn
      │
beforeTurn()                ← inspect assembled context, override model/tools/prompt
      │
  ┌── streamText ───────────────────────────────────┐
  │   beforeStep()                                  │
  │       │                                         │
  │   onChunk()  onChunk()  onChunk()  ...          │
  │       │                                         │
  │   beforeToolCall()  →  tool executes            │
  │                        afterToolCall()           │
  │       │                                         │
  │   onStepFinish()                                │
  │       │                                         │
  │   beforeStep()                                  │
  │       │                                         │
  │   onChunk()  onChunk()  ...                     │
  │       │                                         │
  │   beforeToolCall()  →  tool executes            │
  │                        afterToolCall()           │
  │       │                                         │
  │   onStepFinish()                                │
  └─────────────────────────────────────────────────┘
      │
onChatResponse()            ← message persisted, turn lock released

configureSession

Called once during Durable Object initialization (onStart). Configure the Session with context blocks, compaction, search, and skills.

configureSession(session: Session): Session | Promise<Session>
import { Think, Session } from "@cloudflare/think";
import { createCompactFunction } from "agents/experimental/memory/utils/compaction-helpers";
import { generateText } from "ai";

export class MyAgent extends Think<Env> {
  getModel() {
    /* ... */
  }

  configureSession(session: Session) {
    return session
      .withContext("soul", {
        provider: { get: async () => "You are a helpful coding assistant." }
      })
      .withContext("memory", {
        description: "Learned facts about the user.",
        maxTokens: 1100
      })
      .onCompaction(
        createCompactFunction({
          summarize: (prompt) =>
            generateText({ model: this.resolveModel(), prompt }).then(
              (r) => r.text
            )
        })
      )
      .compactAfter(100_000)
      .withCachedPrompt();
  }
}

When configureSession adds context blocks, Think builds the system prompt from those blocks instead of using getSystemPrompt(). See the Sessions documentation for the full API.


beforeTurn

Called before streamText. Receives the fully assembled context — system prompt, converted messages, merged tools, and model. Return a TurnConfig to override any part, or void to accept defaults.

beforeTurn(ctx: TurnContext): TurnConfig | void | Promise<TurnConfig | void>

TurnContext

Field Type Description
system string Assembled system prompt (from context blocks or getSystemPrompt())
messages ModelMessage[] Assembled model messages (truncated)
tools ToolSet Merged tool set (workspace + getTools + session + MCP + client + caller)
model LanguageModel The resolved model (a string from getModel() is already resolved here)
continuation boolean Whether this is a continuation turn (auto-continue after tool result)
body Record<string, unknown> Custom body fields from the client request

TurnConfig

All fields are optional. Return only what you want to change.

Field Type Description
model ThinkModel Override the model for this turn — a model id string or a LanguageModel
system string Override the system prompt
messages ModelMessage[] Override the assembled messages
tools ToolSet Extra tools to merge (additive)
activeTools string[] Limit which tools the model can call
toolChoice ToolChoice Force a specific tool call
maxSteps number Override maxSteps for this turn
stopWhen StopCondition | StopCondition[] Additional early-exit conditions
sendReasoning boolean Send reasoning chunks for this turn
maxOutputTokens number Maximum tokens to generate
temperature number Sampling temperature
topP number Nucleus sampling value
topK number Top-K sampling value
presencePenalty number Presence penalty
frequencyPenalty number Frequency penalty
stopSequences string[] Stop generation sequences
seed number Sampling seed when supported
maxRetries number Maximum retries for this turn
timeout TimeoutConfiguration Timeout for this turn
chatStreamStallTimeoutMs number Override the stream-stall watchdog for this turn (0 disables it); auto-resets after the turn. Useful for a turn with a known-slow tool — see Think configuration.
headers Record<string, string> Additional provider request headers
providerOptions Record<string, unknown> Provider-specific options
experimental_transform StreamTextTransform | StreamTextTransform[] AI SDK stream transform(s) for this turn — inspect or rewrite stream parts (for example, emit source parts derived from tool results). Applied in order.

Examples

Switch to a cheaper model for continuation turns:

beforeTurn(ctx: TurnContext) {
  if (ctx.continuation) {
    return { model: this.cheapModel };
  }
}

Restrict which tools the model can call:

beforeTurn(ctx: TurnContext) {
  return { activeTools: ["read", "write", "getWeather"] };
}

Add per-turn context from the client body:

beforeTurn(ctx: TurnContext) {
  if (ctx.body?.selectedFile) {
    return {
      system: ctx.system + `\n\nUser is editing: ${ctx.body.selectedFile}`
    };
  }
}

Override maxSteps based on conversation length:

beforeTurn(ctx: TurnContext) {
  if (ctx.messages.length > 100) {
    return { maxSteps: 3 };
  }
}

Stop early when a designated tool is called while retaining Think's maxSteps safety bound:

import { hasToolCall } from "ai";

beforeTurn() {
  return { stopWhen: hasToolCall("finalAnswer") };
}

stopWhen is additive: Think sends both stepCountIs(maxSteps) and your condition(s) to the AI SDK, so the loop ends when either one matches. Stop conditions are functions, so they can be returned from a Think subclass's beforeTurn, but not from sandboxed extension beforeTurn hooks over RPC.

Prune older tool calls from the model context with the AI SDK's pruneMessages:

import { pruneMessages } from "ai";

beforeTurn(ctx: TurnContext) {
  return {
    messages: pruneMessages({
      messages: ctx.messages,
      toolCalls: "before-last-2-messages"
    })
  };
}

Scope pruning per-tool with the array form so client-side tool results survive across turns:

import { pruneMessages } from "ai";

beforeTurn(ctx: TurnContext) {
  return {
    messages: pruneMessages({
      messages: ctx.messages,
      toolCalls: [
        { type: "before-last-2-messages", tools: ["read_file", "search"] }
      ]
    })
  };
}

Hide reasoning for internal continuation turns:

beforeTurn(ctx: TurnContext) {
  if (ctx.continuation) {
    return { sendReasoning: false };
  }
}

Disable retries and apply a streaming timeout for a recovery turn:

beforeTurn(ctx: TurnContext) {
  if (ctx.body?.recovering) {
    return {
      maxRetries: 0,
      timeout: { totalMs: 30_000, chunkMs: 5_000 }
    };
  }
}

Force structured output for a turn (Vercel AI SDK Output.object). Combine with activeTools: [] because some providers (e.g. workers-ai-provider) strip tools when responseFormat: "json" is active:

import { Output } from "ai";
import { z } from "zod";

const ResultSchema = z.object({ severity: z.enum(["low", "high"]) });

beforeTurn(ctx: TurnContext) {
  // Gate however your agent decides "this is the structured-answer turn":
  // a body flag set by the caller, an internal phase enum, or a separate
  // sub-agent invocation. `ctx.continuation === true` means "this turn
  // was triggered by Think's auto-continuation after tool results", which
  // is *not* the same as "terminal turn" — don't conflate them.
  if (ctx.body?.mode === "structured-answer") {
    return {
      output: Output.object({ schema: ResultSchema }),
      activeTools: []
    };
  }
}

output is a turn-level setting only. The AI SDK's prepareStep does not accept an output override, so beforeStep cannot toggle structured output on a single step. If you need per-step structured output, run a separate turn (or a sub-agent call) with output set in beforeTurn.


beforeStep

Called before each AI SDK step in the agentic loop. Think forwards this hook to streamText as prepareStep, so it receives the AI SDK's full prepare-step context and can return per-step overrides. Use beforeTurn for turn-wide assembly and beforeStep when the decision depends on the step number or previous step results.

beforeStep(ctx: PrepareStepContext): StepConfig | void | Promise<StepConfig | void>

beforeStep fires between steps in the agentic loop: after the previous step's onStepFinish and before the next model call. For a tool-call → answer flow that means the order is:

beforeStep(ctx, stepNumber=0)       ← ctx.steps = []
  → model emits tool-call → beforeToolCall → execute → afterToolCall
onStepFinish(step 0)
beforeStep(ctx, stepNumber=1)       ← ctx.steps = [step 0]
  → model emits final text
onStepFinish(step 1)

PrepareStepContext

PrepareStepContext<TOOLS> is the parameter of the AI SDK's PrepareStepFunction<TOOLS>.

Field Type Description
steps Array<StepResult<TOOLS>> Steps that have already completed
stepNumber number Zero-based number of the step about to run
model LanguageModel Model currently selected for this step
messages ModelMessage[] Messages that will be sent to the model
experimental_context unknown AI SDK experimental context for tool execution

StepConfig

StepConfig<TOOLS> is the AI SDK's PrepareStepResult<TOOLS>. Return only the fields to override for the current step.

Field Type Description
model ThinkModel Override the model for this step — a model id string or a LanguageModel
toolChoice ToolChoice<TOOLS> Force or disable tool calling for this step
activeTools Array<keyof TOOLS> Limit which tools are available for this step
system string | SystemModelMessage | SystemModelMessage[] Override the system message for this step
messages ModelMessage[] Override the full message list for this step
experimental_context unknown Override context passed to tool execution from this step on
providerOptions ProviderOptions Provider-specific options for this step

Examples

Force a search tool on the first step:

beforeStep(ctx: PrepareStepContext<typeof tools>): StepConfig<typeof tools> | void {
  if (ctx.stepNumber === 0) {
    return {
      activeTools: ["search"],
      toolChoice: { type: "tool", toolName: "search" }
    };
  }
}

Switch to a cheaper model after tool results are available (assumes a fastSummaryModel field on your subclass):

beforeStep(ctx: PrepareStepContext): StepConfig | void {
  if (ctx.steps.some((step) => step.toolResults.length > 0)) {
    return { model: this.fastSummaryModel };
  }
}

Trim tool-heavy messages on later steps:

beforeStep(ctx: PrepareStepContext): StepConfig | void {
  if (ctx.stepNumber < 2) return;
  return {
    messages: ctx.messages.slice(-8)
  };
}

Limitations

The following are AI SDK boundary constraints surfaced through beforeStep, not Think-imposed limits:

  • No abortSignal in the context. If beforeStep does remote work (e.g. fetches a model from a registry), it cannot be cancelled by turn-level abort. Keep the hook fast and synchronous when possible.
  • output cannot be overridden per step. PrepareStepResult doesn't include output. Set structured output at the turn level via TurnConfig.output (returned from beforeTurn).
  • maxSteps cannot be overridden per step. Set it at the turn level via TurnConfig.maxSteps.
  • experimental_context is typed unknown. Narrow it yourself.
  • Subclass-only. beforeStep is not dispatched to extensions — the prepareStep event surface includes a live LanguageModel instance which is not JSON-safe to snapshot.

beforeToolCall

Called before the tool's execute function runs. Think wraps every server-side tool's execute so it can consult this hook and act on the returned ToolCallDecision. Only fires for tools with execute — client tools are handled on the client.

beforeToolCall(ctx: ToolCallContext): ToolCallDecision | void | Promise<ToolCallDecision | void>

ToolCallDecision

Return value Effect
void / undefined Run the original execute with the original input
{ action: "allow" } Same as void
{ action: "allow", input } Run the original execute with the substituted input
{ action: "block", reason? } Skip execute; the model sees reason (or a default string) as the tool's output
{ action: "substitute", output, input? } Skip execute; the model sees output as the tool's output

afterToolCall always fires after the decision resolves. For block and substitute, the substituted value flows through afterToolCall as success: true, output: ... (the model's perspective: it received a string back).

Note: when allow substitutes the input, afterToolCall.input still reflects what the model emitted (the AI SDK records the original tool-call chunk), while output reflects the result of executing with the substituted input. If you need to see the substituted input in afterToolCall, capture it in beforeToolCall and stash it on the agent.

ToolCallContext

ToolCallContext<TOOLS> spreads the AI SDK's TypedToolCall<TOOLS> at the top level (so ctx.toolName and ctx.input work without unwrapping) and adds the per-call event extras from OnToolCallStartEvent.

Field Type Description
type "tool-call" Discriminator
toolCallId string Unique id for this tool call
toolName string Name of the tool being called
input typed when TOOLS is passed Arguments the model provided (formerly args; renamed to match AI SDK)
dynamic? boolean true for runtime-registered tools, false/absent for statically declared
providerMetadata ProviderMetadata? Provider-specific metadata for this call
stepNumber number | undefined Index of the current step where this tool call occurs
messages ReadonlyArray<ModelMessage> Conversation messages visible at tool execution time
abortSignal AbortSignal | undefined Aborts if the turn is cancelled

Pass an explicit TOOLS generic to get full input typing:

import type { ToolCallContext } from "@cloudflare/think";

const tools = { search: tool({ inputSchema: z.object({ query: z.string() }), ... }) };

beforeToolCall(ctx: ToolCallContext<typeof tools>) {
  if (ctx.toolName === "search") {
    ctx.input.query; // typed as string
  }
}

Examples

Log all tool calls:

beforeToolCall(ctx: ToolCallContext) {
  console.log(`Tool called: ${ctx.toolName}`, ctx.input);
}

Block a tool when the agent is in a restricted mode:

beforeToolCall(ctx: ToolCallContext): ToolCallDecision | void {
  if (this.isReadOnlyMode && ctx.toolName === "delete") {
    return {
      action: "block",
      reason: "delete is disabled in read-only mode"
    };
  }
}

Substitute a cached result without running execute:

async beforeToolCall(ctx: ToolCallContext): Promise<ToolCallDecision | void> {
  if (ctx.toolName === "weather") {
    const cached = await this.cache.get(`weather:${JSON.stringify(ctx.input)}`);
    if (cached) return { action: "substitute", output: cached };
  }
}

Sanitize the model's input before execution (e.g. clamp a limit):

beforeToolCall(ctx: ToolCallContext): ToolCallDecision | void {
  if (ctx.toolName === "search") {
    const input = ctx.input as { query: string; limit?: number };
    return {
      action: "allow",
      input: { ...input, limit: Math.min(input.limit ?? 10, 50) }
    };
  }
}

Notes & limitations

  • Substituted input is not re-validated. The AI SDK validates the model's emitted input against the tool's inputSchema before execute runs. When beforeToolCall returns { action: "allow", input: ... }, that substituted input is passed straight through to execute without going through the schema again. If you substitute, ensure the shape stays valid for the tool you're calling.
  • stepNumber is undefined in ToolCallContext. The AI SDK's ToolExecutionOptions doesn't expose the current step index. The same field is populated on ToolCallResultContext (sourced from experimental_onToolCallFinish).
  • Throwing from beforeToolCall propagates as a tool error — the AI SDK records it in the same way it would record an execute failure, and afterToolCall fires with success: false, error: ....
  • Streaming tools (AsyncIterable returns). The AI SDK supports tools whose execute returns AsyncIterable<output> to emit preliminary results before a final value. The canonical form is an async generator (async function* execute(...) { … }). Think preserves preliminary streaming for that form even through beforeToolCall: each yielded value reaches the model as a preliminary tool-result, and the last as the final value. The non-canonical async function execute(...) { return makeIter(); } form returns a Promise<AsyncIterable>, which does not stream even in the raw AI SDK — Think collapses it to the last yielded value. Use an async function* execute if you need preliminary streaming.
  • Blocking/substituting an async function* tool emits one preliminary chunk. To keep an async function* tool streaming through beforeToolCall, its wrapper must commit to an AsyncIterable shape synchronously — before the (async) decision is known. The AI SDK turns every value sent through that iterable into a preliminary tool-result plus a final, so a block/substitute outcome surfaces one extra preliminary: true chunk to observers like onChunk (a scalar-execute tool never does). The model-visible final output is identical and correct; this only affects observation hooks for the narrow case of blocking/substituting a streaming tool, and it matches how any streaming tool that emits a single value already behaves.
  • Hook order: beforeToolCall (subclass) → extension beforeToolCall dispatch → original execute (or block/substitute short-circuit) → AI SDK records the outcome → afterToolCall (subclass) → extension afterToolCall dispatch.

afterToolCall

Called after a tool's outcome is known — for real executions, for block (carries the reason as output), and for substitute (carries the substituted output). The discriminated success/output/error reflects what the model actually sees: thrown errors from the original execute become success: false; everything else (including blocked / substituted calls) is success: true.

afterToolCall(ctx: ToolCallResultContext): void | Promise<void>

ToolCallResultContext

ToolCallResultContext<TOOLS> is backed by the AI SDK's OnToolCallFinishEvent<TOOLS> (the parameter of experimental_onToolCallFinish). It spreads the originating TypedToolCall<TOOLS> at the top level, plus the per-call event extras and a discriminated outcome:

Field Type Description
type "tool-call" Discriminator (carried over from the call)
toolCallId string Unique id matching the originating ToolCallContext
toolName string Name of the tool that was called
input typed when TOOLS is passed Arguments the tool was called with
dynamic? boolean true for runtime-registered tools
stepNumber number | undefined Index of the current step
messages ReadonlyArray<ModelMessage> Conversation messages visible at tool execution time
durationMs number Wall-clock execution time of execute
success boolean Discriminator: true on success, false on failure
output typed per tool (when success is true) Whatever the tool's execute returned
error unknown (when success is false) Whatever was thrown from execute

When you pass an explicit TOOLS generic, narrowing on ctx.toolName (together with ctx.success) narrows ctx.output to that tool's inferred output type. Dynamic tools (runtime-registered, MCP) stay unknown:

afterToolCall(ctx: ToolCallResultContext<typeof tools>) {
  if (ctx.toolName === "search" && ctx.success) {
    ctx.output.results; // typed as the `search` tool's output
  }
}

Example

Track tool usage and surface failures:

afterToolCall(ctx: ToolCallResultContext) {
  if (ctx.success) {
    this.env.ANALYTICS.writeDataPoint({
      blobs: [ctx.toolName, "ok"],
      doubles: [ctx.durationMs, JSON.stringify(ctx.output).length]
    });
  } else {
    this.env.ANALYTICS.writeDataPoint({
      blobs: [ctx.toolName, "error", String(ctx.error)],
      doubles: [ctx.durationMs]
    });
  }
}

onStepFinish

Called after each step completes in the agentic loop. A step is one streamText iteration — the model generates text, optionally calls tools, and the step ends.

onStepFinish(ctx: StepContext): void | Promise<void>

StepContext

StepContext<TOOLS> is a re-export of the AI SDK's StepResult<TOOLS> (= OnStepFinishEvent<TOOLS>). The full step record is forwarded — nothing is dropped or renamed. Highlights:

Field Type Description
stepNumber number Zero-based index of this step
text string Text generated in this step
reasoning Array<ReasoningPart> Reasoning parts emitted by the model
reasoningText string | undefined Concatenated reasoning text
files Array<GeneratedFile> Files generated during the step
sources Array<Source> Citations / sources used
toolCalls Array<TypedToolCall<TOOLS>> Typed tool calls (same shape as ToolCallContext)
toolResults Array<TypedToolResult<TOOLS>> Typed tool results (same shape as ToolCallResultContext)
finishReason FinishReason Unified finish reason from the model
rawFinishReason string | undefined Raw provider finish reason
usage LanguageModelUsage inputTokens, outputTokens, totalTokens, reasoningTokens, cachedInputTokens
warnings CallWarning[] | undefined Warnings from the provider
request LanguageModelRequestMetadata Raw request metadata
response LanguageModelResponseMetadata & { messages, body? } Raw response metadata + assistant/tool messages
providerMetadata ProviderMetadata | undefined Provider-specific metadata (e.g. Anthropic cache accounting)

Examples

Log step-level usage with cache accounting:

onStepFinish(ctx: StepContext) {
  console.log(
    `Step ${ctx.stepNumber} (${ctx.finishReason}): ` +
    `${ctx.usage.inputTokens}in/${ctx.usage.outputTokens}out, ` +
    `${ctx.usage.cachedInputTokens ?? 0} cached`
  );
}

Capture reasoning text and citations:

onStepFinish(ctx: StepContext) {
  if (ctx.reasoningText) {
    this.env.LOGS.writeDataPoint({ blobs: ["reasoning", ctx.reasoningText] });
  }
  for (const source of ctx.sources) {
    this.env.LOGS.writeDataPoint({ blobs: ["source", source.url ?? ""] });
  }
}

Read provider-specific cache tokens (Anthropic):

onStepFinish(ctx: StepContext) {
  const anthropic = ctx.providerMetadata?.anthropic as
    | { cacheCreationInputTokens?: number; cacheReadInputTokens?: number }
    | undefined;
  if (anthropic) {
    console.log(
      `cache: ${anthropic.cacheCreationInputTokens ?? 0} created, ` +
      `${anthropic.cacheReadInputTokens ?? 0} read`
    );
  }
}

onChunk

Called for each streaming chunk. High-frequency — fires per token. Override for streaming analytics, progress indicators, or token counting. Observational only.

onChunk(ctx: ChunkContext): void | Promise<void>

ChunkContext

ChunkContext<TOOLS> is the parameter type of the AI SDK's StreamTextOnChunkCallback<TOOLS>. The chunk field is a discriminated union of TextStreamPart variants — narrow on chunk.type for typed access:

Field Type Description
chunk Extract<TextStreamPart<TOOLS>, { type: "text-delta" | "reasoning-delta" | "source" | "tool-call" | "tool-input-start" | "tool-input-delta" | "tool-result" | "raw" }> The current chunk from the AI SDK stream

Example — count text-delta tokens and forward reasoning to a logger:

onChunk(ctx: ChunkContext) {
  switch (ctx.chunk.type) {
    case "text-delta":
      this.tokensStreamed += ctx.chunk.text.length;
      break;
    case "reasoning-delta":
      console.log("[reasoning]", ctx.chunk.text);
      break;
    case "tool-call":
      console.log(`[tool] ${ctx.chunk.toolName}`, ctx.chunk.input);
      break;
  }
}

onChatResponse

Called after a chat turn completes and the assistant message has been persisted. The turn lock is released before this hook runs, so it is safe to call saveMessages or other methods from inside.

Fires for all turn completion paths that persist an assistant message: WebSocket, sub-agent RPC, saveMessages(), durable submitMessages() execution, continueLastTurn(), and auto-continuation.

onChatResponse(result: ChatResponseResult): void | Promise<void>

ChatResponseResult

Field Type Description
message UIMessage The persisted assistant message
requestId string Unique ID for this turn
continuation boolean Whether this was a continuation turn
status "completed" | "error" | "aborted" How the turn ended
error string? Error message (when status is "error")

Examples

Log turn completion:

onChatResponse(result: ChatResponseResult) {
  if (result.status === "completed") {
    console.log(
      `Turn ${result.requestId} completed: ${result.message.parts.length} parts`
    );
  }
}

Chain a follow-up turn:

async onChatResponse(result: ChatResponseResult) {
  if (result.status === "completed" && this.shouldFollowUp(result.message)) {
    await this.saveMessages([{
      id: crypto.randomUUID(),
      role: "user",
      parts: [{ type: "text", text: "Now summarize what you found." }]
    }]);
  }
}

Distinguish abort from error:

async onChatResponse(result: ChatResponseResult) {
  if (result.status === "aborted") {
    // Cancelled via chat-request-cancel or saveMessages({ signal })
    // — partial chunks are persisted, the message is the partial
    // assistant transcript at the moment of abort.
    this.logAbortMetric(result.requestId);
  } else if (result.status === "error") {
    // Inference threw — `result.error` carries the error message.
    console.error(`Turn ${result.requestId} errored: ${result.error}`);
  }
}

onChatError

Called when an error occurs during a chat turn. Return the error to propagate it, or return a different error. The optional context describes where the failure happened and whether user messages were already persisted.

onChatError(error: unknown, ctx?: ChatErrorContext): unknown

The partial assistant message (if any) is persisted before this hook fires.

ChatErrorContext includes:

Field Type Description
requestId string | undefined Chat request ID, when available
stage "parse" | "persist" | "turn" | "stream" | "recovery" | "transcript" Failure stage
messagesPersisted boolean Whether incoming user messages were already stored
classification ChatErrorClassification | undefined Set to "context_overflow" on the terminal onChatError when a context overflow could not be recovered (see classifyChatError); undefined otherwise

Think also emits chat:request:failed on the agents:chat observability channel with the same stage and persistence information.

Example

Log and transform errors:

onChatError(error: unknown, ctx?: ChatErrorContext) {
  console.error("Chat turn failed:", ctx?.stage, error);
  if (ctx?.classification === "context_overflow") {
    return new Error("This conversation is too long to continue. Please start a new one.");
  }
  return new Error("Something went wrong. Please try again.");
}

classifyChatError

Called when an error occurs during a turn, before onChatError. Maps a raw provider error into a provider-agnostic category so Think can react without baking provider-specific strings into the framework — the same split as the tokenCounter you pass to compactAfter(). The app owns the mapping because it knows which provider and model it talks to.

classifyChatError(error: unknown, ctx?: ChatErrorContext): ChatErrorClassification | void

ChatErrorClassification is "context_overflow" | "rate_limit" | "transient" | "fatal" | "unknown". Today this hook drives only context-overflow recovery: Think calls it when a turn errors and contextOverflow.reactive is enabled (if reactive is off, it is not called). Returning "context_overflow" runs the compact-and-retry backstop (see Context-window overflow recovery); if recovery cannot save the turn, that classification is surfaced on the terminal onChatError call via ChatErrorContext.classification. The other categories are reserved for future use — returning one today is a no-op (the turn terminalizes as usual) and is not forwarded to onChatError. Returning void (the default) keeps the existing terminal behavior.

The argument may be an Error, an AI SDK APICallError (with statusCode/responseBody), or — for in-stream provider errors that surface as a stream error part rather than a throw — the error message string. Narrow accordingly. (Think confirms provider context-overflow errors always surface as in-stream error parts, never thrown exceptions out of streamText, so this hook sees them whether you read the Error or the string form.)

The second argument is a ChatErrorContext: when consulted for overflow recovery it is { stage: "stream", requestId }, so a classifier can correlate the error with the in-flight turn — for example to call cancelChat(requestId) and bail out of recovery.

Example

For the common case, assign the bundled defaultContextOverflowClassifier, which matches the context-overflow errors of Anthropic, OpenAI, Google, Bedrock, and others:

import { Think, defaultContextOverflowClassifier } from "@cloudflare/think";

export class MyAgent extends Think<Env> {
  override classifyChatError = defaultContextOverflowClassifier;
}

Or write your own, optionally delegating to the bundled classifier:

import type { ChatErrorClassification } from "@cloudflare/think";
import { defaultContextOverflowClassifier } from "@cloudflare/think";

classifyChatError(error: unknown): ChatErrorClassification | void {
  if (error instanceof Error && /rate.?limit/i.test(error.message)) {
    return "rate_limit";
  }
  return defaultContextOverflowClassifier(error);
}

Extension hook subscriptions

Extensions can subscribe to beforeTurn, beforeToolCall, afterToolCall, onStepFinish, and onChunk via their manifest's hooks array. Think dispatches to extension-side handlers in load order, after the subclass hook has run, with a JSON-safe snapshot of the event. beforeStep is available to subclasses only and is not dispatched to extensions (it runs on the AI SDK's prepareStep boundary, where snapshotting non-serializable inputs like LanguageModel instances is not meaningful).

// extension source
({
  tools: {
    /* ... */
  },
  hooks: {
    beforeTurn: async (snapshot, host) => {
      /* may return TurnConfig */
    },
    beforeToolCall: async (snapshot, host) => {
      /* observation */
    },
    afterToolCall: async (snapshot, host) => {
      /* observation */
    },
    onStepFinish: async (snapshot, host) => {
      /* observation */
    },
    onChunk: async (snapshot, host) => {
      /* observation; high-frequency */
    }
  }
});

The handler receives (snapshot, host) — symmetric with tool execute. host is the bridge (HostBridgeLoopback) when the extension was loaded with permissions that require it; otherwise null.

Snapshot shapes

Snapshots are intentionally narrower than the subclass Context types — class instances, AbortSignals, and other non-JSON-clonable values can't cross the Workers RPC boundary.

Hook Snapshot fields
beforeTurn { system, toolNames, messageCount, continuation, body?, modelId } — see TurnContextSnapshot
beforeToolCall { toolName, toolCallId, input, stepNumber, dynamic? }
afterToolCall { toolName, toolCallId, input, stepNumber, durationMs, success, output? | error?, dynamic? }
onStepFinish { stepNumber, finishReason, text, reasoningText, toolCallCount, toolResultCount, usage, providerMetadata }
onChunk { type, text?, toolName?, toolCallId? } — minimal because this fires per token

Return values

Only beforeTurn honors return values (it merges scalar TurnConfig fields back into the turn). The other extension hooks are observation-only — return values are discarded. Errors thrown from extension hooks are caught and logged; they do not abort the turn.

Performance note

onChunk fires per streaming token. Subscribing in an extension means an RPC round trip per chunk. Use sparingly — prefer aggregating in onStepFinish instead unless you specifically need per-token reactivity.