diff --git a/.changeset/computer-multimodal-reads.md b/.changeset/computer-multimodal-reads.md new file mode 100644 index 0000000..22d14f0 --- /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. diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index 6b4b9d1..ee04418 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; @@ -869,9 +879,320 @@ 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]); + }); + + 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 }); + 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); + 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 }); + 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" }); + + 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("sniffs SVG only when it is the root element", async () => { + for (const content of [ + '', + '\n', + '\n', + '\n', + '\n', + '">]>\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 }); + 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); + }); + + 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 () => { + 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, 5 bytes), but it exceeds the 4-byte inline model output limit.", + }); + 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("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 }); + 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); }); }); diff --git a/packages/computer/src/tools/fs/media.ts b/packages/computer/src/tools/fs/media.ts new file mode 100644 index 0000000..e761355 --- /dev/null +++ b/packages/computer/src/tools/fs/media.ts @@ -0,0 +1,206 @@ +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); + let cursor = skipWhitespace(prefix, 0); + + 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; + } + + 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; +} + +function isWhitespace(value: string | undefined): boolean { + return value !== undefined && /\s/.test(value); +} + +function looksLikeText(bytes: Uint8Array): boolean { + if (bytes.length === 0) return true; + if (bytes.includes(0)) return false; + const text = new TextDecoder().decode(bytes); + if (text.length === 0) return true; + let replacements = 0; + for (const char of text) { + if (char === "\uFFFD") replacements += 1; + } + return replacements / text.length < 0.01; +} diff --git a/packages/computer/src/tools/fs/read.ts b/packages/computer/src/tools/fs/read.ts index 8e2baf0..e015010 100644 --- a/packages/computer/src/tools/fs/read.ts +++ b/packages/computer/src/tools/fs/read.ts @@ -1,17 +1,31 @@ -import { type Tool, tool } from "ai"; +import { type JSONValue, type Tool, tool } from "ai"; import { z } from "zod"; +import { detectMedia } from "./media.js"; 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; + /** Maximum image or PDF size sent inline to the model. Default 3.5 MiB. */ + maxModelBytes?: number; + /** Prefix bytes inspected when an extension does not identify the file. Default 512. */ + mediaSniffBytes?: number; } const DEFAULT_MAX_LINES = 2000; const DEFAULT_MAX_BYTES = 256 * 1024; +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"), @@ -21,6 +35,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 +54,169 @@ 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; +interface MediaReadResult { + kind: "image" | "file" | "binary"; + path: string; + name: string; + mediaType: string; + sizeBytes: number; + unsupported?: true; } + +type ReadToolResult = ReadResult | MediaReadResult | { error: string }; + +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); + const maxModelBytes = validateBoundedReadLimit( + "maxModelBytes", + options.maxModelBytes ?? DEFAULT_MAX_MODEL_BYTES, + ); + const mediaSniffBytes = options.mediaSniffBytes ?? DEFAULT_MEDIA_SNIFF_BYTES; 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 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: 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 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 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 startLine = offset ?? 1; + const startByte = byteOffset ?? 0; + 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 = (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; + } + lineStart = afterNewline; + cursor = newline + 1; + if (collected.length >= lineCap && afterNewline < stat.size) { + truncatedByBudget = true; + nextByteOffset = afterNewline; 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 } + 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 +236,214 @@ 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 output.truncated === true + ? { type: "json", value: toJSONValue(output) } + : { 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 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); + } + 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, + }, + ], + }; + }, }); } + +function validateBoundedReadLimit(name: string, value: number): number { + if (!Number.isSafeInteger(value) || value < 1 || value === Number.MAX_SAFE_INTEGER) { + throw new TypeError(`${name} must be a positive safe integer below Number.MAX_SAFE_INTEGER`); + } + return value; +} + +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 ( + typeof candidate.message === "string" && + (/\bENOENT\b/i.test(candidate.message) || /no such (?:file|path)\b/i.test(candidate.message)) + ); +} + +async function readBounded(store: FileStore, path: string, limit: number): Promise { + 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; + if (!Number.isSafeInteger(amount) || amount < 1) { + throw new TypeError("lineTruncation must be a positive safe integer"); + } + return value; +} + +function bytesToRetain(truncation: LineTruncation | undefined, maxBytes: number): number { + if (truncation === undefined) return maxBytes + 1; + return "bytes" in truncation ? truncation.bytes : truncation.chars * 4; +} + +function truncateLine( + bytes: Uint8Array, + actualBytes: number, + truncation: LineTruncation | undefined, +): string { + if (truncation === undefined) return decoder.decode(bytes); + if ("bytes" in truncation) { + if (actualBytes <= truncation.bytes) return decoder.decode(bytes); + return `${decodeUtf8Prefix(bytes.subarray(0, truncation.bytes))}${TRUNCATION_MARKER}`; + } + const text = decoder.decode(bytes); + const chars = Array.from(text); + if (chars.length <= truncation.chars && actualBytes === bytes.byteLength) return text; + return `${chars.slice(0, truncation.chars).join("")}${TRUNCATION_MARKER}`; +} + +function decodeUtf8Prefix(bytes: Uint8Array): string { + const fatalDecoder = new TextDecoder("utf-8", { fatal: true }); + for (let end = bytes.byteLength; end >= 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 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 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); + 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; +} + +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 7e06468..9bad474 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";