From 5827b53655a521fa38c1262a7993ac9aa9ab1700 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:33:12 +0000 Subject: [PATCH 01/19] computer: Add bounded read formatting Let callers opt into line numbers and byte- or character-based line truncation. Return a byte continuation with truncated pages so the next read starts at the first unread line. --- packages/computer/src/tools/ai.test.ts | 64 ++++++- packages/computer/src/tools/fs/read.ts | 233 +++++++++++++++++-------- packages/computer/src/tools/index.ts | 2 +- 3 files changed, 228 insertions(+), 71 deletions(-) diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index 28a9196d..4fdd0446 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -827,10 +827,70 @@ describe("createAITools filesystem tools", () => { const tool = createReadTool({ store: memoryStore({ content: "abcdef\n" }), maxBytes: 3 }); await expect(executeTool(tool, { path: "/workspace/file.txt" })).resolves.toEqual({ - error: - "Line 1 exceeds the 3-byte read cap. Increase the cap or read a narrower range with offset/limit.", + error: "Line 1 exceeds the 3-byte read cap. Increase the cap or configure lineTruncation.", }); }); + + it("optionally includes line numbers", async () => { + const store = memoryStore({ content: "one\ntwo\n" }); + const plain = createReadTool({ store }); + const numbered = createReadTool({ store, includeLineNumbers: true }); + + await expect(executeTool(plain, { path: "/workspace/file.txt" })).resolves.toMatchObject({ + content: "one\ntwo", + }); + await expect(executeTool(numbered, { path: "/workspace/file.txt" })).resolves.toMatchObject({ + content: "1\tone\n2\ttwo", + }); + }); + + it("truncates long lines by characters or UTF-8 bytes", async () => { + const store = memoryStore({ content: "a😀bc\n" }); + const byChars = createReadTool({ store, lineTruncation: { chars: 2 } }); + const byBytes = createReadTool({ store, lineTruncation: { bytes: 5 } }); + + await expect(executeTool(byChars, { path: "/workspace/file.txt" })).resolves.toMatchObject({ + content: "a😀... (truncated)", + }); + await expect(executeTool(byBytes, { path: "/workspace/file.txt" })).resolves.toMatchObject({ + content: "a😀... (truncated)", + }); + }); + + it("continues from the first unread byte on the next page", async () => { + const content = bytes("first\nsecond\nthird\n"); + const offsets: number[] = []; + const store: FileStore = { + async stat() { + return { size: content.length, mtime: 1 }; + }, + async *readChunks(_path, byteOffset = 0, byteLength) { + offsets.push(byteOffset); + yield content.slice( + byteOffset, + byteLength === undefined ? undefined : byteOffset + byteLength, + ); + }, + async readAll() { + return content; + }, + async write() {}, + }; + const tool = createReadTool({ store }); + const first = (await executeTool(tool, { + path: "/workspace/file.txt", + limit: 1, + })) as { nextOffset: number; nextByteOffset: number }; + await executeTool(tool, { + path: "/workspace/file.txt", + offset: first.nextOffset, + byteOffset: first.nextByteOffset, + limit: 1, + }); + + expect(first).toMatchObject({ nextOffset: 2, nextByteOffset: 6 }); + expect(offsets).toEqual([0, 6]); + }); }); describe("createAITools exec tool", () => { diff --git a/packages/computer/src/tools/fs/read.ts b/packages/computer/src/tools/fs/read.ts index 8e2baf09..beb14f33 100644 --- a/packages/computer/src/tools/fs/read.ts +++ b/packages/computer/src/tools/fs/read.ts @@ -2,16 +2,23 @@ import { type Tool, tool } from "ai"; import { z } from "zod"; import type { FileStore } from "./types.js"; +export type LineTruncation = { bytes: number } | { chars: number }; + export interface ReadToolOptions { store: FileStore; /** Hard line cap. Default 2000. */ maxLines?: number; - /** Hard byte cap. Default 256 KiB. */ + /** Hard output byte cap. Default 256 KiB. */ maxBytes?: number; + /** Prefix each returned line with its 1-indexed line number. Default false. */ + includeLineNumbers?: boolean; + /** Shorten individual lines before applying the output byte cap. */ + lineTruncation?: LineTruncation; } const DEFAULT_MAX_LINES = 2000; const DEFAULT_MAX_BYTES = 256 * 1024; +const TRUNCATION_MARKER = "... (truncated)"; const inputSchema = z.object({ path: z.string().describe("Path to the file to read"), @@ -21,6 +28,14 @@ const inputSchema = z.object({ .min(1) .optional() .describe("Line number to start reading from (1-indexed)"), + byteOffset: z + .number() + .int() + .min(0) + .optional() + .describe( + "Byte continuation returned by a previous read. Pass it with offset to avoid rescanning.", + ), limit: z.number().int().min(1).optional().describe("Maximum number of lines to read"), }); @@ -32,116 +47,140 @@ interface ReadResult { totalLines: number | null; truncated: boolean; nextOffset?: number; + nextByteOffset?: number; } -/** - * Memory-efficient line reader. Pulls chunks lazily through the store's - * `readChunks` iterable; stops the moment the line/byte budget is filled. - * Never materializes the full file unless the file itself fits within the - * budget. - * - * Returns a continuation `nextOffset` whenever output was truncated so the - * model can call `read` again with `offset=nextOffset` to keep going. Total - * line count is reported as `null` when truncation cut us off — counting - * every line in a multi-megabyte file would defeat the streaming approach. - */ - -// Workerd has no Buffer. TextEncoder.encode allocates a Uint8Array per call, -// but it's the only portable byte-length primitive available across Node and -// workers runtimes. -const _enc = new TextEncoder(); -function utf8ByteLength(s: string): number { - return _enc.encode(s).length; +const encoder = new TextEncoder(); +const decoder = new TextDecoder("utf-8", { fatal: false }); + +function utf8ByteLength(value: string): number { + return encoder.encode(value).length; } + export function createReadTool(options: ReadToolOptions): Tool> { const { store } = options; const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + const includeLineNumbers = options.includeLineNumbers ?? false; + const lineTruncation = validateLineTruncation(options.lineTruncation); return tool({ - description: `Read the contents of a file. Output is truncated to ${maxLines} lines or ${Math.round(maxBytes / 1024)}KB, whichever is reached first; use offset/limit to page through large files.`, + description: `Read a text file. Output is capped at ${maxLines} lines or ${Math.round(maxBytes / 1024)}KB. A truncated result includes line and byte continuations for the next page.`, inputSchema, - execute: async ({ path, offset, limit }): Promise => { + execute: async ({ + path, + offset, + byteOffset, + limit, + }): Promise => { const stat = await store.stat(path); if (!stat) return { error: `File not found: ${path}` }; const startLine = offset ?? 1; - const wantedLines = limit ?? maxLines; - const lineCap = Math.min(wantedLines, maxLines); - - const decoder = new TextDecoder("utf-8"); - let carry = ""; // bytes from previous chunk that didn't end on a newline - let currentLine = 1; // 1-indexed line we're about to emit + const startByte = byteOffset ?? 0; + const lineCap = Math.min(limit ?? maxLines, maxLines); + let currentLine = byteOffset === undefined ? 1 : startLine; const collected: string[] = []; let collectedBytes = 0; let firstEmittedLine: number | null = null; let truncatedByBudget = false; let firstLineOverflow = false; + let nextByteOffset: number | undefined; - const processLine = (line: string): boolean => { - // Returns true to keep going, false to stop the outer pump. + const processLine = ( + lineBytes: Uint8Array, + actualBytes: number, + lineStart: number, + ): boolean => { if (currentLine < startLine) { - currentLine++; + currentLine += 1; return true; } - // We're at or past `startLine` — try to emit. - const lineBytes = utf8ByteLength(line); - if (collected.length === 0 && lineBytes > maxBytes) { - firstLineOverflow = true; - return false; - } - // Stop before emitting if this line would push us over either cap. if (collected.length >= lineCap) { truncatedByBudget = true; + nextByteOffset = lineStart; return false; } - if (collectedBytes + lineBytes + (collected.length > 0 ? 1 : 0) > maxBytes) { + + const line = renderLine( + truncateLine(lineBytes, actualBytes, lineTruncation), + currentLine, + includeLineNumbers, + ); + const outputBytes = utf8ByteLength(line) + (collected.length > 0 ? 1 : 0); + if (collected.length === 0 && outputBytes > maxBytes) { + firstLineOverflow = true; + return false; + } + if (collectedBytes + outputBytes > maxBytes) { truncatedByBudget = true; + nextByteOffset = lineStart; return false; } + if (firstEmittedLine === null) firstEmittedLine = currentLine; collected.push(line); - collectedBytes += lineBytes + (collected.length > 1 ? 1 : 0); - currentLine++; + collectedBytes += outputBytes; + currentLine += 1; return true; }; + const keepBytes = bytesToRetain(lineTruncation, maxBytes); + let keptParts: Uint8Array[] = []; + let keptLength = 0; + let actualLength = 0; + let absoluteOffset = startByte; + let lineStart = startByte; let keepGoing = true; - for await (const chunk of store.readChunks(path)) { - if (!keepGoing) break; - carry += decoder.decode(chunk, { stream: true }); - // Process every complete line in `carry`. Keep the final partial line - // for the next iteration. - let nl = carry.indexOf("\n"); - while (nl !== -1) { - const line = carry.slice(0, nl); - carry = carry.slice(nl + 1); - if (!processLine(line)) { + + const append = (part: Uint8Array): void => { + actualLength += part.byteLength; + const available = keepBytes - keptLength; + if (available <= 0) return; + const kept = part.byteLength <= available ? part : part.subarray(0, available); + if (kept.byteLength > 0) { + keptParts.push(kept); + keptLength += kept.byteLength; + } + }; + const finishLine = (): boolean => { + const bytes = joinBytes(keptParts, keptLength); + const result = processLine(bytes, actualLength, lineStart); + keptParts = []; + keptLength = 0; + actualLength = 0; + return result; + }; + + for await (const chunk of store.readChunks(path, startByte)) { + let cursor = 0; + while (cursor < chunk.byteLength) { + const newline = chunk.indexOf(0x0a, cursor); + if (newline === -1) { + append(chunk.subarray(cursor)); + break; + } + append(chunk.subarray(cursor, newline)); + const afterNewline = absoluteOffset + newline + 1; + if (!finishLine()) { keepGoing = false; break; } - nl = carry.indexOf("\n"); - } - } - // Flush the decoder and process any trailing line. - if (keepGoing) { - carry += decoder.decode(); - if (carry.length > 0) { - processLine(carry); - } else if (currentLine === 1 && stat.size === 0) { - // empty file + lineStart = afterNewline; + cursor = newline + 1; } + absoluteOffset += chunk.byteLength; + if (!keepGoing) break; } + if (keepGoing && actualLength > 0) finishLine(); if (firstLineOverflow) { return { - error: `Line ${currentLine} exceeds the ${maxBytes}-byte read cap. Increase the cap or read a narrower range with offset/limit.`, + error: `Line ${currentLine} exceeds the ${maxBytes}-byte read cap. Increase the cap or configure lineTruncation.`, }; } if (firstEmittedLine === null) { - // Either an empty file (totalLines = 0 or 1) or offset past EOF. - // currentLine reflects how many lines we've seen so far. const linesSeen = currentLine - 1; if (stat.size === 0) { return { @@ -161,19 +200,77 @@ export function createReadTool(options: ReadToolOptions): Tool= Math.max(0, bytes.byteLength - 3); end -= 1) { + try { + return fatalDecoder.decode(bytes.subarray(0, end)); + } catch { + // The byte limit split a multibyte character; remove one more byte. + } + } + return decoder.decode(bytes); +} + +function renderLine(line: string, lineNumber: number, includeLineNumbers: boolean): string { + return includeLineNumbers ? `${lineNumber}\t${line}` : line; +} + +function joinBytes(parts: Uint8Array[], length: number): Uint8Array { + if (parts.length === 0) return new Uint8Array(); + if (parts.length === 1) return parts[0]; + const result = new Uint8Array(length); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.byteLength; + } + return result; +} diff --git a/packages/computer/src/tools/index.ts b/packages/computer/src/tools/index.ts index 7e064689..9bad4745 100644 --- a/packages/computer/src/tools/index.ts +++ b/packages/computer/src/tools/index.ts @@ -12,7 +12,7 @@ export { createEditTool, type EditToolOptions } from "./fs/edit.js"; export { createFindTool, type FindToolOptions } from "./fs/find.js"; export { createGrepTool, type GrepToolOptions } from "./fs/grep.js"; export { createListTool, type ListToolOptions } from "./fs/list.js"; -export { createReadTool, type ReadToolOptions } from "./fs/read.js"; +export { createReadTool, type LineTruncation, type ReadToolOptions } from "./fs/read.js"; export { WorkspaceFileStore, type WorkspaceLike } from "./fs/store.js"; export type { FileStat, FileStore, MutableFileStore } from "./fs/types.js"; export { createWriteTool, type WriteToolOptions } from "./fs/write.js"; From 94df95342c5221dea7995c1051bd104fcf1f08ff Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:36:29 +0000 Subject: [PATCH 02/19] computer: Add multimodal reads Classify images and PDFs by extension with a bounded magic-byte fallback, and defer whole-file base64 encoding to the model output hook after enforcing an inline size cap. --- packages/computer/src/tools/ai.test.ts | 71 +++++++++++++ packages/computer/src/tools/fs/media.ts | 130 ++++++++++++++++++++++++ packages/computer/src/tools/fs/read.ts | 122 ++++++++++++++++++++-- 3 files changed, 315 insertions(+), 8 deletions(-) create mode 100644 packages/computer/src/tools/fs/media.ts diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index 4fdd0446..c1e2d1f5 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -29,6 +29,16 @@ async function executeTool(tool: unknown, input: unknown): Promise { return output; } +async function modelOutput(tool: unknown, input: unknown, output: unknown): Promise { + const toModelOutput = ( + tool as { + toModelOutput?: (options: { input: unknown; output: unknown }) => unknown; + } + ).toModelOutput; + if (!toModelOutput) throw new Error("tool has no toModelOutput function"); + return toModelOutput({ input, output }); +} + async function collectTool(tool: unknown, input: unknown): Promise { const execute = (tool as { execute?: (input: unknown, options: typeof toolOptions) => unknown }) .execute; @@ -891,6 +901,67 @@ describe("createAITools filesystem tools", () => { expect(first).toMatchObject({ nextOffset: 2, nextByteOffset: 6 }); expect(offsets).toEqual([0, 6]); }); + + it("returns image extensions as file-data model output", async () => { + const content = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + const store = memoryStore({ size: content.length }); + store.readAll = async () => content; + const tool = createReadTool({ store }); + const output = await executeTool(tool, { path: "/workspace/image.png" }); + + expect(output).toMatchObject({ + kind: "image", + mediaType: "image/png", + sizeBytes: content.length, + }); + await expect(modelOutput(tool, { path: "/workspace/image.png" }, output)).resolves.toEqual({ + type: "content", + value: [ + { type: "text", text: "Read /workspace/image.png (image/png, 4 bytes)." }, + { + type: "file-data", + data: "iVBORw==", + mediaType: "image/png", + filename: "image.png", + }, + ], + }); + }); + + it("sniffs only a bounded prefix for files without a known extension", async () => { + const content = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, ...bytes("body")]); + const ranges: Array<{ offset: number; length: number | undefined }> = []; + const store = memoryStore({ size: content.length }); + store.readChunks = async function* (_path, offset = 0, length) { + ranges.push({ offset, length }); + yield content.slice(offset, length === undefined ? undefined : offset + length); + }; + const tool = createReadTool({ store }); + + await expect(executeTool(tool, { path: "/workspace/upload" })).resolves.toMatchObject({ + kind: "file", + mediaType: "application/pdf", + }); + expect(ranges).toEqual([{ offset: 0, length: 512 }]); + }); + + it("rejects oversized inline media before reading the whole file", async () => { + let readAll = false; + const store = memoryStore({ size: 10 }); + store.readAll = async () => { + readAll = true; + return new Uint8Array(10); + }; + const tool = createReadTool({ store, maxModelBytes: 4 }); + const output = await executeTool(tool, { path: "/workspace/image.png" }); + + await expect(modelOutput(tool, { path: "/workspace/image.png" }, output)).resolves.toEqual({ + type: "error-text", + value: + "Read /workspace/image.png (image/png, 10 bytes), but it exceeds the 4-byte inline model output limit.", + }); + expect(readAll).toBe(false); + }); }); describe("createAITools exec tool", () => { diff --git a/packages/computer/src/tools/fs/media.ts b/packages/computer/src/tools/fs/media.ts new file mode 100644 index 00000000..157bcee3 --- /dev/null +++ b/packages/computer/src/tools/fs/media.ts @@ -0,0 +1,130 @@ +import type { FileStore } from "./types.js"; + +export type DetectedMedia = + | { kind: "image"; mediaType: string } + | { kind: "file"; mediaType: "application/pdf" } + | { kind: "binary"; mediaType: string } + | { kind: "text"; mediaType: string }; + +const EXTENSIONS = new Map([ + [".png", { kind: "image", mediaType: "image/png" }], + [".jpg", { kind: "image", mediaType: "image/jpeg" }], + [".jpeg", { kind: "image", mediaType: "image/jpeg" }], + [".gif", { kind: "image", mediaType: "image/gif" }], + [".webp", { kind: "image", mediaType: "image/webp" }], + [".svg", { kind: "image", mediaType: "image/svg+xml" }], + [".pdf", { kind: "file", mediaType: "application/pdf" }], +]); + +const TEXT_EXTENSIONS = new Set([ + ".c", + ".cc", + ".cpp", + ".css", + ".csv", + ".go", + ".h", + ".html", + ".java", + ".js", + ".json", + ".jsonc", + ".jsx", + ".md", + ".mjs", + ".py", + ".rs", + ".sh", + ".toml", + ".ts", + ".tsx", + ".txt", + ".xml", + ".yaml", + ".yml", + ".zig", +]); + +export async function detectMedia( + store: FileStore, + path: string, + sniffBytes: number, +): Promise { + const extension = extensionOf(path); + const known = EXTENSIONS.get(extension); + if (known !== undefined) return known; + if (TEXT_EXTENSIONS.has(extension)) return { kind: "text", mediaType: "text/plain" }; + + const prefix = await readPrefix(store, path, sniffBytes); + if (startsWith(prefix, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { + return { kind: "image", mediaType: "image/png" }; + } + if (startsWith(prefix, [0xff, 0xd8, 0xff])) { + return { kind: "image", mediaType: "image/jpeg" }; + } + if (startsWithAscii(prefix, "GIF87a") || startsWithAscii(prefix, "GIF89a")) { + return { kind: "image", mediaType: "image/gif" }; + } + if (startsWithAscii(prefix, "RIFF") && asciiAt(prefix, 8, 12) === "WEBP") { + return { kind: "image", mediaType: "image/webp" }; + } + if (startsWithAscii(prefix, "%PDF-")) { + return { kind: "file", mediaType: "application/pdf" }; + } + if (looksLikeSvg(prefix)) return { kind: "image", mediaType: "image/svg+xml" }; + if (looksLikeText(prefix)) return { kind: "text", mediaType: "text/plain" }; + return { kind: "binary", mediaType: "application/octet-stream" }; +} + +async function readPrefix(store: FileStore, path: string, length: number): Promise { + const parts: Uint8Array[] = []; + let total = 0; + for await (const chunk of store.readChunks(path, 0, length)) { + parts.push(chunk); + total += chunk.byteLength; + } + if (parts.length === 0) return new Uint8Array(); + if (parts.length === 1) return parts[0]; + const result = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.byteLength; + } + return result; +} + +function extensionOf(path: string): string { + const name = path.slice(path.lastIndexOf("/") + 1).toLowerCase(); + const dot = name.lastIndexOf("."); + return dot <= 0 ? "" : name.slice(dot); +} + +function startsWith(bytes: Uint8Array, prefix: number[]): boolean { + return bytes.length >= prefix.length && prefix.every((byte, index) => bytes[index] === byte); +} + +function startsWithAscii(bytes: Uint8Array, prefix: string): boolean { + return asciiAt(bytes, 0, prefix.length) === prefix; +} + +function asciiAt(bytes: Uint8Array, start: number, end: number): string { + return String.fromCharCode(...bytes.subarray(start, end)); +} + +function looksLikeSvg(bytes: Uint8Array): boolean { + const prefix = new TextDecoder().decode(bytes).trimStart().toLowerCase(); + return prefix.startsWith(" => { + execute: async ({ path, offset, byteOffset, limit }): Promise => { const stat = await store.stat(path); if (!stat) return { error: `File not found: ${path}` }; + const media = await detectMedia(store, path, mediaSniffBytes); + if (media.kind !== "text") { + return { + kind: media.kind, + path, + name: basename(path), + mediaType: media.mediaType, + sizeBytes: stat.size, + ...(media.kind === "binary" ? { unsupported: true as const } : {}), + }; + } + const startLine = offset ?? 1; const startByte = byteOffset ?? 0; const lineCap = Math.min(limit ?? maxLines, maxLines); @@ -214,6 +241,44 @@ export function createReadTool(options: ReadToolOptions): Tool { + if (!isRecord(output)) return { type: "text", value: String(output) }; + if (typeof output.error === "string") { + return { type: "error-text", value: output.error }; + } + if (typeof output.content === "string") { + return { type: "text", value: output.content }; + } + if (output.kind === "binary") return { type: "json", value: toJSONValue(output) }; + if (!isMediaReadResult(output) || !isReadInput(input)) { + return { type: "json", value: toJSONValue(output) }; + } + if (output.sizeBytes > maxModelBytes) { + return { + type: "error-text", + value: `Read ${output.path} (${output.mediaType}, ${output.sizeBytes} bytes), but it exceeds the ${maxModelBytes}-byte inline model output limit.`, + }; + } + const bytes = await store.readAll(input.path); + if (bytes === null) { + return { type: "error-text", value: `Could not read file bytes: ${input.path}` }; + } + return { + type: "content", + value: [ + { + type: "text", + text: `Read ${output.path} (${output.mediaType}, ${bytes.byteLength} bytes).`, + }, + { + type: "file-data", + data: uint8ArrayToBase64(bytes), + mediaType: output.mediaType, + filename: output.name, + }, + ], + }; + }, }); } @@ -259,6 +324,47 @@ function decodeUtf8Prefix(bytes: Uint8Array): string { return decoder.decode(bytes); } +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isMediaReadResult(value: unknown): value is MediaReadResult { + return ( + isRecord(value) && + (value.kind === "image" || value.kind === "file") && + typeof value.path === "string" && + typeof value.name === "string" && + typeof value.mediaType === "string" && + typeof value.sizeBytes === "number" + ); +} + +function toJSONValue(value: unknown): JSONValue { + try { + const json = JSON.stringify(value); + return json === undefined ? null : (JSON.parse(json) as JSONValue); + } catch { + return String(value); + } +} + +function isReadInput(value: unknown): value is { path: string } { + return isRecord(value) && typeof value.path === "string"; +} + +function basename(path: string): string { + return path.slice(path.lastIndexOf("/") + 1); +} + +function uint8ArrayToBase64(bytes: Uint8Array): string { + let binary = ""; + const chunkSize = 0x8000; + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + } + return btoa(binary); +} + function renderLine(line: string, lineNumber: number, includeLineNumbers: boolean): string { return includeLineNumbers ? `${lineNumber}\t${line}` : line; } From 9d7cefcfed121b27f518f80241c9065fd9bad801 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:52:44 +0000 Subject: [PATCH 03/19] computer: Recheck multimodal read limits Restat media immediately before loading it and verify the loaded byte count so a concurrent file replacement cannot bypass the inline model output cap. --- packages/computer/src/tools/ai.test.ts | 13 +++++++++++++ packages/computer/src/tools/fs/read.ts | 26 ++++++++++++++++++++++---- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index c1e2d1f5..6986b4ea 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -962,6 +962,19 @@ describe("createAITools filesystem tools", () => { }); expect(readAll).toBe(false); }); + + it("rechecks the inline media cap after reading a concurrently changed file", async () => { + const store = memoryStore({ size: 2 }); + store.readAll = async () => new Uint8Array(10); + const tool = createReadTool({ store, maxModelBytes: 4 }); + const output = await executeTool(tool, { path: "/workspace/image.png" }); + + await expect(modelOutput(tool, { path: "/workspace/image.png" }, output)).resolves.toEqual({ + type: "error-text", + value: + "Read /workspace/image.png (image/png, 10 bytes), but it exceeds the 4-byte inline model output limit.", + }); + }); }); describe("createAITools exec tool", () => { diff --git a/packages/computer/src/tools/fs/read.ts b/packages/computer/src/tools/fs/read.ts index 9f743527..f5d36346 100644 --- a/packages/computer/src/tools/fs/read.ts +++ b/packages/computer/src/tools/fs/read.ts @@ -254,15 +254,22 @@ export function createReadTool(options: ReadToolOptions): Tool maxModelBytes) { - return { - type: "error-text", - value: `Read ${output.path} (${output.mediaType}, ${output.sizeBytes} bytes), but it exceeds the ${maxModelBytes}-byte inline model output limit.`, - }; + return inlineMediaLimitError(output, output.sizeBytes, maxModelBytes); + } + const currentStat = await store.stat(input.path); + if (currentStat === null) { + return { type: "error-text", value: `Could not read file bytes: ${input.path}` }; + } + if (currentStat.size > maxModelBytes) { + return inlineMediaLimitError(output, currentStat.size, maxModelBytes); } const bytes = await store.readAll(input.path); if (bytes === null) { return { type: "error-text", value: `Could not read file bytes: ${input.path}` }; } + if (bytes.byteLength > maxModelBytes) { + return inlineMediaLimitError(output, bytes.byteLength, maxModelBytes); + } return { type: "content", value: [ @@ -339,6 +346,17 @@ function isMediaReadResult(value: unknown): value is MediaReadResult { ); } +function inlineMediaLimitError( + output: MediaReadResult, + sizeBytes: number, + maxModelBytes: number, +): { type: "error-text"; value: string } { + return { + type: "error-text", + value: `Read ${output.path} (${output.mediaType}, ${sizeBytes} bytes), but it exceeds the ${maxModelBytes}-byte inline model output limit.`, + }; +} + function toJSONValue(value: unknown): JSONValue { try { const json = JSON.stringify(value); From 452823fe4e3035973d1214872cc4a26fb263b1dd Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:54:00 +0000 Subject: [PATCH 04/19] computer: Stop reads at the line cap Return the next byte continuation as soon as the requested line page is complete so a long following line does not force unnecessary ranged reads. --- packages/computer/src/tools/ai.test.ts | 32 ++++++++++++++++++++++++++ packages/computer/src/tools/fs/read.ts | 6 +++++ 2 files changed, 38 insertions(+) diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index 6986b4ea..9703b197 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -902,6 +902,38 @@ describe("createAITools filesystem tools", () => { expect(offsets).toEqual([0, 6]); }); + it("stops pulling chunks as soon as the line cap is complete", async () => { + const chunks = [bytes("first\nsecond"), bytes(" line continues"), bytes(" to the end")]; + const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); + let chunksRead = 0; + const store: FileStore = { + async stat() { + return { size, mtime: 1 }; + }, + async *readChunks() { + for (const chunk of chunks) { + chunksRead += 1; + yield chunk; + } + }, + async readAll() { + return null; + }, + async write() {}, + }; + const tool = createReadTool({ store }); + + await expect( + executeTool(tool, { path: "/workspace/file.txt", limit: 1 }), + ).resolves.toMatchObject({ + content: "first", + truncated: true, + nextOffset: 2, + nextByteOffset: 6, + }); + expect(chunksRead).toBe(1); + }); + it("returns image extensions as file-data model output", async () => { const content = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); const store = memoryStore({ size: content.length }); diff --git a/packages/computer/src/tools/fs/read.ts b/packages/computer/src/tools/fs/read.ts index f5d36346..a2e1ffa4 100644 --- a/packages/computer/src/tools/fs/read.ts +++ b/packages/computer/src/tools/fs/read.ts @@ -195,6 +195,12 @@ export function createReadTool(options: ReadToolOptions): Tool= lineCap && afterNewline < stat.size) { + truncatedByBudget = true; + nextByteOffset = afterNewline; + keepGoing = false; + break; + } } absoluteOffset += chunk.byteLength; if (!keepGoing) break; From ec0d3f68b32c4d6171e0dcc1ecc9680620f538f7 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:25:13 +0000 Subject: [PATCH 05/19] computer: Add multimodal read changeset Record line formatting, byte continuations, and bounded media output with the Computer package that exposes them. --- .changeset/computer-multimodal-reads.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/computer-multimodal-reads.md diff --git a/.changeset/computer-multimodal-reads.md b/.changeset/computer-multimodal-reads.md new file mode 100644 index 00000000..22d14f0a --- /dev/null +++ b/.changeset/computer-multimodal-reads.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": minor +--- + +Add line formatting and byte continuations to `read`, with bounded image and PDF model output. From 11bfcb163f49e59e6143e081d7041e9e07db0ab5 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:24:00 +0000 Subject: [PATCH 06/19] computer: Bound inline media reads --- packages/computer/src/tools/ai.test.ts | 18 +++++++++++--- packages/computer/src/tools/fs/read.ts | 33 ++++++++++++++++++++++---- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index 9703b197..3724adb2 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -938,6 +938,9 @@ describe("createAITools filesystem tools", () => { const content = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); const store = memoryStore({ size: content.length }); store.readAll = async () => content; + store.readChunks = async function* (_path, offset = 0, length) { + yield content.slice(offset, length === undefined ? undefined : offset + length); + }; const tool = createReadTool({ store }); const output = await executeTool(tool, { path: "/workspace/image.png" }); @@ -995,17 +998,26 @@ describe("createAITools filesystem tools", () => { expect(readAll).toBe(false); }); - it("rechecks the inline media cap after reading a concurrently changed file", async () => { + it("bounds the inline read when media grows after the size check", async () => { + const content = new Uint8Array(10); + const ranges: Array<{ offset: number; length: number | undefined }> = []; const store = memoryStore({ size: 2 }); - store.readAll = async () => new Uint8Array(10); + store.readAll = async () => { + throw new Error("inline media must not use readAll"); + }; + store.readChunks = async function* (_path, offset = 0, length) { + ranges.push({ offset, length }); + yield content.slice(offset, length === undefined ? undefined : offset + length); + }; const tool = createReadTool({ store, maxModelBytes: 4 }); const output = await executeTool(tool, { path: "/workspace/image.png" }); await expect(modelOutput(tool, { path: "/workspace/image.png" }, output)).resolves.toEqual({ type: "error-text", value: - "Read /workspace/image.png (image/png, 10 bytes), but it exceeds the 4-byte inline model output limit.", + "Read /workspace/image.png (image/png, 5 bytes), but it exceeds the 4-byte inline model output limit.", }); + expect(ranges).toEqual([{ offset: 0, length: 5 }]); }); }); diff --git a/packages/computer/src/tools/fs/read.ts b/packages/computer/src/tools/fs/read.ts index a2e1ffa4..16ec49a6 100644 --- a/packages/computer/src/tools/fs/read.ts +++ b/packages/computer/src/tools/fs/read.ts @@ -81,7 +81,10 @@ export function createReadTool(options: ReadToolOptions): Tool maxModelBytes) { return inlineMediaLimitError(output, currentStat.size, maxModelBytes); } - const bytes = await store.readAll(input.path); - if (bytes === null) { - return { type: "error-text", value: `Could not read file bytes: ${input.path}` }; - } + const bytes = await readBounded(store, input.path, maxModelBytes + 1); if (bytes.byteLength > maxModelBytes) { return inlineMediaLimitError(output, bytes.byteLength, maxModelBytes); } @@ -295,6 +295,29 @@ export function createReadTool(options: ReadToolOptions): Tool { + const parts: Uint8Array[] = []; + let total = 0; + for await (const chunk of store.readChunks(path, 0, limit)) { + const remaining = limit - total; + if (remaining <= 0) break; + const part = chunk.byteLength <= remaining ? chunk : chunk.subarray(0, remaining); + if (part.byteLength > 0) { + parts.push(part); + total += part.byteLength; + } + if (total >= limit) break; + } + return joinBytes(parts, total); +} + function validateLineTruncation(value: LineTruncation | undefined): LineTruncation | undefined { if (value === undefined) return undefined; const amount = "bytes" in value ? value.bytes : value.chars; From 83a0211b5a3beae64b89600a9698089f05f49aab Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:08:43 +0000 Subject: [PATCH 07/19] computer: Preserve read continuations --- packages/computer/src/tools/ai.test.ts | 14 ++++++++++++++ packages/computer/src/tools/fs/read.ts | 4 +++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index 3724adb2..171f8f0c 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -902,6 +902,20 @@ describe("createAITools filesystem tools", () => { expect(offsets).toEqual([0, 6]); }); + it("keeps text continuations in truncated model output", async () => { + const tool = createReadTool({ store: memoryStore({ content: "first\nsecond\n" }) }); + const truncated = await executeTool(tool, { path: "/workspace/file.txt", limit: 1 }); + const complete = await executeTool(tool, { path: "/workspace/file.txt" }); + + await expect( + modelOutput(tool, { path: "/workspace/file.txt", limit: 1 }, truncated), + ).resolves.toEqual({ type: "json", value: truncated }); + await expect(modelOutput(tool, { path: "/workspace/file.txt" }, complete)).resolves.toEqual({ + type: "text", + value: "first\nsecond", + }); + }); + it("stops pulling chunks as soon as the line cap is complete", async () => { const chunks = [bytes("first\nsecond"), bytes(" line continues"), bytes(" to the end")]; const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); diff --git a/packages/computer/src/tools/fs/read.ts b/packages/computer/src/tools/fs/read.ts index 16ec49a6..4cdd3898 100644 --- a/packages/computer/src/tools/fs/read.ts +++ b/packages/computer/src/tools/fs/read.ts @@ -256,7 +256,9 @@ export function createReadTool(options: ReadToolOptions): Tool Date: Sat, 8 Aug 2026 20:09:34 +0000 Subject: [PATCH 08/19] computer: Tighten SVG media sniffing --- packages/computer/src/tools/ai.test.ts | 24 ++++++++++++++++++++++++ packages/computer/src/tools/fs/media.ts | 17 +++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index 171f8f0c..4c22fbdc 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -994,6 +994,30 @@ describe("createAITools filesystem tools", () => { expect(ranges).toEqual([{ offset: 0, length: 512 }]); }); + it("sniffs SVG only when it is the root element", async () => { + for (const content of [ + '', + '\n', + "\n", + ]) { + const tool = createReadTool({ store: memoryStore({ content }) }); + await expect(executeTool(tool, { path: "/workspace/upload" })).resolves.toMatchObject({ + kind: "image", + mediaType: "image/svg+xml", + }); + } + + for (const content of [ + "const markup = '';", + "", + "not an svg root", + ]) { + const tool = createReadTool({ store: memoryStore({ content }) }); + const output = await executeTool(tool, { path: "/workspace/upload" }); + expect(output).toMatchObject({ content, truncated: false }); + } + }); + it("rejects oversized inline media before reading the whole file", async () => { let readAll = false; const store = memoryStore({ size: 10 }); diff --git a/packages/computer/src/tools/fs/media.ts b/packages/computer/src/tools/fs/media.ts index 157bcee3..1689e858 100644 --- a/packages/computer/src/tools/fs/media.ts +++ b/packages/computer/src/tools/fs/media.ts @@ -113,8 +113,21 @@ function asciiAt(bytes: Uint8Array, start: number, end: number): string { } function looksLikeSvg(bytes: Uint8Array): boolean { - const prefix = new TextDecoder().decode(bytes).trimStart().toLowerCase(); - return prefix.startsWith(""); + if (declarationEnd === -1) return false; + prefix = prefix.slice(declarationEnd + 2).trimStart(); + } + + while (prefix.startsWith(""); + if (commentEnd === -1) return false; + prefix = prefix.slice(commentEnd + 3).trimStart(); + } + + return /^)/i.test(prefix); } function looksLikeText(bytes: Uint8Array): boolean { From 04d6a6652962aeaa24067a68433582673610e63b Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:19:06 +0000 Subject: [PATCH 09/19] computer: Handle missing inline media --- packages/computer/src/tools/ai.test.ts | 36 ++++++++++++++++++++++++++ packages/computer/src/tools/fs/read.ts | 19 +++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index 4c22fbdc..da434ebb 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -1057,6 +1057,42 @@ describe("createAITools filesystem tools", () => { }); expect(ranges).toEqual([{ offset: 0, length: 5 }]); }); + + it("reports inline media deleted after the size check", async () => { + const store = memoryStore({ size: 2 }); + store.readChunks = () => ({ + [Symbol.asyncIterator]() { + return this; + }, + async next(): Promise> { + throw Object.assign(new Error("no such file"), { code: "ENOENT" }); + }, + }); + const tool = createReadTool({ store, maxModelBytes: 4 }); + const output = await executeTool(tool, { path: "/workspace/image.png" }); + + await expect(modelOutput(tool, { path: "/workspace/image.png" }, output)).resolves.toEqual({ + type: "error-text", + value: "Could not read file bytes: /workspace/image.png", + }); + }); + + it("does not hide unrelated inline media read failures", async () => { + const failure = new Error("storage unavailable"); + const store = memoryStore({ size: 2 }); + store.readChunks = () => ({ + [Symbol.asyncIterator]() { + return this; + }, + async next(): Promise> { + throw failure; + }, + }); + const tool = createReadTool({ store, maxModelBytes: 4 }); + const output = await executeTool(tool, { path: "/workspace/image.png" }); + + await expect(modelOutput(tool, { path: "/workspace/image.png" }, output)).rejects.toBe(failure); + }); }); describe("createAITools exec tool", () => { diff --git a/packages/computer/src/tools/fs/read.ts b/packages/computer/src/tools/fs/read.ts index 4cdd3898..4ec4dbbb 100644 --- a/packages/computer/src/tools/fs/read.ts +++ b/packages/computer/src/tools/fs/read.ts @@ -274,7 +274,15 @@ export function createReadTool(options: ReadToolOptions): Tool maxModelBytes) { return inlineMediaLimitError(output, currentStat.size, maxModelBytes); } - const bytes = await readBounded(store, input.path, maxModelBytes + 1); + let bytes: Uint8Array; + try { + bytes = await readBounded(store, input.path, maxModelBytes + 1); + } catch (error) { + if (isMissingFileError(error)) { + return { type: "error-text", value: `Could not read file bytes: ${input.path}` }; + } + throw error; + } if (bytes.byteLength > maxModelBytes) { return inlineMediaLimitError(output, bytes.byteLength, maxModelBytes); } @@ -304,6 +312,15 @@ function validateBoundedReadLimit(name: string, value: number): number { return value; } +function isMissingFileError(error: unknown): boolean { + return ( + error !== null && + typeof error === "object" && + "code" in error && + (error as { code?: unknown }).code === "ENOENT" + ); +} + async function readBounded(store: FileStore, path: string, limit: number): Promise { const parts: Uint8Array[] = []; let total = 0; From 95498f275a15056b9aeecc805f3f6e931befbb88 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:19:56 +0000 Subject: [PATCH 10/19] computer: Parse SVG XML prologs --- packages/computer/src/tools/ai.test.ts | 4 ++ packages/computer/src/tools/fs/media.ts | 83 ++++++++++++++++++++++--- 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index da434ebb..3a2ab6e5 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -998,6 +998,10 @@ describe("createAITools filesystem tools", () => { for (const content of [ '', '\n', + '\n', + '\n', + '\n', + '">]>\n', "\n", ]) { const tool = createReadTool({ store: memoryStore({ content }) }); diff --git a/packages/computer/src/tools/fs/media.ts b/packages/computer/src/tools/fs/media.ts index 1689e858..e761355c 100644 --- a/packages/computer/src/tools/fs/media.ts +++ b/packages/computer/src/tools/fs/media.ts @@ -113,21 +113,84 @@ function asciiAt(bytes: Uint8Array, start: number, end: number): string { } function looksLikeSvg(bytes: Uint8Array): boolean { - let prefix = new TextDecoder().decode(bytes).trimStart(); + const prefix = new TextDecoder().decode(bytes); + let cursor = skipWhitespace(prefix, 0); - if (prefix.startsWith(""); - if (declarationEnd === -1) return false; - prefix = prefix.slice(declarationEnd + 2).trimStart(); + while (cursor < prefix.length) { + if (prefix.startsWith("", cursor + 4); + if (end === -1) return false; + cursor = skipWhitespace(prefix, end + 3); + continue; + } + if (prefix.startsWith("", cursor + 2); + if (end === -1) return false; + cursor = skipWhitespace(prefix, end + 2); + continue; + } + const doctypeEnd = consumeSvgDoctype(prefix, cursor); + if (doctypeEnd !== null) { + cursor = skipWhitespace(prefix, doctypeEnd); + continue; + } + break; } - while (prefix.startsWith(""); - if (commentEnd === -1) return false; - prefix = prefix.slice(commentEnd + 3).trimStart(); + return /^)/i.test(prefix.slice(cursor)); +} + +function consumeSvgDoctype(value: string, start: number): number | null { + const keyword = "") return null; + + let quote: '"' | "'" | undefined; + let subsetDepth = 0; + while (cursor < value.length) { + const character = value[cursor]; + if (quote !== undefined) { + if (character === quote) quote = undefined; + cursor += 1; + continue; + } + if (character === '"' || character === "'") { + quote = character; + cursor += 1; + continue; + } + if (value.startsWith("", cursor + 4); + if (end === -1) return null; + cursor = end + 3; + continue; + } + if (character === "[") { + subsetDepth += 1; + } else if (character === "]" && subsetDepth > 0) { + subsetDepth -= 1; + } else if (character === ">" && subsetDepth === 0) { + return cursor + 1; + } + cursor += 1; } + return null; +} + +function skipWhitespace(value: string, start: number): number { + let cursor = start; + while (isWhitespace(value[cursor])) cursor += 1; + return cursor; +} - return /^)/i.test(prefix); +function isWhitespace(value: string | undefined): boolean { + return value !== undefined && /\s/.test(value); } function looksLikeText(bytes: Uint8Array): boolean { From f15015f975818212dce92b798c66e529ea3b03ac Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:25:50 +0000 Subject: [PATCH 11/19] computer: Treat zero byte offsets as initial reads --- packages/computer/src/tools/ai.test.ts | 19 +++++++++++++++++++ packages/computer/src/tools/fs/read.ts | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index 3a2ab6e5..7cd9044e 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -902,6 +902,25 @@ describe("createAITools filesystem tools", () => { expect(offsets).toEqual([0, 6]); }); + it("treats a zero byte offset as the start of the file", async () => { + const tool = createReadTool({ store: memoryStore({ content: "first\nsecond\nthird\n" }) }); + + await expect( + executeTool(tool, { + path: "/workspace/file.txt", + offset: 2, + byteOffset: 0, + limit: 1, + }), + ).resolves.toMatchObject({ + content: "second", + startLine: 2, + endLine: 2, + nextOffset: 3, + nextByteOffset: 13, + }); + }); + it("keeps text continuations in truncated model output", async () => { const tool = createReadTool({ store: memoryStore({ content: "first\nsecond\n" }) }); const truncated = await executeTool(tool, { path: "/workspace/file.txt", limit: 1 }); diff --git a/packages/computer/src/tools/fs/read.ts b/packages/computer/src/tools/fs/read.ts index 4ec4dbbb..1a05d323 100644 --- a/packages/computer/src/tools/fs/read.ts +++ b/packages/computer/src/tools/fs/read.ts @@ -109,7 +109,7 @@ export function createReadTool(options: ReadToolOptions): Tool Date: Mon, 10 Aug 2026 12:26:46 +0000 Subject: [PATCH 12/19] computer: Recognize message-only missing media --- packages/computer/src/tools/ai.test.ts | 36 ++++++++++++++++++++++++++ packages/computer/src/tools/fs/read.ts | 9 ++++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index 7cd9044e..3cf4e545 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -1100,6 +1100,42 @@ describe("createAITools filesystem tools", () => { }); }); + it("recognizes message-only missing media errors", async () => { + const store = memoryStore({ size: 2 }); + store.readChunks = () => ({ + [Symbol.asyncIterator]() { + return this; + }, + async next(): Promise> { + throw new Error("ENOENT: no such file or directory"); + }, + }); + const tool = createReadTool({ store, maxModelBytes: 4 }); + const output = await executeTool(tool, { path: "/workspace/image.png" }); + + await expect(modelOutput(tool, { path: "/workspace/image.png" }, output)).resolves.toEqual({ + type: "error-text", + value: "Could not read file bytes: /workspace/image.png", + }); + }); + + it("does not confuse unrelated no-such errors with missing media", async () => { + const failure = new Error("SQLITE_ERROR: no such table: vfs_chunks"); + const store = memoryStore({ size: 2 }); + store.readChunks = () => ({ + [Symbol.asyncIterator]() { + return this; + }, + async next(): Promise> { + throw failure; + }, + }); + const tool = createReadTool({ store, maxModelBytes: 4 }); + const output = await executeTool(tool, { path: "/workspace/image.png" }); + + await expect(modelOutput(tool, { path: "/workspace/image.png" }, output)).rejects.toBe(failure); + }); + it("does not hide unrelated inline media read failures", async () => { const failure = new Error("storage unavailable"); const store = memoryStore({ size: 2 }); diff --git a/packages/computer/src/tools/fs/read.ts b/packages/computer/src/tools/fs/read.ts index 1a05d323..e015010e 100644 --- a/packages/computer/src/tools/fs/read.ts +++ b/packages/computer/src/tools/fs/read.ts @@ -313,11 +313,12 @@ function validateBoundedReadLimit(name: string, value: number): number { } function isMissingFileError(error: unknown): boolean { + if (error === null || typeof error !== "object") return false; + const candidate = error as { code?: unknown; message?: unknown }; + if (candidate.code === "ENOENT") return true; return ( - error !== null && - typeof error === "object" && - "code" in error && - (error as { code?: unknown }).code === "ENOENT" + typeof candidate.message === "string" && + (/\bENOENT\b/i.test(candidate.message) || /no such (?:file|path)\b/i.test(candidate.message)) ); } From e347988bfb300b21457acda3b93025f6187c5bab Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:28:18 +0000 Subject: [PATCH 13/19] computer: Preserve text read positions --- packages/computer/src/tools/ai.test.ts | 28 +++++++++++ packages/computer/src/tools/fs/read.ts | 69 +++++++++++++++++--------- 2 files changed, 74 insertions(+), 23 deletions(-) diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index 3cf4e545..d30f0f3d 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -902,6 +902,16 @@ describe("createAITools filesystem tools", () => { expect(offsets).toEqual([0, 6]); }); + it("rejects a positive byte continuation without its line continuation", async () => { + const tool = createReadTool({ store: memoryStore({ content: "first\nsecond\n" }) }); + + await expect( + executeTool(tool, { path: "/workspace/file.txt", byteOffset: 6 }), + ).resolves.toEqual({ + error: "offset is required when byteOffset is greater than zero", + }); + }); + it("treats a zero byte offset as the start of the file", async () => { const tool = createReadTool({ store: memoryStore({ content: "first\nsecond\nthird\n" }) }); @@ -935,6 +945,24 @@ describe("createAITools filesystem tools", () => { }); }); + it("keeps empty and positioned complete reads as structured model output", async () => { + const emptyTool = createReadTool({ store: memoryStore({ content: "" }) }); + const empty = await executeTool(emptyTool, { path: "/workspace/empty" }); + await expect(modelOutput(emptyTool, { path: "/workspace/empty" }, empty)).resolves.toEqual({ + type: "json", + value: empty, + }); + + const positionedTool = createReadTool({ store: memoryStore({ content: "one\ntwo\n" }) }); + const positioned = await executeTool(positionedTool, { + path: "/workspace/file.txt", + offset: 2, + }); + await expect( + modelOutput(positionedTool, { path: "/workspace/file.txt", offset: 2 }, positioned), + ).resolves.toEqual({ type: "json", value: positioned }); + }); + it("stops pulling chunks as soon as the line cap is complete", async () => { const chunks = [bytes("first\nsecond"), bytes(" line continues"), bytes(" to the end")]; const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); diff --git a/packages/computer/src/tools/fs/read.ts b/packages/computer/src/tools/fs/read.ts index e015010e..fe54b461 100644 --- a/packages/computer/src/tools/fs/read.ts +++ b/packages/computer/src/tools/fs/read.ts @@ -27,24 +27,33 @@ const DEFAULT_MAX_MODEL_BYTES = 3.5 * 1024 * 1024; const DEFAULT_MEDIA_SNIFF_BYTES = 512; const TRUNCATION_MARKER = "... (truncated)"; -const inputSchema = z.object({ - path: z.string().describe("Path to the file to read"), - offset: z - .number() - .int() - .min(1) - .optional() - .describe("Line number to start reading from (1-indexed)"), - byteOffset: z - .number() - .int() - .min(0) - .optional() - .describe( - "Byte continuation returned by a previous read. Pass it with offset to avoid rescanning.", - ), - limit: z.number().int().min(1).optional().describe("Maximum number of lines to read"), -}); +const inputSchema = z + .object({ + path: z.string().describe("Path to the file to read"), + offset: z + .number() + .int() + .min(1) + .optional() + .describe("Line number to start reading from (1-indexed)"), + byteOffset: z + .number() + .int() + .min(0) + .optional() + .describe( + "Byte continuation returned by a previous read. Pass it with offset to avoid rescanning.", + ), + limit: z.number().int().min(1).optional().describe("Maximum number of lines to read"), + }) + .refine( + ({ offset, byteOffset }) => + byteOffset === undefined || byteOffset === 0 || offset !== undefined, + { + message: "offset is required when byteOffset is greater than zero", + path: ["byteOffset"], + }, + ); interface ReadResult { path: string; @@ -91,6 +100,10 @@ export function createReadTool(options: ReadToolOptions): Tool => { + if (byteOffset !== undefined && byteOffset > 0 && offset === undefined) { + return { error: "offset is required when byteOffset is greater than zero" }; + } + const stat = await store.stat(path); if (!stat) return { error: `File not found: ${path}` }; @@ -169,7 +182,7 @@ export function createReadTool(options: ReadToolOptions): Tool 0) { - keptParts.push(kept); + keptParts.push(kept.slice()); keptLength += kept.byteLength; } }; @@ -256,7 +269,9 @@ export function createReadTool(options: ReadToolOptions): Tool Date: Mon, 10 Aug 2026 20:29:48 +0000 Subject: [PATCH 14/19] computer: Harden text media detection --- packages/computer/src/tools/ai.test.ts | 66 +++++++++++++++++++------ packages/computer/src/tools/fs/media.ts | 30 +++++++++-- packages/computer/src/tools/fs/read.ts | 16 ++++-- 3 files changed, 89 insertions(+), 23 deletions(-) diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index d30f0f3d..0dc6c30d 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -1041,7 +1041,7 @@ describe("createAITools filesystem tools", () => { expect(ranges).toEqual([{ offset: 0, length: 512 }]); }); - it("sniffs SVG only when it is the root element", async () => { + it("returns SVG source as text instead of inline image data", async () => { for (const content of [ '', '\n', @@ -1051,22 +1051,58 @@ describe("createAITools filesystem tools", () => { '">]>\n', "\n", ]) { - const tool = createReadTool({ store: memoryStore({ content }) }); - await expect(executeTool(tool, { path: "/workspace/upload" })).resolves.toMatchObject({ - kind: "image", - mediaType: "image/svg+xml", - }); + for (const path of ["/workspace/upload", "/workspace/image.svg"]) { + const tool = createReadTool({ store: memoryStore({ content }) }); + await expect(executeTool(tool, { path })).resolves.toMatchObject({ + content, + truncated: false, + }); + } } + }); - for (const content of [ - "const markup = '';", - "", - "not an svg root", - ]) { - const tool = createReadTool({ store: memoryStore({ content }) }); - const output = await executeTool(tool, { path: "/workspace/upload" }); - expect(output).toMatchObject({ content, truncated: false }); - } + it("tolerates a few invalid UTF-8 bytes in short extensionless text", async () => { + const content = new Uint8Array([...bytes("name=caf"), 0xe9, 0x0a]); + const store = memoryStore({ size: content.byteLength }); + store.readChunks = async function* (_path, offset = 0, length) { + yield content.slice(offset, length === undefined ? undefined : offset + length); + }; + const tool = createReadTool({ store }); + + await expect(executeTool(tool, { path: "/workspace/config" })).resolves.toMatchObject({ + content: "name=caf�", + truncated: false, + }); + }); + + it("validates the media sniff limit when constructing the tool", () => { + expect(() => + createReadTool({ store: memoryStore({ content: "text" }), mediaSniffBytes: 0 }), + ).toThrow("mediaSniffBytes must be a positive safe integer"); + }); + + it("does not repeat media sniffing for a text continuation", async () => { + const content = bytes("first\nsecond\n"); + const ranges: Array<{ offset: number; length: number | undefined }> = []; + const store = memoryStore({ size: content.byteLength }); + store.readChunks = async function* (_path, offset = 0, length) { + ranges.push({ offset, length }); + yield content.slice(offset, length === undefined ? undefined : offset + length); + }; + const tool = createReadTool({ store }); + const first = (await executeTool(tool, { + path: "/workspace/config", + limit: 1, + })) as { nextOffset: number; nextByteOffset: number }; + ranges.length = 0; + + await executeTool(tool, { + path: "/workspace/config", + offset: first.nextOffset, + byteOffset: first.nextByteOffset, + }); + + expect(ranges).toEqual([{ offset: first.nextByteOffset, length: undefined }]); }); it("rejects oversized inline media before reading the whole file", async () => { diff --git a/packages/computer/src/tools/fs/media.ts b/packages/computer/src/tools/fs/media.ts index e761355c..b994eac4 100644 --- a/packages/computer/src/tools/fs/media.ts +++ b/packages/computer/src/tools/fs/media.ts @@ -12,7 +12,6 @@ const EXTENSIONS = new Map([ [".jpeg", { kind: "image", mediaType: "image/jpeg" }], [".gif", { kind: "image", mediaType: "image/gif" }], [".webp", { kind: "image", mediaType: "image/webp" }], - [".svg", { kind: "image", mediaType: "image/svg+xml" }], [".pdf", { kind: "file", mediaType: "application/pdf" }], ]); @@ -20,21 +19,30 @@ const TEXT_EXTENSIONS = new Set([ ".c", ".cc", ".cpp", + ".conf", ".css", ".csv", + ".env", ".go", ".h", ".html", + ".ini", ".java", ".js", ".json", ".jsonc", ".jsx", + ".lock", + ".log", ".md", ".mjs", + ".php", ".py", + ".rb", ".rs", ".sh", + ".sql", + ".svg", ".toml", ".ts", ".tsx", @@ -45,6 +53,17 @@ const TEXT_EXTENSIONS = new Set([ ".zig", ]); +const TEXT_FILENAMES = new Set([ + ".dockerignore", + ".editorconfig", + ".env", + ".gitattributes", + ".gitignore", + ".npmrc", + "dockerfile", + "makefile", +]); + export async function detectMedia( store: FileStore, path: string, @@ -53,7 +72,10 @@ export async function detectMedia( const extension = extensionOf(path); const known = EXTENSIONS.get(extension); if (known !== undefined) return known; - if (TEXT_EXTENSIONS.has(extension)) return { kind: "text", mediaType: "text/plain" }; + const filename = path.slice(path.lastIndexOf("/") + 1).toLowerCase(); + if (TEXT_EXTENSIONS.has(extension) || TEXT_FILENAMES.has(filename)) { + return { kind: "text", mediaType: "text/plain" }; + } const prefix = await readPrefix(store, path, sniffBytes); if (startsWith(prefix, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { @@ -71,7 +93,7 @@ export async function detectMedia( if (startsWithAscii(prefix, "%PDF-")) { return { kind: "file", mediaType: "application/pdf" }; } - if (looksLikeSvg(prefix)) return { kind: "image", mediaType: "image/svg+xml" }; + if (looksLikeSvg(prefix)) return { kind: "text", mediaType: "image/svg+xml" }; if (looksLikeText(prefix)) return { kind: "text", mediaType: "text/plain" }; return { kind: "binary", mediaType: "application/octet-stream" }; } @@ -202,5 +224,5 @@ function looksLikeText(bytes: Uint8Array): boolean { for (const char of text) { if (char === "\uFFFD") replacements += 1; } - return replacements / text.length < 0.01; + return replacements <= 2 || replacements / text.length < 0.01; } diff --git a/packages/computer/src/tools/fs/read.ts b/packages/computer/src/tools/fs/read.ts index fe54b461..1deef0d4 100644 --- a/packages/computer/src/tools/fs/read.ts +++ b/packages/computer/src/tools/fs/read.ts @@ -94,7 +94,10 @@ export function createReadTool(options: ReadToolOptions): Tool 0 + ? ({ kind: "text", mediaType: "text/plain" } as const) + : await detectMedia(store, path, mediaSniffBytes); if (media.kind !== "text") { return { kind: media.kind, @@ -119,8 +129,6 @@ export function createReadTool(options: ReadToolOptions): Tool Date: Mon, 10 Aug 2026 20:31:56 +0000 Subject: [PATCH 15/19] computer: Capture inline media during reads --- packages/computer/src/tools/ai.test.ts | 45 +++++++++++++---------- packages/computer/src/tools/fs/read.ts | 51 +++++++++++++------------- 2 files changed, 51 insertions(+), 45 deletions(-) diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index 0dc6c30d..952874fd 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -995,11 +995,13 @@ describe("createAITools filesystem tools", () => { expect(chunksRead).toBe(1); }); - it("returns image extensions as file-data model output", async () => { + it("captures image bytes once and emits modern file model output", async () => { const content = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + let reads = 0; const store = memoryStore({ size: content.length }); store.readAll = async () => content; store.readChunks = async function* (_path, offset = 0, length) { + reads += 1; yield content.slice(offset, length === undefined ? undefined : offset + length); }; const tool = createReadTool({ store }); @@ -1009,19 +1011,27 @@ describe("createAITools filesystem tools", () => { kind: "image", mediaType: "image/png", sizeBytes: content.length, + data: "iVBORw==", }); - await expect(modelOutput(tool, { path: "/workspace/image.png" }, output)).resolves.toEqual({ + const expected = { type: "content", value: [ { type: "text", text: "Read /workspace/image.png (image/png, 4 bytes)." }, { - type: "file-data", - data: "iVBORw==", + type: "file", + data: { type: "data", data: "iVBORw==" }, mediaType: "image/png", filename: "image.png", }, ], - }); + }; + await expect(modelOutput(tool, { path: "/workspace/image.png" }, output)).resolves.toEqual( + expected, + ); + await expect(modelOutput(tool, { path: "/workspace/image.png" }, output)).resolves.toEqual( + expected, + ); + expect(reads).toBe(1); }); it("sniffs only a bounded prefix for files without a known extension", async () => { @@ -1038,7 +1048,10 @@ describe("createAITools filesystem tools", () => { kind: "file", mediaType: "application/pdf", }); - expect(ranges).toEqual([{ offset: 0, length: 512 }]); + expect(ranges).toEqual([ + { offset: 0, length: 512 }, + { offset: 0, length: 3.5 * 1024 * 1024 + 1 }, + ]); }); it("returns SVG source as text instead of inline image data", async () => { @@ -1145,7 +1158,7 @@ describe("createAITools filesystem tools", () => { expect(ranges).toEqual([{ offset: 0, length: 5 }]); }); - it("reports inline media deleted after the size check", async () => { + it("reports inline media deleted while its bytes are captured", async () => { const store = memoryStore({ size: 2 }); store.readChunks = () => ({ [Symbol.asyncIterator]() { @@ -1156,11 +1169,9 @@ describe("createAITools filesystem tools", () => { }, }); const tool = createReadTool({ store, maxModelBytes: 4 }); - const output = await executeTool(tool, { path: "/workspace/image.png" }); - await expect(modelOutput(tool, { path: "/workspace/image.png" }, output)).resolves.toEqual({ - type: "error-text", - value: "Could not read file bytes: /workspace/image.png", + await expect(executeTool(tool, { path: "/workspace/image.png" })).resolves.toEqual({ + error: "Could not read file bytes: /workspace/image.png", }); }); @@ -1175,11 +1186,9 @@ describe("createAITools filesystem tools", () => { }, }); const tool = createReadTool({ store, maxModelBytes: 4 }); - const output = await executeTool(tool, { path: "/workspace/image.png" }); - await expect(modelOutput(tool, { path: "/workspace/image.png" }, output)).resolves.toEqual({ - type: "error-text", - value: "Could not read file bytes: /workspace/image.png", + await expect(executeTool(tool, { path: "/workspace/image.png" })).resolves.toEqual({ + error: "Could not read file bytes: /workspace/image.png", }); }); @@ -1195,9 +1204,8 @@ describe("createAITools filesystem tools", () => { }, }); const tool = createReadTool({ store, maxModelBytes: 4 }); - const output = await executeTool(tool, { path: "/workspace/image.png" }); - await expect(modelOutput(tool, { path: "/workspace/image.png" }, output)).rejects.toBe(failure); + await expect(executeTool(tool, { path: "/workspace/image.png" })).rejects.toBe(failure); }); it("does not hide unrelated inline media read failures", async () => { @@ -1212,9 +1220,8 @@ describe("createAITools filesystem tools", () => { }, }); const tool = createReadTool({ store, maxModelBytes: 4 }); - const output = await executeTool(tool, { path: "/workspace/image.png" }); - await expect(modelOutput(tool, { path: "/workspace/image.png" }, output)).rejects.toBe(failure); + await expect(executeTool(tool, { path: "/workspace/image.png" })).rejects.toBe(failure); }); }); diff --git a/packages/computer/src/tools/fs/read.ts b/packages/computer/src/tools/fs/read.ts index 1deef0d4..f8f37393 100644 --- a/packages/computer/src/tools/fs/read.ts +++ b/packages/computer/src/tools/fs/read.ts @@ -72,6 +72,8 @@ interface MediaReadResult { name: string; mediaType: string; sizeBytes: number; + /** Base64 bytes captured during execution for stable prompt history. */ + data?: string; unsupported?: true; } @@ -119,7 +121,7 @@ export function createReadTool(options: ReadToolOptions): Tool maxModelBytes) return result; + + let bytes: Uint8Array; + try { + bytes = await readBounded(store, path, maxModelBytes + 1); + } catch (error) { + if (isMissingFileError(error)) { + return { error: `Could not read file bytes: ${path}` }; + } + throw error; + } + result.sizeBytes = bytes.byteLength; + if (bytes.byteLength <= maxModelBytes) result.data = uint8ArrayToBase64(bytes); + return result; } const lineCap = Math.min(limit ?? maxLines, maxLines); @@ -284,41 +300,23 @@ export function createReadTool(options: ReadToolOptions): Tool maxModelBytes) { return inlineMediaLimitError(output, output.sizeBytes, maxModelBytes); } - const currentStat = await store.stat(input.path); - if (currentStat === null) { - return { type: "error-text", value: `Could not read file bytes: ${input.path}` }; - } - if (currentStat.size > maxModelBytes) { - return inlineMediaLimitError(output, currentStat.size, maxModelBytes); - } - let bytes: Uint8Array; - try { - bytes = await readBounded(store, input.path, maxModelBytes + 1); - } catch (error) { - if (isMissingFileError(error)) { - return { type: "error-text", value: `Could not read file bytes: ${input.path}` }; - } - throw error; - } - if (bytes.byteLength > maxModelBytes) { - return inlineMediaLimitError(output, bytes.byteLength, maxModelBytes); + if (output.data === undefined) { + return { type: "error-text", value: `Could not read captured file bytes: ${output.path}` }; } return { type: "content", value: [ { type: "text", - text: `Read ${output.path} (${output.mediaType}, ${bytes.byteLength} bytes).`, + text: `Read ${output.path} (${output.mediaType}, ${output.sizeBytes} bytes).`, }, { - type: "file-data", - data: uint8ArrayToBase64(bytes), + type: "file", + data: { type: "data", data: output.data }, mediaType: output.mediaType, filename: output.name, }, @@ -415,7 +413,8 @@ function isMediaReadResult(value: unknown): value is MediaReadResult { typeof value.path === "string" && typeof value.name === "string" && typeof value.mediaType === "string" && - typeof value.sizeBytes === "number" + typeof value.sizeBytes === "number" && + (value.data === undefined || typeof value.data === "string") ); } From 74aad8e1e082d65e89fac9cdd7395e83da4b5bd3 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:32:56 +0000 Subject: [PATCH 16/19] computer: Clarify text read failures --- packages/computer/src/tools/ai.test.ts | 22 +++++++++++++++++++++- packages/computer/src/tools/fs/read.ts | 5 ++++- packages/computer/src/tools/fs/store.ts | 4 ++-- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index 952874fd..69fbf268 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -837,7 +837,8 @@ describe("createAITools filesystem tools", () => { const tool = createReadTool({ store: memoryStore({ content: "abcdef\n" }), maxBytes: 3 }); await expect(executeTool(tool, { path: "/workspace/file.txt" })).resolves.toEqual({ - error: "Line 1 exceeds the 3-byte read cap. Increase the cap or configure lineTruncation.", + error: + "Line 1 exceeds the 3-byte read cap. The host must increase maxBytes, reduce lineTruncation, or provide a byte-oriented tool.", }); }); @@ -912,6 +913,25 @@ describe("createAITools filesystem tools", () => { }); }); + it("reports a stale byte continuation without inventing a line count", async () => { + const content = bytes("first\n"); + const store = memoryStore({ size: content.byteLength }); + store.readChunks = async function* (_path, offset = 0, length) { + yield content.slice(offset, length === undefined ? undefined : offset + length); + }; + const tool = createReadTool({ store }); + + await expect( + executeTool(tool, { + path: "/workspace/file.txt", + offset: 7, + byteOffset: 100, + }), + ).resolves.toEqual({ + error: "Byte continuation 100 is beyond end of file", + }); + }); + it("treats a zero byte offset as the start of the file", async () => { const tool = createReadTool({ store: memoryStore({ content: "first\nsecond\nthird\n" }) }); diff --git a/packages/computer/src/tools/fs/read.ts b/packages/computer/src/tools/fs/read.ts index f8f37393..a00c263f 100644 --- a/packages/computer/src/tools/fs/read.ts +++ b/packages/computer/src/tools/fs/read.ts @@ -249,7 +249,7 @@ export function createReadTool(options: ReadToolOptions): Tool 0) { + return { error: `Byte continuation ${startByte} is beyond end of file` }; + } if (offset !== undefined && startLine > Math.max(1, linesSeen)) { return { error: `Offset ${offset} is beyond end of file (${linesSeen} line(s))` }; } diff --git a/packages/computer/src/tools/fs/store.ts b/packages/computer/src/tools/fs/store.ts index 468ff56e..a4b96e92 100644 --- a/packages/computer/src/tools/fs/store.ts +++ b/packages/computer/src/tools/fs/store.ts @@ -7,8 +7,8 @@ * `workspace.fs` surface. * * Chunked and ranged reads use one `fs.readFile` stream so remote workspaces - * keep one snapshot and one RPC invocation. Whole-file reads used by edit and - * multimodal output drain the same stream interface. + * keep one snapshot and one RPC invocation. Edit drains `readAll`; multimodal + * reads use a bounded `readChunks` range and capture those bytes once. */ import type { FileStat, MutableFileStore } from "./types.js"; From 02333de89da7ed7a0add3b007bb3b113034559d8 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:42:57 +0000 Subject: [PATCH 17/19] computer: Keep short binary payloads unsupported --- packages/computer/src/tools/ai.test.ts | 14 ++++++++++++++ packages/computer/src/tools/fs/media.ts | 9 ++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index 69fbf268..baeb5355 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -1108,6 +1108,20 @@ describe("createAITools filesystem tools", () => { }); }); + it("keeps short invalid byte sequences classified as binary", async () => { + const content = new Uint8Array([0xff, 0xfe]); + const store = memoryStore({ size: content.byteLength }); + store.readChunks = async function* (_path, offset = 0, length) { + yield content.slice(offset, length === undefined ? undefined : offset + length); + }; + const tool = createReadTool({ store }); + + await expect(executeTool(tool, { path: "/workspace/data" })).resolves.toMatchObject({ + kind: "binary", + unsupported: true, + }); + }); + it("validates the media sniff limit when constructing the tool", () => { expect(() => createReadTool({ store: memoryStore({ content: "text" }), mediaSniffBytes: 0 }), diff --git a/packages/computer/src/tools/fs/media.ts b/packages/computer/src/tools/fs/media.ts index b994eac4..39de519f 100644 --- a/packages/computer/src/tools/fs/media.ts +++ b/packages/computer/src/tools/fs/media.ts @@ -224,5 +224,12 @@ function looksLikeText(bytes: Uint8Array): boolean { for (const char of text) { if (char === "\uFFFD") replacements += 1; } - return replacements <= 2 || replacements / text.length < 0.01; + if (replacements / text.length < 0.01) return true; + if (replacements > 2) return false; + for (const char of text) { + if (char !== "�" && (char >= " " || char === "\n" || char === "\r" || char === "\t")) { + return true; + } + } + return false; } From d4ca500c9af5db796d02b34379ca99d4ae78f68f Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:10:31 +0000 Subject: [PATCH 18/19] computer: Reject empty model attachments --- packages/computer/src/tools/ai.test.ts | 35 ++++++++++++++++++++++++++ packages/computer/src/tools/fs/read.ts | 4 +++ 2 files changed, 39 insertions(+) diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index baeb5355..acc28090 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -1054,6 +1054,41 @@ describe("createAITools filesystem tools", () => { expect(reads).toBe(1); }); + it("rejects empty image and PDF attachments", async () => { + for (const { path, size } of [ + { path: "/workspace/empty.png", size: 0 }, + { path: "/workspace/empty.pdf", size: 0 }, + { path: "/workspace/incomplete.png", size: 10 }, + ]) { + const store = memoryStore({ size }); + store.readChunks = async function* () {}; + const tool = createReadTool({ store }); + + await expect(executeTool(tool, { path })).resolves.toEqual({ + error: `Cannot attach empty file: ${path}`, + }); + } + + const tool = createReadTool({ store: memoryStore({ size: 0 }) }); + await expect( + modelOutput( + tool, + { path: "/workspace/empty.png" }, + { + kind: "image", + path: "/workspace/empty.png", + name: "empty.png", + mediaType: "image/png", + sizeBytes: 0, + data: "", + }, + ), + ).resolves.toEqual({ + type: "error-text", + value: "Cannot attach empty file: /workspace/empty.png", + }); + }); + it("sniffs only a bounded prefix for files without a known extension", async () => { const content = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, ...bytes("body")]); const ranges: Array<{ offset: number; length: number | undefined }> = []; diff --git a/packages/computer/src/tools/fs/read.ts b/packages/computer/src/tools/fs/read.ts index a00c263f..6612c2a7 100644 --- a/packages/computer/src/tools/fs/read.ts +++ b/packages/computer/src/tools/fs/read.ts @@ -140,6 +140,7 @@ export function createReadTool(options: ReadToolOptions): Tool Date: Tue, 11 Aug 2026 10:42:20 +0000 Subject: [PATCH 19/19] computer: Test filesystem read internals --- packages/computer/src/tools/fs/media.test.ts | 96 +++++ packages/computer/src/tools/fs/read.test.ts | 116 ++++++ packages/computer/src/tools/fs/read.ts | 372 ++++++++++--------- 3 files changed, 410 insertions(+), 174 deletions(-) create mode 100644 packages/computer/src/tools/fs/media.test.ts create mode 100644 packages/computer/src/tools/fs/read.test.ts diff --git a/packages/computer/src/tools/fs/media.test.ts b/packages/computer/src/tools/fs/media.test.ts new file mode 100644 index 00000000..89c36d05 --- /dev/null +++ b/packages/computer/src/tools/fs/media.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import { detectMedia } from "./media.js"; +import type { FileStore } from "./types.js"; + +const encode = (value: string) => new TextEncoder().encode(value); + +function store(content: Uint8Array | string): FileStore { + const bytes = typeof content === "string" ? encode(content) : content; + return { + async stat() { + return { size: bytes.byteLength, mtime: 1 }; + }, + async *readChunks(_path, offset = 0, length) { + yield bytes.slice(offset, length === undefined ? undefined : offset + length); + }, + async readAll() { + return bytes.slice(); + }, + async write() {}, + }; +} + +describe("detectMedia", () => { + it.each([ + ["/workspace/image.png", "image", "image/png"], + ["/workspace/image.JPG", "image", "image/jpeg"], + ["/workspace/image.gif", "image", "image/gif"], + ["/workspace/image.webp", "image", "image/webp"], + ["/workspace/document.pdf", "file", "application/pdf"], + ["/workspace/config.json", "text", "text/plain"], + ["/workspace/Dockerfile", "text", "text/plain"], + ["/workspace/.gitignore", "text", "text/plain"], + ["/workspace/vector.svg", "text", "text/plain"], + ] as const)("classifies %s by name", async (path, kind, mediaType) => { + await expect(detectMedia(store(new Uint8Array()), path, 512)).resolves.toEqual({ + kind, + mediaType, + }); + }); + + it.each([ + [new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), "image/png"], + [new Uint8Array([0xff, 0xd8, 0xff]), "image/jpeg"], + [encode("GIF89a"), "image/gif"], + [new Uint8Array([...encode("RIFF"), 0, 0, 0, 0, ...encode("WEBP")]), "image/webp"], + ] as const)("recognizes image magic bytes", async (content, mediaType) => { + await expect(detectMedia(store(content), "/workspace/upload", 512)).resolves.toEqual({ + kind: "image", + mediaType, + }); + }); + + it("recognizes PDF and SVG prefixes", async () => { + await expect(detectMedia(store("%PDF-1.7"), "/workspace/upload", 512)).resolves.toEqual({ + kind: "file", + mediaType: "application/pdf", + }); + await expect( + detectMedia( + store('\n\n'), + "/workspace/upload", + 512, + ), + ).resolves.toEqual({ kind: "text", mediaType: "image/svg+xml" }); + }); + + it("distinguishes plausible text from binary data", async () => { + await expect( + detectMedia( + store(new Uint8Array([...encode("name=caf"), 0xe9, 0x0a])), + "/workspace/config", + 512, + ), + ).resolves.toEqual({ kind: "text", mediaType: "text/plain" }); + await expect( + detectMedia(store(new Uint8Array([0xff, 0xfe])), "/workspace/data", 512), + ).resolves.toEqual({ kind: "binary", mediaType: "application/octet-stream" }); + await expect( + detectMedia(store(new Uint8Array([0x61, 0x00, 0x62])), "/workspace/data", 512), + ).resolves.toEqual({ kind: "binary", mediaType: "application/octet-stream" }); + }); + + it("reads only the configured prefix", async () => { + const calls: Array<{ offset?: number; length?: number }> = []; + const target = store("plain text"); + const original = target.readChunks.bind(target); + target.readChunks = async function* (path, offset, length) { + calls.push({ offset, length }); + yield* original(path, offset, length); + }; + + await detectMedia(target, "/workspace/upload", 4); + expect(calls).toEqual([{ offset: 0, length: 4 }]); + }); +}); diff --git a/packages/computer/src/tools/fs/read.test.ts b/packages/computer/src/tools/fs/read.test.ts new file mode 100644 index 00000000..824fce02 --- /dev/null +++ b/packages/computer/src/tools/fs/read.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; + +import { readFromStore } from "./read.js"; +import type { FileStore } from "./types.js"; + +const encode = (value: string) => new TextEncoder().encode(value); + +function store(content: Uint8Array | string, reportedSize?: number): FileStore { + const bytes = typeof content === "string" ? encode(content) : content; + return { + async stat() { + return { size: reportedSize ?? bytes.byteLength, mtime: 1 }; + }, + async *readChunks(_path, offset = 0, length) { + yield bytes.slice(offset, length === undefined ? undefined : offset + length); + }, + async readAll() { + return bytes.slice(); + }, + async write() {}, + }; +} + +describe("readFromStore", () => { + it("returns structured text and enforces line and byte caps", async () => { + await expect( + readFromStore( + { store: store("first\nsecond\nthird\n"), maxLines: 2 }, + { path: "/workspace/file.txt" }, + ), + ).resolves.toEqual({ + path: "/workspace/file.txt", + content: "first\nsecond", + startLine: 1, + endLine: 2, + totalLines: null, + truncated: true, + nextOffset: 3, + nextByteOffset: 13, + }); + + await expect( + readFromStore({ store: store("abcdef\n"), maxBytes: 3 }, { path: "/workspace/file.txt" }), + ).resolves.toEqual({ + error: + "Line 1 exceeds the 3-byte read cap. The host must increase maxBytes, reduce lineTruncation, or provide a byte-oriented tool.", + }); + }); + + it("continues directly from a returned byte position", async () => { + const target = store("first\nsecond\nthird\n"); + const first = await readFromStore( + { store: target, maxLines: 1 }, + { path: "/workspace/file.txt" }, + ); + expect(first).toMatchObject({ nextOffset: 2, nextByteOffset: 6 }); + + await expect( + readFromStore( + { store: target, maxLines: 1 }, + { path: "/workspace/file.txt", offset: 2, byteOffset: 6 }, + ), + ).resolves.toMatchObject({ + content: "second", + startLine: 2, + endLine: 2, + nextOffset: 3, + nextByteOffset: 13, + }); + }); + + it("requires a line position with a positive byte continuation", async () => { + await expect( + readFromStore( + { store: store("first\nsecond\n") }, + { path: "/workspace/file.txt", byteOffset: 6 }, + ), + ).resolves.toEqual({ error: "offset is required when byteOffset is greater than zero" }); + }); + + it("truncates individual lines on UTF-8 boundaries", async () => { + await expect( + readFromStore( + { store: store("ééé\n"), lineTruncation: { bytes: 5 } }, + { path: "/workspace/file.txt" }, + ), + ).resolves.toMatchObject({ content: "éé... (truncated)", truncated: false }); + }); + + it("captures bounded media bytes and rejects empty attachments", async () => { + await expect( + readFromStore( + { store: store(new Uint8Array([0x89, 0x50, 0x4e, 0x47])) }, + { path: "/workspace/image.png" }, + ), + ).resolves.toMatchObject({ + kind: "image", + mediaType: "image/png", + sizeBytes: 4, + data: "iVBORw==", + }); + + await expect( + readFromStore({ store: store(new Uint8Array()) }, { path: "/workspace/image.png" }), + ).resolves.toEqual({ error: "Cannot attach empty file: /workspace/image.png" }); + await expect( + readFromStore({ store: store(new Uint8Array(), 10) }, { path: "/workspace/image.png" }), + ).resolves.toEqual({ error: "Cannot attach empty file: /workspace/image.png" }); + }); + + it("validates bounded media options without constructing an AI tool", () => { + expect(() => + readFromStore({ store: store("text"), mediaSniffBytes: 0 }, { path: "/workspace/file" }), + ).toThrow("mediaSniffBytes must be a positive safe integer"); + }); +}); diff --git a/packages/computer/src/tools/fs/read.ts b/packages/computer/src/tools/fs/read.ts index 6612c2a7..f24cff01 100644 --- a/packages/computer/src/tools/fs/read.ts +++ b/packages/computer/src/tools/fs/read.ts @@ -55,6 +55,13 @@ const inputSchema = z }, ); +export interface ReadInput { + path: string; + offset?: number; + byteOffset?: number; + limit?: number; +} + interface ReadResult { path: string; content: string; @@ -86,7 +93,9 @@ function utf8ByteLength(value: string): number { return encoder.encode(value).length; } -export function createReadTool(options: ReadToolOptions): Tool> { +function createReadExecutor( + options: ReadToolOptions, +): (input: ReadInput) => Promise { const { store } = options; const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; @@ -101,196 +110,211 @@ export function createReadTool(options: ReadToolOptions): Tool => { - if (byteOffset !== undefined && byteOffset > 0 && offset === undefined) { - return { error: "offset is required when byteOffset is greater than zero" }; - } + return async ({ path, offset, byteOffset, limit }): Promise => { + if (byteOffset !== undefined && byteOffset > 0 && offset === undefined) { + return { error: "offset is required when byteOffset is greater than zero" }; + } - const stat = await store.stat(path); - if (!stat) return { error: `File not found: ${path}` }; - - const startLine = offset ?? 1; - const startByte = byteOffset ?? 0; - // Positive byte offsets are continuations emitted only after a text read, - // so avoid re-reading the file prefix to classify every subsequent page. - const media = - startByte > 0 - ? ({ kind: "text", mediaType: "text/plain" } as const) - : await detectMedia(store, path, mediaSniffBytes); - if (media.kind !== "text") { - const result: MediaReadResult = { - kind: media.kind, - path, - name: basename(path), - mediaType: media.mediaType, - sizeBytes: stat.size, - ...(media.kind === "binary" ? { unsupported: true as const } : {}), - }; - if (media.kind === "binary" || stat.size > maxModelBytes) return result; - - let bytes: Uint8Array; - try { - bytes = await readBounded(store, path, maxModelBytes + 1); - } catch (error) { - if (isMissingFileError(error)) { - return { error: `Could not read file bytes: ${path}` }; - } - throw error; + const stat = await store.stat(path); + if (!stat) return { error: `File not found: ${path}` }; + + const startLine = offset ?? 1; + const startByte = byteOffset ?? 0; + // Positive byte offsets are continuations emitted only after a text read, + // so avoid re-reading the file prefix to classify every subsequent page. + const media = + startByte > 0 + ? ({ kind: "text", mediaType: "text/plain" } as const) + : await detectMedia(store, path, mediaSniffBytes); + if (media.kind !== "text") { + const result: MediaReadResult = { + kind: media.kind, + path, + name: basename(path), + mediaType: media.mediaType, + sizeBytes: stat.size, + ...(media.kind === "binary" ? { unsupported: true as const } : {}), + }; + if (media.kind === "binary" || stat.size > maxModelBytes) return result; + + let bytes: Uint8Array; + try { + bytes = await readBounded(store, path, maxModelBytes + 1); + } catch (error) { + if (isMissingFileError(error)) { + return { error: `Could not read file bytes: ${path}` }; } - if (bytes.byteLength === 0) return { error: `Cannot attach empty file: ${path}` }; - result.sizeBytes = bytes.byteLength; - if (bytes.byteLength <= maxModelBytes) result.data = uint8ArrayToBase64(bytes); - return result; + throw error; } + if (bytes.byteLength === 0) return { error: `Cannot attach empty file: ${path}` }; + result.sizeBytes = bytes.byteLength; + if (bytes.byteLength <= maxModelBytes) result.data = uint8ArrayToBase64(bytes); + return result; + } - const lineCap = Math.min(limit ?? maxLines, maxLines); - let currentLine = byteOffset === undefined || byteOffset === 0 ? 1 : startLine; - const collected: string[] = []; - let collectedBytes = 0; - let firstEmittedLine: number | null = null; - let truncatedByBudget = false; - let firstLineOverflow = false; - let nextByteOffset: number | undefined; - - const processLine = ( - lineBytes: Uint8Array, - actualBytes: number, - lineStart: number, - ): boolean => { - if (currentLine < startLine) { - currentLine += 1; - return true; - } - if (collected.length >= lineCap) { - truncatedByBudget = true; - nextByteOffset = lineStart; - return false; - } - - const line = renderLine( - truncateLine(lineBytes, actualBytes, lineTruncation), - currentLine, - includeLineNumbers, - ); - const outputBytes = utf8ByteLength(line) + (collected.length > 0 ? 1 : 0); - if (collected.length === 0 && outputBytes > maxBytes) { - firstLineOverflow = true; - return false; - } - if (collectedBytes + outputBytes > maxBytes) { - truncatedByBudget = true; - nextByteOffset = lineStart; - return false; - } - - if (firstEmittedLine === null) firstEmittedLine = currentLine; - collected.push(line); - collectedBytes += outputBytes; + const lineCap = Math.min(limit ?? maxLines, maxLines); + let currentLine = byteOffset === undefined || byteOffset === 0 ? 1 : startLine; + const collected: string[] = []; + let collectedBytes = 0; + let firstEmittedLine: number | null = null; + let truncatedByBudget = false; + let firstLineOverflow = false; + let nextByteOffset: number | undefined; + + const processLine = ( + lineBytes: Uint8Array, + actualBytes: number, + lineStart: number, + ): boolean => { + if (currentLine < startLine) { currentLine += 1; return true; - }; - - const keepBytes = bytesToRetain(lineTruncation, maxBytes); - let keptParts: Uint8Array[] = []; - let keptLength = 0; - let actualLength = 0; - let absoluteOffset = startByte; - let lineStart = startByte; - let keepGoing = true; - - const append = (part: Uint8Array): void => { - actualLength += part.byteLength; - const available = keepBytes - keptLength; - if (available <= 0) return; - const kept = part.byteLength <= available ? part : part.subarray(0, available); - if (kept.byteLength > 0) { - keptParts.push(kept.slice()); - keptLength += kept.byteLength; - } - }; - const finishLine = (): boolean => { - const bytes = joinBytes(keptParts, keptLength); - const result = processLine(bytes, actualLength, lineStart); - keptParts = []; - keptLength = 0; - actualLength = 0; - return result; - }; - - for await (const chunk of store.readChunks(path, startByte)) { - let cursor = 0; - while (cursor < chunk.byteLength) { - const newline = chunk.indexOf(0x0a, cursor); - if (newline === -1) { - append(chunk.subarray(cursor)); - break; - } - append(chunk.subarray(cursor, newline)); - const afterNewline = absoluteOffset + newline + 1; - if (!finishLine()) { - keepGoing = false; - break; - } - lineStart = afterNewline; - cursor = newline + 1; - if (collected.length >= lineCap && afterNewline < stat.size) { - truncatedByBudget = true; - nextByteOffset = afterNewline; - keepGoing = false; - break; - } - } - absoluteOffset += chunk.byteLength; - if (!keepGoing) break; } - if (keepGoing && actualLength > 0) finishLine(); + if (collected.length >= lineCap) { + truncatedByBudget = true; + nextByteOffset = lineStart; + return false; + } - if (firstLineOverflow) { - return { - error: `Line ${currentLine} exceeds the ${maxBytes}-byte read cap. The host must increase maxBytes, reduce lineTruncation, or provide a byte-oriented tool.`, - }; + const line = renderLine( + truncateLine(lineBytes, actualBytes, lineTruncation), + currentLine, + includeLineNumbers, + ); + const outputBytes = utf8ByteLength(line) + (collected.length > 0 ? 1 : 0); + if (collected.length === 0 && outputBytes > maxBytes) { + firstLineOverflow = true; + return false; + } + if (collectedBytes + outputBytes > maxBytes) { + truncatedByBudget = true; + nextByteOffset = lineStart; + return false; } - if (firstEmittedLine === null) { - const linesSeen = currentLine - 1; - if (stat.size === 0) { - return { - path, - content: "", - startLine: 1, - endLine: 0, - totalLines: 0, - truncated: false, - }; + if (firstEmittedLine === null) firstEmittedLine = currentLine; + collected.push(line); + collectedBytes += outputBytes; + currentLine += 1; + return true; + }; + + const keepBytes = bytesToRetain(lineTruncation, maxBytes); + let keptParts: Uint8Array[] = []; + let keptLength = 0; + let actualLength = 0; + let absoluteOffset = startByte; + let lineStart = startByte; + let keepGoing = true; + + const append = (part: Uint8Array): void => { + actualLength += part.byteLength; + const available = keepBytes - keptLength; + if (available <= 0) return; + const kept = part.byteLength <= available ? part : part.subarray(0, available); + if (kept.byteLength > 0) { + keptParts.push(kept.slice()); + keptLength += kept.byteLength; + } + }; + const finishLine = (): boolean => { + const bytes = joinBytes(keptParts, keptLength); + const result = processLine(bytes, actualLength, lineStart); + keptParts = []; + keptLength = 0; + actualLength = 0; + return result; + }; + + for await (const chunk of store.readChunks(path, startByte)) { + let cursor = 0; + while (cursor < chunk.byteLength) { + const newline = chunk.indexOf(0x0a, cursor); + if (newline === -1) { + append(chunk.subarray(cursor)); + break; } - if (startByte > 0) { - return { error: `Byte continuation ${startByte} is beyond end of file` }; + append(chunk.subarray(cursor, newline)); + const afterNewline = absoluteOffset + newline + 1; + if (!finishLine()) { + keepGoing = false; + break; } - if (offset !== undefined && startLine > Math.max(1, linesSeen)) { - return { error: `Offset ${offset} is beyond end of file (${linesSeen} line(s))` }; + lineStart = afterNewline; + cursor = newline + 1; + if (collected.length >= lineCap && afterNewline < stat.size) { + truncatedByBudget = true; + nextByteOffset = afterNewline; + keepGoing = false; + break; } } + absoluteOffset += chunk.byteLength; + if (!keepGoing) break; + } + if (keepGoing && actualLength > 0) finishLine(); - const startLineActual = firstEmittedLine ?? startLine; - const endLine = startLineActual + collected.length - 1; - const truncated = truncatedByBudget; - const result: ReadResult = { - path, - content: collected.join("\n"), - startLine: startLineActual, - endLine, - totalLines: truncated ? null : currentLine - 1, - truncated, + if (firstLineOverflow) { + return { + error: `Line ${currentLine} exceeds the ${maxBytes}-byte read cap. The host must increase maxBytes, reduce lineTruncation, or provide a byte-oriented tool.`, }; - if (truncated) { - result.nextOffset = endLine + 1; - result.nextByteOffset = nextByteOffset; + } + + if (firstEmittedLine === null) { + const linesSeen = currentLine - 1; + if (stat.size === 0) { + return { + path, + content: "", + startLine: 1, + endLine: 0, + totalLines: 0, + truncated: false, + }; } - return result; - }, + if (startByte > 0) { + return { error: `Byte continuation ${startByte} is beyond end of file` }; + } + if (offset !== undefined && startLine > Math.max(1, linesSeen)) { + return { error: `Offset ${offset} is beyond end of file (${linesSeen} line(s))` }; + } + } + + const startLineActual = firstEmittedLine ?? startLine; + const endLine = startLineActual + collected.length - 1; + const truncated = truncatedByBudget; + const result: ReadResult = { + path, + content: collected.join("\n"), + startLine: startLineActual, + endLine, + totalLines: truncated ? null : currentLine - 1, + truncated, + }; + if (truncated) { + result.nextOffset = endLine + 1; + result.nextByteOffset = nextByteOffset; + } + return result; + }; +} + +export function readFromStore(options: ReadToolOptions, input: ReadInput): Promise { + return createReadExecutor(options)(input); +} + +export function createReadTool(options: ReadToolOptions): Tool> { + const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + const maxModelBytes = validateBoundedReadLimit( + "maxModelBytes", + options.maxModelBytes ?? DEFAULT_MAX_MODEL_BYTES, + ); + + return tool({ + description: `Read a workspace file. Images and PDFs are passed to capable models. Text output is capped at ${maxLines} lines or ${Math.round(maxBytes / 1024)}KB and includes line and byte continuations when truncated.`, + inputSchema, + execute: createReadExecutor(options), toModelOutput: async ({ input, output }: { input: unknown; output: unknown }) => { if (!isRecord(output)) return { type: "text", value: String(output) }; if (typeof output.error === "string") {