Skip to content

Releases: cloudflare/agents

agents@0.11.5

23 Apr 03:31
f07411b

Choose a tag to compare

Patch Changes

  • #1353 f834c81 Thanks @threepointone! - Align AIChatAgent generics and types with @cloudflare/think, plus a reference example for multi-session chat built on the sub-agent routing primitive.

    • New Props generic: AIChatAgent<Env, State, Props> extending Agent<Env, State, Props>. Subclasses now get properly typed this.ctx.props.
    • Shared lifecycle types: ChatResponseResult, ChatRecoveryContext, ChatRecoveryOptions, SaveMessagesResult, and MessageConcurrency now live in agents/chat and are re-exported by both @cloudflare/ai-chat and @cloudflare/think. No behavior change; one place to edit when shapes evolve.
    • ChatMessage stays the public message type: the package continues to export ChatMessage, and the public API/docs keep using that name.
    • messages stays a public field: messages: ChatMessage[].

    The full stance (AIChatAgent is first-class, production-ready, and continuing to get features; shared infrastructure should land in agents/chat where both classes benefit) is captured in design/rfc-ai-chat-maintenance.md.

    A new example, examples/multi-ai-chat, demonstrates the multi-session pattern end-to-end on top of the sub-agent routing primitive: an Inbox Agent owns the chat list + shared memory; each chat is an AIChatAgent facet (this.subAgent(Chat, id)). The client addresses the active chat via useAgent({ sub: [{ agent: "Chat", name: chatId }] }) — no separate DO binding, no custom routing on the server. Inbox.onBeforeSubAgent gates with hasSubAgent as a strict registry, and Chat reaches its parent via this.parentAgent(Inbox).

  • #1348 0693a5f Thanks @threepointone! - Bump dependencies.

  • #1362 d901804 Thanks @threepointone! - fix(mcp): capture tool title in MCP client

  • #1355 df2023f Thanks @threepointone! - External addressability for sub-agents.

    Clients can now reach a facet (a child DO created by Agent#subAgent()) directly via a nested URL:

    /agents/{parent-class}/{parent-name}/sub/{child-class}/{child-name}[/...]
    

    New public APIs (all @experimental):

    • routeSubAgentRequest(req, parent, options?) — sub-agent analog of routeAgentRequest. For custom-routing setups where the outer URL doesn't match the default /agents/... shape.
    • getSubAgentByName(parent, Cls, name) — sub-agent analog of getAgentByName. Returns a typed Proxy that round-trips typed RPC calls through the parent. RPC-only (no .fetch()); use routeSubAgentRequest for external HTTP/WS.
    • parseSubAgentPath(url, options?) — public URL parser used internally by the routers.
    • SUB_PREFIX — the "sub" separator constant (not configurable; exposed for symbolic URL building).

    New public on Agent:

    • onBeforeSubAgent(req, { className, name }) — overridable middleware hook, mirrors onBeforeConnect / onBeforeRequest. Returns Request | Response | void for short-circuit responses, request mutation, or passthrough. Default: void.
    • parentPath / selfPath — root-first { className, name } ancestor chains, populated at facet init time. Inductive across recursive nesting.
    • hasSubAgent(ClsOrName, name) / listSubAgents(ClsOrName?) — parent-side introspection backed by an auto-maintained SQLite registry written by subAgent() / deleteSubAgent(). Both accept either the class constructor or a CamelCase class name string.

    New public on useAgent (React):

    • sub?: Array<{ agent, name }> — flat root-first chain addressing a descendant facet. The hook's .agent / .name report the leaf identity; .path exposes the full chain.

    Breaking changes: none. routeAgentRequest behavior is unchanged when URLs don't contain /sub/. onBeforeSubAgent defaults to permissive (forward unchanged). useAgent without sub is unchanged. subAgent() / deleteSubAgent() gain registry side effects but preserve return types and failure modes. The _cf_initAsFacet signature gained an optional parentPath parameter. deleteSubAgent() is now idempotent — calling it for a never-spawned or already-deleted child no longer throws. Sub-agent class names equal to "Sub" are rejected (the /sub/ URL separator is reserved).

    See design/rfc-sub-agent-routing.md for the full rationale, design decisions, and edge cases. The spike at packages/agents/src/tests/spike-sub-agent-routing.test.ts documents the three candidate approaches considered for cross-DO stub passthrough and why the per-call bridge won.

  • #1346 a78bb2a Thanks @threepointone! - Remove unused dependencies, devDependencies, and peerDependencies from the agents package.

    • dependencies: drop json-schema, json-schema-to-typescript, and picomatch. None are imported by the package; picomatch was already pulled in transitively via @rolldown/plugin-babel.
    • devDependencies: drop @ai-sdk/openai (only referenced in a commented-out line) and @cloudflare/workers-oauth-provider (not referenced anywhere).
    • peerDependencies / peerDependenciesMeta: drop @ai-sdk/react and viem. @ai-sdk/react is already a peer of @cloudflare/ai-chat (itself an optional peer here), and viem is a regular dependency of @x402/evm, so both are supplied transitively when the relevant optional features are used.

@cloudflare/think@0.4.0

23 Apr 03:31
f07411b

Choose a tag to compare

Minor Changes

  • #1350 3a1140f Thanks @threepointone! - Align Think generics with Agent / AIChatAgent.

    Think is now Think<Env, State, Props> and extends Agent<Env, State, Props>, so subclasses get properly typed this.state, this.setState(), initialState, and this.ctx.props. The previous Config class generic is removed.

    configure() and getConfig() remain, but the config type is now specified at the call site via a method-level generic:

    // Before
    export class MyAgent extends Think<Env, MyConfig> {
      getModel() {
        const tier = this.getConfig()?.modelTier ?? "fast";
        // ...
      }
    }
    
    // After
    export class MyAgent extends Think<Env> {
      getModel() {
        const tier = this.getConfig<MyConfig>()?.modelTier ?? "fast";
        // ...
      }
    }

    This is a breaking change for anyone using the second type parameter of Think. Update the class declaration and any direct configure(...) / getConfig() call sites that relied on the class-level Config type.

@cloudflare/ai-chat@0.5.1

23 Apr 16:19
140cde3

Choose a tag to compare

Patch Changes

  • #1368 2fe85cb Thanks @threepointone! - Add isToolContinuation: boolean to useAgentChat() so consumers can disambiguate a fresh user-initiated status === "submitted" from one driven by a server-pushed tool continuation. See #1365.

    status already tracks the whole tool round-trip (submittedstreamingready) after addToolOutput / addToolApprovalResponse, on purpose — that's what #1157 asked for and what many loading-spinner UIs now rely on. But some consumers want a typing indicator only for new user messages, not for mid-turn continuations, and previously had to inspect message history to tell them apart.

    isToolContinuation is true from the moment addToolOutput / addToolApprovalResponse kicks off an auto-continuation until the continuation stream closes (or is aborted by stop()). It is false otherwise — including during cross-tab server broadcasts, which surface via isServerStreaming only.

    const { status, isStreaming, isToolContinuation } = useAgentChat({ ... });
    
    const isLoading = isStreaming || status === "submitted";
    const showTypingIndicator = status === "submitted" && !isToolContinuation;

    Purely additive — no change to status, isServerStreaming, or isStreaming semantics.

  • #1366 53600d0 Thanks @threepointone! - Fix useAgentChat() going silent while an onToolCall handler is running. The server's streamText ends the stream as soon as the model emits a client-tool call, which dropped status back to ready and isStreaming/isServerStreaming to false for the full duration of the client-side tool.execute() — often a fetch taking several seconds. Consumers had no single flag that covered the whole "turn in progress" window. See #1365.

    useAgentChat() now treats any unresolved client-side tool call on the latest assistant message as an active server-driven phase:

    • isServerStreaming is true from the moment the tool part appears in input-available (with an active handler — onToolCall or a deprecated tools entry with execute) until it transitions out via addToolOutput / addToolResult.
    • isStreaming (status === "streaming" || isServerStreaming) stays true across the whole tool round-trip, including the gap between the model emitting the call and the server pushing its continuation.
    • status is untouched — it still means "user-initiated submission awaiting a response." Tools waiting for explicit user confirmation are excluded from the busy signal (nothing is happening until the user acts).

    Consumer code simplifies to:

    const { isStreaming, status } = useAgentChat({ ... });
    const isLoading = isStreaming || status === "submitted";
    const showTypingIndicator = status === "submitted";

    No API changes. Existing code that only looked at status behaves the same.

@cloudflare/ai-chat@0.5.0

23 Apr 03:31
f07411b

Choose a tag to compare

Minor Changes

  • #1353 f834c81 Thanks @threepointone! - Align AIChatAgent generics and types with @cloudflare/think, plus a reference example for multi-session chat built on the sub-agent routing primitive.

    • New Props generic: AIChatAgent<Env, State, Props> extending Agent<Env, State, Props>. Subclasses now get properly typed this.ctx.props.
    • Shared lifecycle types: ChatResponseResult, ChatRecoveryContext, ChatRecoveryOptions, SaveMessagesResult, and MessageConcurrency now live in agents/chat and are re-exported by both @cloudflare/ai-chat and @cloudflare/think. No behavior change; one place to edit when shapes evolve.
    • ChatMessage stays the public message type: the package continues to export ChatMessage, and the public API/docs keep using that name.
    • messages stays a public field: messages: ChatMessage[].

    The full stance (AIChatAgent is first-class, production-ready, and continuing to get features; shared infrastructure should land in agents/chat where both classes benefit) is captured in design/rfc-ai-chat-maintenance.md.

    A new example, examples/multi-ai-chat, demonstrates the multi-session pattern end-to-end on top of the sub-agent routing primitive: an Inbox Agent owns the chat list + shared memory; each chat is an AIChatAgent facet (this.subAgent(Chat, id)). The client addresses the active chat via useAgent({ sub: [{ agent: "Chat", name: chatId }] }) — no separate DO binding, no custom routing on the server. Inbox.onBeforeSubAgent gates with hasSubAgent as a strict registry, and Chat reaches its parent via this.parentAgent(Inbox).

Patch Changes

  • #1358 ea229b1 Thanks @threepointone! - Fix useAgentChat() crashing on first render when agent.getHttpUrl() returns an empty string. This happened in setups where the WebSocket handshake hadn't completed by the time React rendered — most commonly when the agent is reached through a proxy or custom-routed worker — because @cloudflare/ai-chat unconditionally called new URL(agent.getHttpUrl()). See #1356.

    useAgentChat() now treats a missing HTTP URL as "not ready yet":

    • The built-in /get-messages fetch is deferred until the URL is known, and applied exactly once when it resolves (empty chats only — existing messages are never overwritten).
    • Custom getInitialMessages callbacks continue to run and are passed url: undefined so they can load from other sources if they don't need the socket URL. GetInitialMessagesOptions.url is now string | undefined; callers that previously typed url: string should widen to url?: string.
    • Initial messages are cached by agent identity (class + name) rather than by URL + identity, so the URL-arrival transition no longer invalidates the cache, re-invokes the loader, or re-triggers Suspense once the chat has already been populated.
    • The underlying useChat instance keeps a stable id across the URL-arrival transition, so in-flight stream resume and chat state are preserved.

    No API or behavior changes for apps where the URL was already available synchronously on first render.

agents@0.11.4

18 Apr 23:33
3dd0ab5

Choose a tag to compare

Patch Changes

  • #1222 3ebd966 Thanks @Muhammad-Bin-Ali! - Add experimental WebMCP adapter (agents/experimental/webmcp) that bridges MCP server tools to Chrome's native navigator.modelContext API, enabling browser-native AI agents to discover and call tools registered on a Cloudflare McpAgent.

agents@0.11.3

18 Apr 18:20
68e2fd6

Choose a tag to compare

Patch Changes

  • #1330 b4d3fcf Thanks @threepointone! - Fix subAgent() cross-DO I/O errors on first use and drop the "experimental" compatibility flag requirement.

    subAgent() cross-DO I/O fix

    Three issues in the facet initialization path caused "Cannot perform I/O on behalf of a different Durable Object" errors when spawning sub-agents in production:

    • subAgent() constructed a Request in the parent DO and passed it to the child via stub.fetch(). The Request carried native I/O tied to the parent isolate, which the child rejected.
    • The facet flag was set after the first onStart() ran, so broadcastMcpServers() fired with _isFacet === false on the initial boot.
    • _broadcastProtocol(), the inherited broadcast(), and _workflow_broadcast() iterated the connection registry without an _isFacet guard, letting broadcasts reach into the parent DO's WebSocket registry from a child isolate.

    Replaces the fetch-based handshake with a new _cf_initAsFacet(name) RPC that runs entirely in the child isolate, sets _isFacet before init, and seeds partyserver's __ps_name key directly. Adds _isFacet guards to _broadcastProtocol() and overrides broadcast() to no-op on facets so downstream callers (chat-streaming paths, workflow broadcasts, user this.broadcast(...)) are covered. Removes the previous internal _cf_markAsFacet() method — _cf_initAsFacet(name) is the correct entry point (it sets the flag before running the first onStart(), which _cf_markAsFacet did not).

    "experimental" compatibility flag no longer required

    ctx.facets, ctx.exports, and env.LOADER (Worker Loader) have graduated out of the "experimental" compatibility flag in workerd. agents and @cloudflare/think no longer require it:

    • subAgent() / abortSubAgent() / deleteSubAgent() — the @experimental JSDoc tag and runtime error messages no longer reference the flag. The runtime guards on ctx.facets / ctx.exports stay in place and now nudge users toward updating compatibility_date instead.
    • Think — the @experimental JSDoc tag no longer references the flag.

    No code change is required; remove "experimental" from your compatibility_flags in wrangler.jsonc if it was only there for these features.

  • #1332 7cb8acf Thanks @threepointone! - Expose createdAt on fiber and chat recovery contexts so apps can suppress continuations for stale, interrupted turns.

    • FiberRecoveryContext (from agents) gains createdAt: number — epoch milliseconds when runFiber started, read from the cf_agents_runs row that was already tracked internally.
    • ChatRecoveryContext (from @cloudflare/ai-chat and @cloudflare/think) gains the same createdAt field, threaded through from the underlying fiber.

    With this, the stale-recovery guard pattern described in #1324 is a short override:

    override async onChatRecovery(ctx: ChatRecoveryContext): Promise<ChatRecoveryOptions> {
      if (Date.now() - ctx.createdAt > 2 * 60 * 1000) return { continue: false };
      return {};
    }

    No behavior change for existing callers. See docs/chat-agents.md (new "Guarding against stale recoveries" section) for the full recipe, including a loop-protection pattern using onChatResponse.

@cloudflare/worker-bundler@0.1.2

18 Apr 18:20
68e2fd6

Choose a tag to compare

Patch Changes

  • #1334 77c8c9c Thanks @threepointone! - createWorker and createApp now accept a handful of extra esbuild knobs that previously required forking or patching the package:

    • jsx ("transform" | "preserve" | "automatic")
    • jsxImportSource
    • define (compile-time constant replacement)
    • loader (per-extension loader overrides — e.g. { ".svg": "text", ".wasm": "binary" }; built-in handling for .ts/.tsx/.js/.jsx/.json/.css is preserved unless overridden, and longer extensions match first so ".d.ts" wins over ".ts"). The accepted values are deliberately narrowed to the portable BundlerLoader set (js/jsx/ts/tsx/json/css/text/binary/base64/dataurl) — esbuild-specific loaders like file/copy/empty/default are intentionally excluded. file/copy would silently break in this bundler today (they emit secondary output files that get discarded), and anything outside the portable set should go through the plugin escape hatch instead.
    • conditions (package export conditions, e.g. ["workerd", "worker", "browser"])

    The first five are re-typed locally (JsxMode, BundlerLoader) so the published .d.ts does not import from esbuild-wasm — a future bundler swap is a refactor, not a breaking type change.

    For advanced consumers (RSC-style transforms, custom asset pipelines, codegen) there is also an explicit escape hatch:

    __dangerouslyUseEsBuildPluginsDoNotUseOrYouWillBeFired?: unknown[]

    The deliberately unwieldy name is the API contract: this option is not covered by semver, can change shape or be removed in any release, and ties the caller to esbuild's plugin shape — if this package switches bundlers, plugins authored against it will break. It is typed as unknown[] at the public boundary (cast Plugin[] from esbuild-wasm when passing in) so the published types don't acquire a hard dependency on esbuild. User plugins run before the internal virtual-filesystem plugin, so their onResolve/onLoad claims fire first.

    In createApp, all of these options apply to both the server and client bundles.

    The internal bundleWithEsbuild signature was refactored from a long positional argument list to a single options object so future bundler knobs can be added without churning every call site. This is an internal change; no public API moved.

    Inspired by #1321 — thanks @bndkt for the draft and the RSC-on-Workers proof-of-concept that motivated it.

  • #1335 e59388d Thanks @threepointone! - Fix: don't crash with Cannot find package 'gojs' when imported from Node.

    Previously, bundler.ts did a top-level static import esbuildWasm from "./esbuild.wasm". In the Workers runtime that resolves to a WebAssembly.Module natively, but in Node 22+ (e.g. Vitest on GitHub Actions CI) Node's experimental ESM-WASM loader actually parses the file and tries to resolve esbuild-wasm's Go-runtime import namespace gojs as an npm package. That surfaced as the deeply confusing error reported in #1306:

    Cannot find package 'gojs' imported from
    .../@cloudflare/worker-bundler/dist/esbuild.wasm
    

    Two changes:

    • The ./esbuild.wasm import is now lazy — it lives inside initializeEsbuild() as a dynamic import("./esbuild.wasm") call instead of a module-level static import. The package is now safely importable from any JavaScript runtime.
    • Before evaluating that dynamic import, the bundler checks navigator.userAgent === "Cloudflare-Workers". If it's not running inside workerd, it throws an actionable error pointing the caller at @cloudflare/vitest-pool-workers instead of letting Node surface the cryptic gojs resolution failure.

    A side benefit: createWorker({ bundle: false }) (transform-only mode, which never invokes esbuild) now also works in Node, because the WASM is never loaded on that code path.

    The README now also calls out the Workers-only requirement near the top.

    While in there, sharpened a handful of unhelpful error messages to include actionable context:

    • "Entry point/Server entry point/Client entry point ... not found" now lists the user-provided files in the bundle (skipping node_modules/) so it's obvious whether the path is mistyped vs. missing entirely.
    • "Could not determine entry point" now spells out the full priority list it tried (entryPoint option → wrangler mainpackage.json → defaults).
    • npm registry errors include the package name, version, registry URL, and HTTP status text — e.g. Registry returned 404 Not Found for "hno" at https://registry.npmjs.org/hno (package not found — check the name in package.json or set the registry option if it lives on a private registry).
    • The npm fetch-timeout error names the URL and notes the registry was slow/unreachable from the Worker.
    • "Invalid package.json" includes both the path and the underlying parse error.
    • "No output generated from esbuild" now names the entry point and explains the two real-world causes (a custom plugin claiming the entry without returning contents, or the entry resolving to an externalised module).

@cloudflare/think@0.3.0

18 Apr 23:33
3dd0ab5

Choose a tag to compare

Minor Changes

  • #1340 3cbe776 Thanks @threepointone! - Align Think lifecycle hooks with the AI SDK and fix latent bugs around tool-call hooks and extension dispatch.

    Lifecycle hook context types are now derived from the AI SDK (resolves #1339). StepContext, ChunkContext, ToolCallContext, and ToolCallResultContext are derived from StepResult, TextStreamPart, and TypedToolCall so users get full typed access to reasoning, sources, files, providerMetadata (where Anthropic cache tokens live), request/response, etc., instead of unknown. The relevant AI SDK types are re-exported from @cloudflare/think.

    beforeToolCall / afterToolCall now fire with correct timing. beforeToolCall runs before the tool's execute (Think wraps every tool's execute), and afterToolCall runs after with durationMs and a discriminated success/output/error outcome (backed by experimental_onToolCallFinish).

    ToolCallDecision is now functional. Returning { action: "block", reason }, { action: "substitute", output }, or { action: "allow", input } from beforeToolCall actually intercepts execution.

    Extension hook dispatch. ExtensionManifest.hooks claimed support for beforeToolCall/afterToolCall/onStepFinish/onChunk but Think only ever dispatched beforeTurn. All five hooks now dispatch to subscribed extensions with JSON-safe snapshots. Extension hook handlers also receive (snapshot, host) (symmetric with tool execute); previously only tool executes got the host bridge.

    Breaking renames (per AI SDK conventions): ToolCallContext.argsinput, ToolCallResultContext.argsinput, ToolCallResultContext.resultoutput. afterToolCall is now a discriminated union — read output only when ctx.success === true, and error when ctx.success === false. Equivalent renames on ToolCallDecision.

    See docs/think/lifecycle-hooks.md for the full hook reference.

Patch Changes

  • #1340 3cbe776 Thanks @threepointone! - Fix _wrapToolsWithDecision to await originalExecute(...) before checking for Symbol.asyncIterator. The previous code missed Promise<AsyncIterable> returns from plain async functions (async function execute(...) { return makeIter(); }) — Symbol.asyncIterator in promise is always false, the collapse logic was skipped, and the AI SDK ended up treating the iterator instance itself as the final output value (which the wrapper's own comment warned about). Both sync-returned-iterable and async-returned-iterable cases are now covered, with regression tests for each.

@cloudflare/think@0.2.5

18 Apr 18:20
68e2fd6

Choose a tag to compare

Patch Changes

  • #1330 b4d3fcf Thanks @threepointone! - Fix subAgent() cross-DO I/O errors on first use and drop the "experimental" compatibility flag requirement.

    subAgent() cross-DO I/O fix

    Three issues in the facet initialization path caused "Cannot perform I/O on behalf of a different Durable Object" errors when spawning sub-agents in production:

    • subAgent() constructed a Request in the parent DO and passed it to the child via stub.fetch(). The Request carried native I/O tied to the parent isolate, which the child rejected.
    • The facet flag was set after the first onStart() ran, so broadcastMcpServers() fired with _isFacet === false on the initial boot.
    • _broadcastProtocol(), the inherited broadcast(), and _workflow_broadcast() iterated the connection registry without an _isFacet guard, letting broadcasts reach into the parent DO's WebSocket registry from a child isolate.

    Replaces the fetch-based handshake with a new _cf_initAsFacet(name) RPC that runs entirely in the child isolate, sets _isFacet before init, and seeds partyserver's __ps_name key directly. Adds _isFacet guards to _broadcastProtocol() and overrides broadcast() to no-op on facets so downstream callers (chat-streaming paths, workflow broadcasts, user this.broadcast(...)) are covered. Removes the previous internal _cf_markAsFacet() method — _cf_initAsFacet(name) is the correct entry point (it sets the flag before running the first onStart(), which _cf_markAsFacet did not).

    "experimental" compatibility flag no longer required

    ctx.facets, ctx.exports, and env.LOADER (Worker Loader) have graduated out of the "experimental" compatibility flag in workerd. agents and @cloudflare/think no longer require it:

    • subAgent() / abortSubAgent() / deleteSubAgent() — the @experimental JSDoc tag and runtime error messages no longer reference the flag. The runtime guards on ctx.facets / ctx.exports stay in place and now nudge users toward updating compatibility_date instead.
    • Think — the @experimental JSDoc tag no longer references the flag.

    No code change is required; remove "experimental" from your compatibility_flags in wrangler.jsonc if it was only there for these features.

  • #1332 7cb8acf Thanks @threepointone! - Expose createdAt on fiber and chat recovery contexts so apps can suppress continuations for stale, interrupted turns.

    • FiberRecoveryContext (from agents) gains createdAt: number — epoch milliseconds when runFiber started, read from the cf_agents_runs row that was already tracked internally.
    • ChatRecoveryContext (from @cloudflare/ai-chat and @cloudflare/think) gains the same createdAt field, threaded through from the underlying fiber.

    With this, the stale-recovery guard pattern described in #1324 is a short override:

    override async onChatRecovery(ctx: ChatRecoveryContext): Promise<ChatRecoveryOptions> {
      if (Date.now() - ctx.createdAt > 2 * 60 * 1000) return { continue: false };
      return {};
    }

    No behavior change for existing callers. See docs/chat-agents.md (new "Guarding against stale recoveries" section) for the full recipe, including a loop-protection pattern using onChatResponse.

@cloudflare/shell@0.3.3

18 Apr 18:20
68e2fd6

Choose a tag to compare

Patch Changes

  • #1333 dce4d17 Thanks @threepointone! - Workspace is now idempotent on duplicate construction for the same {sql, namespace} when the options that affect durable storage (r2, r2Prefix, inlineThreshold) agree. Previously, any second construction threw Workspace namespace "<ns>" is already registered on this agent, which wedged legitimate cases — most commonly Vite HMR re-evaluating a Durable Object's module against a still-live ctx.storage.sql, and helpers that accept a sql and construct a short-lived Workspace alongside an existing class-field one.

    The guard is preserved where it actually catches a bug: if a second construction passes a different r2, r2Prefix, or inlineThreshold, the constructor throws with a message naming the disagreeing field and both values — because diverging storage options silently route large files to different R2 keys or classify them at different sizes, so reads through one instance would fail to find data written via the other.

    onChange is intentionally not part of the consistency check — each Workspace instance calls its own listener for its own writes, which is the existing per-instance semantic.