Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions vendor/agents/.changeset/unavailable-read-history.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion vendor/agents/docs/fork-diff.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion vendor/agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
197 changes: 197 additions & 0 deletions vendor/agents/packages/think/src/tests/assistant-tools.test.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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" }]);
Expand Down
29 changes: 28 additions & 1 deletion vendor/agents/packages/think/src/tools/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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";
Expand Down
Loading