diff --git a/vendor/agents/.changeset/unavailable-read-history.md b/vendor/agents/.changeset/unavailable-read-history.md new file mode 100644 index 0000000..375f300 --- /dev/null +++ b/vendor/agents/.changeset/unavailable-read-history.md @@ -0,0 +1,7 @@ +--- +"@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. + +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 86f5a0e..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` 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, 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/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..047a67c 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,201 @@ 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] + ])( + "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..ccc74fa 100644 --- a/vendor/agents/packages/think/src/tools/workspace.ts +++ b/vendor/agents/packages/think/src/tools/workspace.ts @@ -436,7 +436,28 @@ 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 { + // 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 { + type: "error-text", + value: `Could not read file bytes: ${input.path}: ${errorMessage(error)}` + }; + } if (bytes === null) { return { type: "error-text", @@ -639,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";