From d238e8dc6858a83a4b68bd565d2abe8d8a00dd54 Mon Sep 17 00:00:00 2001 From: Alex Nahas Date: Sat, 19 Sep 2026 17:51:32 -0700 Subject: [PATCH 1/2] Keep chat history usable when media access is lost Think rereads image and PDF tool results when assembling later model requests. An expired folder grant therefore aborted both ordinary turns and recovery, even when the next message supplied a fresh attachment. Render the filesystem failure as an error tool result while preserving the saved reference so reconnection restores the original media. Exercise AI SDK history conversion with denied PDF and image reads, unchanged saved history, and restored access. Include the existing workspace-tool suite in the SDK gate so this regression remains covered. --- .../.changeset/unavailable-read-history.md | 5 + vendor/agents/docs/fork-diff.md | 2 +- vendor/agents/package.json | 2 +- .../think/src/tests/assistant-tools.test.ts | 96 +++++++++++++++++++ .../packages/think/src/tools/workspace.ts | 12 ++- 5 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 vendor/agents/.changeset/unavailable-read-history.md diff --git a/vendor/agents/.changeset/unavailable-read-history.md b/vendor/agents/.changeset/unavailable-read-history.md new file mode 100644 index 0000000..e350c4f --- /dev/null +++ b/vendor/agents/.changeset/unavailable-read-history.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/think": patch +--- + +Render an explicit tool error when a previously read image or PDF becomes inaccessible, so later turns and recovery can continue. Preserve the saved read result so the file can be rendered again after access is restored. diff --git a/vendor/agents/docs/fork-diff.md b/vendor/agents/docs/fork-diff.md index 86f5a0e..a4b8058 100644 --- a/vendor/agents/docs/fork-diff.md +++ b/vendor/agents/docs/fork-diff.md @@ -101,7 +101,7 @@ upstreamable additions, not claims that Workers need browser polyfills. | Dynamic channels and root routing: Think reconciliation/delivery-surface hooks plus `messengers/chat-sdk.ts`'s resolved target. | Rook root settings/messenger runtime; native messenger tests. | SDK owns channel ingress/recovery; Rook supplies configuration and namespace conversation identity. Retire with equivalent public reconfiguration and routing seams. | | Atomic messenger delivery and completed attachments: Think plus `messengers/{chat-sdk,delivery,index}.ts` bind/chat/restore within one destination RPC and expose `onReplyComplete`. | Rook attachment delivery through the live adapter; native messenger tests. | Retains capability lifetime and completed-stream delivery. Later bookkeeping failure must not send a false interruption apology. Related [#2106](https://github.com/cloudflare/agents/issues/2106) does not establish full recovery-delivery parity. | | Browser messenger leaves: `think/src/messengers/browser/` owns Slack Socket Mode/fetch/thread adaptation and Discord Gateway/parent-thread resolution, shared types and explicit Node-only refusals. | Rook direct imports/aliases; real Chromium Worker/Chat adapter and provider-boundary race tests under `think/src/browser-tests/`. | Optional browser SDK implementation: heartbeat/ACK/resume, retired-socket fencing and failed-upload handling have live protocol consumers. Retire with equivalent browser-native adapter extension points. No matching upstream implementation found. | -| Early model preparation and media: Think `prepareModel`, inference configuration and `tools/workspace.ts` materialize history/tool media for one selected model. | Rook model configuration; model-history-capability and read-tool wire/contract tests. | `beforeTurn` occurs too late to restore bytes excluded during history rendering. Keep the early owner seam until upstream supplies equivalent selection and per-render image/PDF capability controls. | +| Early model preparation and media: Think `prepareModel`, inference configuration and `tools/workspace.ts` select capabilities and report inaccessible image/PDF rereads as tool errors without changing saved history. | Rook model configuration; model-history-capability, read-tool wire/contract and native `assistant-tools.test.ts` history/reconnection tests. | History rendering precedes `beforeTurn`, so a host shim is too late or hides storage errors. Retire with equivalent early model selection, image/PDF capability controls and failed reread handling upstream. | | Inference callback context: `think/src/inference-context.ts` binds admitted-turn model callbacks and tool iterator operations. | Real AI SDK mock-model streams in Think Chromium tests overlap ordinary/streaming tools and cleanup. | Native stream callbacks can enter outside their creator's scope. Compiler-assisted awaits cannot restore missing callback-entry context. Retire only when upstream/native context covers those same entry points. | | Shell OPFS: `shell/src/browser/` implements host-selected directories, symlinks, Web Locks and staged writes; `filesystem.ts`, `extras.ts`, `helpers.ts`, `fs/mime-types.ts` share existing filesystem facts. | Rook shared workspace; real Worker OPFS tests cover failures, cancellation and concurrent writes. | Optional backend, not a second Rook filesystem. Runtime SQL/OPFS alone does not implement Shell's filesystem contract. Retire with a matching upstream backend. | | Shell metadata export and Git scans: package/build export `state-methods.ts`; `shell/src/git/index.ts` uses three `refresh:false` scan options. | Rook workspace connector; real browser Git test checks index bytes and changed-content detection. | Pure metadata avoids loading Codemode. Browser handles lack full POSIX stat identity; scans must not rewrite the index. Retire with equivalent public metadata and configurable/native scan behavior. | diff --git a/vendor/agents/package.json b/vendor/agents/package.json index f42f667..75aaa70 100644 --- a/vendor/agents/package.json +++ b/vendor/agents/package.json @@ -17,7 +17,7 @@ "test:chat": "pnpm --filter agents exec vitest --run -c src/chat/__tests__/vitest.config.ts src/chat/__tests__/agent-tools.test.ts src/chat/__tests__/recovery-incident.test.ts src/chat/__tests__/recovery-engine.test.ts src/chat/__tests__/recovery-cutover.test.ts src/chat/__tests__/recovery-task.test.ts src/chat/__tests__/broadcast-state.test.ts src/chat/__tests__/chat-throttle.test.ts src/chat/__tests__/replay-batch.test.ts src/chat/__tests__/stream-accumulator.test.ts src/chat/__tests__/sanitize.test.ts src/chat/__tests__/pre-stream-turns.test.ts src/chat/__tests__/resume-handshake.test.ts src/chat/__tests__/ws-chat-transport.test.ts --retry=0", "test:agents": "pnpm --filter agents exec vitest --run -c src/tests/vitest.config.ts src/tests/sessions/capability.test.ts src/tests/sessions/compaction-utils.test.ts src/tests/sessions/migration.test.ts src/tests/chat/truncate-older-messages.test.ts src/tests/streams/cutover.test.ts src/tests/resumable-stream-migration.test.ts src/tests/tasks/capability.test.ts src/tests/lifecycle/alarm-arbitration.test.ts src/tests/schedules/capability.test.ts src/tests/schedule.test.ts src/tests/schema-and-state-optimization.test.ts src/tests/state.test.ts src/tests/agent-tool-detached.test.ts src/tests/agent-tool-replay.test.ts src/tests/agent-tool-lifecycle.test.ts src/tests/sub-agent.test.ts src/tests/sub-agent-rpc-bridge.test.ts --retry=0 --fileParallelism=false", "test:ai-chat": "pnpm --filter @cloudflare/ai-chat exec vitest --run -c src/tests/vitest.config.ts src/tests/pre-stream-resume.test.ts src/tests/request-id.test.ts src/tests/resumable-streaming.test.ts src/tests/sanitize-messages.test.ts src/tests/sessions-migration.test.ts src/tests/client-tools-continuation.test.ts src/tests/plaintext-response.test.ts --retry=0 --fileParallelism=false", - "test:think": "pnpm --filter @cloudflare/think exec vitest --run -c src/tests/vitest.config.ts src/tests/agent-tools.test.ts src/tests/agent-tool-reattach-recovery.test.ts src/tests/onconnect-broadcast.test.ts src/tests/think-session.test.ts src/tests/message-reconciliation.test.ts src/tests/submissions.test.ts src/tests/messengers.test.ts src/tests/hooks.test.ts src/tests/client-tools.test.ts src/tests/actions-attach-reply.test.ts src/tests/actions-durable-pause.test.ts src/tests/channels.test.ts src/tests/channel-policy.test.ts src/tests/channel-threading.test.ts src/tests/channel-recovery.test.ts src/tests/connection-state.test.ts src/tests/hydration-budget.test.ts src/tests/stream-cleanup.test.ts src/tests/media-eviction.test.ts --retry=0 --fileParallelism=false", + "test:think": "pnpm --filter @cloudflare/think exec vitest --run -c src/tests/vitest.config.ts src/tests/assistant-tools.test.ts src/tests/agent-tools.test.ts src/tests/agent-tool-reattach-recovery.test.ts src/tests/onconnect-broadcast.test.ts src/tests/think-session.test.ts src/tests/message-reconciliation.test.ts src/tests/submissions.test.ts src/tests/messengers.test.ts src/tests/hooks.test.ts src/tests/client-tools.test.ts src/tests/actions-attach-reply.test.ts src/tests/actions-durable-pause.test.ts src/tests/channels.test.ts src/tests/channel-policy.test.ts src/tests/channel-threading.test.ts src/tests/channel-recovery.test.ts src/tests/connection-state.test.ts src/tests/hydration-budget.test.ts src/tests/stream-cleanup.test.ts src/tests/media-eviction.test.ts --retry=0 --fileParallelism=false", "test:voice": "pnpm --filter agents exec vitest --run -c src/voice/tests/vitest.config.ts src/voice/tests/voice-input.test.ts --retry=0", "test:browser": "pnpm --filter @cloudflare/think build && pnpm --filter @cloudflare/think test:browser && pnpm --filter @cloudflare/shell build && pnpm --filter @cloudflare/shell test:browser", "test:shell": "pnpm --filter @cloudflare/shell exec vitest run src/tests/memory.test.ts src/tests/workspace.test.ts --retry=0 --fileParallelism=false" diff --git a/vendor/agents/packages/think/src/tests/assistant-tools.test.ts b/vendor/agents/packages/think/src/tests/assistant-tools.test.ts index 8111d16..f76511a 100644 --- a/vendor/agents/packages/think/src/tests/assistant-tools.test.ts +++ b/vendor/agents/packages/think/src/tests/assistant-tools.test.ts @@ -1,5 +1,7 @@ import { env } from "cloudflare:workers"; import { describe, expect, it } from "vitest"; +import { convertToModelMessages, type UIMessage } from "ai"; +import { createReadTool } from "../tools/workspace"; import { getAgentByName } from "agents"; async function freshAgent(name: string) { @@ -15,6 +17,100 @@ const PNG_BYTES = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; // ── Read tool ───────────────────────────────────────────────────────── describe("assistant tools — read", () => { + it.each([ + ["report.pdf", "application/pdf", asciiBytes("%PDF-1.4\n")], + ["shot.png", "image/png", PNG_BYTES] + ])( + "keeps history usable when %s becomes inaccessible", + async (name, mimeType, data) => { + const path = `/mounts/cookbooks/${name}`; + const bytes = new Uint8Array(data); + let readable = true; + const reason = + "EACCES: local folder mount /mounts/cookbooks needs to be reconnected in the sidepanel"; + const read = createReadTool({ + ops: { + stat: () => ({ + path, + name, + type: "file", + mimeType, + size: bytes.length, + createdAt: 0, + updatedAt: 0 + }), + readFile: async () => null, + readFileBytes: async () => { + if (!readable) + throw Object.assign(new Error(reason), { code: "EACCES" }); + return bytes; + } + } + }); + const input = { path }; + const output = await read.execute!(input, { + toolCallId: "read-file", + messages: [], + context: {} + }); + const history: UIMessage[] = [ + { + id: "assistant-read", + role: "assistant", + parts: [ + { + type: "tool-read", + toolCallId: "read-file", + state: "output-available", + input, + output + } + ] + }, + { + id: "followup", + role: "user", + parts: [ + { type: "text", text: "Continue with this attachment." }, + { + type: "file", + mediaType: "application/pdf", + filename: "replacement.pdf", + url: `data:application/pdf;base64,${btoa("%PDF-1.4\n")}` + } + ] + } + ]; + const stored = JSON.stringify(history); + const render = () => convertToModelMessages(history, { tools: { read } }); + const original = await render(); + expect(original[1]).toMatchObject({ + role: "tool", + content: [{ output: { type: "content" } }] + }); + + readable = false; + await expect(render()).resolves.toContainEqual({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "read-file", + toolName: "read", + output: { + type: "error-text", + value: `Could not read file bytes: ${path}: ${reason}` + } + } + ] + }); + expect(JSON.stringify(history)).toBe(stored); + + readable = true; + await expect(render()).resolves.toEqual(original); + } + ); + it("reads a file with line numbers", async () => { const agent = await freshAgent("read-basic"); await agent.seed([{ path: "/hello.txt", content: "line1\nline2\nline3" }]); diff --git a/vendor/agents/packages/think/src/tools/workspace.ts b/vendor/agents/packages/think/src/tools/workspace.ts index b84d347..f575b74 100644 --- a/vendor/agents/packages/think/src/tools/workspace.ts +++ b/vendor/agents/packages/think/src/tools/workspace.ts @@ -436,7 +436,17 @@ export function createReadTool(options: ReadToolOptions): Tool { }; } - const bytes = await ops.readFileBytes(input.path); + // History is rendered again for later turns and recovery. A file that + // became inaccessible must not prevent the rest of the chat from running. + let bytes: Uint8Array | null; + try { + bytes = await ops.readFileBytes(input.path); + } catch (error) { + return { + type: "error-text", + value: `Could not read file bytes: ${input.path}: ${errorMessage(error)}` + }; + } if (bytes === null) { return { type: "error-text", From c506afef150dd3b6495a8f4a9c99bbe0caf31224 Mon Sep 17 00:00:00 2001 From: Alex Nahas Date: Sat, 19 Sep 2026 18:02:04 -0700 Subject: [PATCH 2/2] Check media size before allocating its contents The inline media limit was enforced only after readFileBytes, so a hundreds-of-megabytes PDF could exhaust a browser worker before the tool rejected it. Check fresh metadata before loading media and leave oversized untyped files as metadata instead of reading them solely to sniff their format. Keep the post-read check for changing files and prove that a file which grows is rejected without a content read, while a smaller replacement renders from the same saved reference. --- .../.changeset/unavailable-read-history.md | 2 + vendor/agents/docs/fork-diff.md | 2 +- .../think/src/tests/assistant-tools.test.ts | 101 ++++++++++++++++++ .../packages/think/src/tools/workspace.ts | 17 +++ 4 files changed, 121 insertions(+), 1 deletion(-) diff --git a/vendor/agents/.changeset/unavailable-read-history.md b/vendor/agents/.changeset/unavailable-read-history.md index e350c4f..375f300 100644 --- a/vendor/agents/.changeset/unavailable-read-history.md +++ b/vendor/agents/.changeset/unavailable-read-history.md @@ -3,3 +3,5 @@ --- Render an explicit tool error when a previously read image or PDF becomes inaccessible, so later turns and recovery can continue. Preserve the saved read result so the file can be rendered again after access is restored. + +Check current file metadata against the existing inline media limit before reading bytes, avoiding whole-file allocation for oversized PDFs and images. Keep the post-read check for files that change between metadata and content reads. diff --git a/vendor/agents/docs/fork-diff.md b/vendor/agents/docs/fork-diff.md index a4b8058..5710303 100644 --- a/vendor/agents/docs/fork-diff.md +++ b/vendor/agents/docs/fork-diff.md @@ -101,7 +101,7 @@ upstreamable additions, not claims that Workers need browser polyfills. | Dynamic channels and root routing: Think reconciliation/delivery-surface hooks plus `messengers/chat-sdk.ts`'s resolved target. | Rook root settings/messenger runtime; native messenger tests. | SDK owns channel ingress/recovery; Rook supplies configuration and namespace conversation identity. Retire with equivalent public reconfiguration and routing seams. | | Atomic messenger delivery and completed attachments: Think plus `messengers/{chat-sdk,delivery,index}.ts` bind/chat/restore within one destination RPC and expose `onReplyComplete`. | Rook attachment delivery through the live adapter; native messenger tests. | Retains capability lifetime and completed-stream delivery. Later bookkeeping failure must not send a false interruption apology. Related [#2106](https://github.com/cloudflare/agents/issues/2106) does not establish full recovery-delivery parity. | | Browser messenger leaves: `think/src/messengers/browser/` owns Slack Socket Mode/fetch/thread adaptation and Discord Gateway/parent-thread resolution, shared types and explicit Node-only refusals. | Rook direct imports/aliases; real Chromium Worker/Chat adapter and provider-boundary race tests under `think/src/browser-tests/`. | Optional browser SDK implementation: heartbeat/ACK/resume, retired-socket fencing and failed-upload handling have live protocol consumers. Retire with equivalent browser-native adapter extension points. No matching upstream implementation found. | -| Early model preparation and media: Think `prepareModel`, inference configuration and `tools/workspace.ts` select capabilities and report inaccessible image/PDF rereads as tool errors without changing saved history. | Rook model configuration; model-history-capability, read-tool wire/contract and native `assistant-tools.test.ts` history/reconnection tests. | History rendering precedes `beforeTurn`, so a host shim is too late or hides storage errors. Retire with equivalent early model selection, image/PDF capability controls and failed reread handling upstream. | +| Early model preparation and media: Think `prepareModel`, inference configuration and `tools/workspace.ts` select capabilities, reject oversized media before loading bytes, and render inaccessible rereads as tool errors without changing saved history. | Rook model configuration; model-history-capability, read-tool wire/contract and native `assistant-tools.test.ts` history/reconnection and size-preflight tests. | History rendering precedes `beforeTurn`, so a host shim is too late or hides storage errors. Retire with equivalent early model selection, image/PDF capability controls and failed reread handling upstream. | | Inference callback context: `think/src/inference-context.ts` binds admitted-turn model callbacks and tool iterator operations. | Real AI SDK mock-model streams in Think Chromium tests overlap ordinary/streaming tools and cleanup. | Native stream callbacks can enter outside their creator's scope. Compiler-assisted awaits cannot restore missing callback-entry context. Retire only when upstream/native context covers those same entry points. | | Shell OPFS: `shell/src/browser/` implements host-selected directories, symlinks, Web Locks and staged writes; `filesystem.ts`, `extras.ts`, `helpers.ts`, `fs/mime-types.ts` share existing filesystem facts. | Rook shared workspace; real Worker OPFS tests cover failures, cancellation and concurrent writes. | Optional backend, not a second Rook filesystem. Runtime SQL/OPFS alone does not implement Shell's filesystem contract. Retire with a matching upstream backend. | | Shell metadata export and Git scans: package/build export `state-methods.ts`; `shell/src/git/index.ts` uses three `refresh:false` scan options. | Rook workspace connector; real browser Git test checks index bytes and changed-content detection. | Pure metadata avoids loading Codemode. Browser handles lack full POSIX stat identity; scans must not rewrite the index. Retire with equivalent public metadata and configurable/native scan behavior. | diff --git a/vendor/agents/packages/think/src/tests/assistant-tools.test.ts b/vendor/agents/packages/think/src/tests/assistant-tools.test.ts index f76511a..047a67c 100644 --- a/vendor/agents/packages/think/src/tests/assistant-tools.test.ts +++ b/vendor/agents/packages/think/src/tests/assistant-tools.test.ts @@ -17,6 +17,107 @@ const PNG_BYTES = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; // ── Read tool ───────────────────────────────────────────────────────── describe("assistant tools — read", () => { + it("does not load an oversized untyped file just to detect its format", async () => { + const path = "/mounts/cookbooks/untyped.pdf"; + let reads = 0; + const read = createReadTool({ + ops: { + stat: () => ({ + path, + name: "untyped.pdf", + type: "file", + size: 389 * 1024 * 1024, + createdAt: 0, + updatedAt: 0 + }), + readFile: async () => null, + readFileBytes: async () => { + reads++; + return new Uint8Array(asciiBytes("%PDF-1.4\n")); + } + } + }); + await expect( + read.execute!( + { path }, + { toolCallId: "untyped-read", messages: [], context: {} } + ) + ).resolves.toMatchObject({ + kind: "binary", + path, + sizeBytes: 389 * 1024 * 1024, + unsupported: true + }); + expect(reads).toBe(0); + }); + + it.each([ + ["report.pdf", "application/pdf"], + ["shot.png", "image/png"] + ])( + "checks the current size of %s before loading media", + async (name, mimeType) => { + const path = `/mounts/cookbooks/${name}`; + const bytes = new Uint8Array([1, 2, 3]); + let size = bytes.length; + let reads = 0; + const read = createReadTool({ + ops: { + stat: () => ({ + path, + name, + type: "file", + mimeType, + size, + createdAt: 0, + updatedAt: 0 + }), + readFile: async () => null, + readFileBytes: async () => { + reads++; + return bytes; + } + } + }); + const input = { path }; + const output = await read.execute!(input, { + toolCallId: "large-read", + messages: [], + context: {} + }); + const render = () => + read.toModelOutput!({ toolCallId: "large-read", input, output }); + // Saved metadata can be small while the current file has grown. Do not + // allocate a 389 MiB fixture just to prove that its bytes are never read. + size = 389 * 1024 * 1024; + await expect(render()).resolves.toEqual({ + type: "error-text", + value: expect.stringContaining( + "exceeds the 3.5 MB inline model output limit" + ) + }); + expect(reads).toBe(0); + const largeOutput = await read.execute!(input, { + toolCallId: "large-read", + messages: [], + context: {} + }); + expect(reads).toBe(0); + + // Conversely, replacing a large file with a small one must restore access + // without changing its old transcript metadata. + size = bytes.length; + await expect( + read.toModelOutput!({ + toolCallId: "large-read", + input, + output: largeOutput + }) + ).resolves.toMatchObject({ type: "content" }); + expect(reads).toBe(1); + } + ); + it.each([ ["report.pdf", "application/pdf", asciiBytes("%PDF-1.4\n")], ["shot.png", "image/png", PNG_BYTES] diff --git a/vendor/agents/packages/think/src/tools/workspace.ts b/vendor/agents/packages/think/src/tools/workspace.ts index f575b74..ccc74fa 100644 --- a/vendor/agents/packages/think/src/tools/workspace.ts +++ b/vendor/agents/packages/think/src/tools/workspace.ts @@ -440,6 +440,17 @@ export function createReadTool(options: ReadToolOptions): Tool { // became inaccessible must not prevent the rest of the chat from running. let bytes: Uint8Array | null; try { + // Use current metadata: a saved reference may have grown or shrunk. + // Loading hundreds of MB just to reject inline output can kill a host. + const stat = await ops.stat(input.path); + if (stat && stat.size > MAX_MODEL_FILE_BYTES) { + return { + type: "error-text", + value: + `Read ${replayOutput.path} (${replayOutput.mediaType}, ${formatSize(stat.size)}), ` + + `but it exceeds the ${formatSize(MAX_MODEL_FILE_BYTES)} inline model output limit.` + }; + } bytes = await ops.readFileBytes(input.path); } catch (error) { return { @@ -649,6 +660,12 @@ async function detectWorkspaceMediaType({ return statMime; } + // Sniffing uses a whole-file read. Keep oversized untyped files as metadata + // rather than loading them just to learn they cannot be sent inline. + if (stat.size > MAX_MODEL_FILE_BYTES) { + return statMime || "application/octet-stream"; + } + const bytes = await ops.readFileBytes(path); if (bytes === null) { return statMime || "application/octet-stream";