From c63df06ba749abfbacf205a88a69a8f307b2351e Mon Sep 17 00:00:00 2001 From: Shalin-Shah-2002 <2002shalin@gmail.com> Date: Fri, 24 Jul 2026 23:11:57 +0530 Subject: [PATCH] fix(core): support PDF files in V2 read tool The V2 ReadToolFileSystem rejected PDFs at the magic-byte check, throwing BinaryFileError ('Cannot read binary file'). This was a regression from V1, which returned PDFs as base64 attachments. - Added a dedicated PDF ingest path in read-filesystem.ts that mirrors the image path: detect %PDF magic bytes, enforce the existing MAX_MEDIA_INGEST_BYTES (20 MiB) cap, read all bytes, and return FileSystem.Content with encoding: 'base64' and mime: 'application/pdf' - Updated read.ts toModelOutput to emit PDF content alongside the existing image path ('PDF read successfully' + ToolFileContent) - Updated read.ts base64 guard to allow application/pdf through (PDFs skip image normalization since they can't be resized) The rest of the pipeline (settlement, message-v2 media extraction, provider lowering, and unsupportedParts gating) already handled application/pdf and required no changes. Closes #37323 Co-Authored-By: Claude --- packages/core/src/tool/read-filesystem.ts | 26 ++++++++++++++++++++++- packages/core/src/tool/read.ts | 22 ++++++++++++------- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/packages/core/src/tool/read-filesystem.ts b/packages/core/src/tool/read-filesystem.ts index e325a83edd2c..7263a9bffc8d 100644 --- a/packages/core/src/tool/read-filesystem.ts +++ b/packages/core/src/tool/read-filesystem.ts @@ -209,7 +209,31 @@ export const read = Effect.fn("ReadTool.read")(function* ( mime, } } - if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || extensions.has(path.extname(resource).toLowerCase())) + if (startsWith(first, [0x25, 0x50, 0x44, 0x46])) { + if (info.size > MAX_MEDIA_INGEST_BYTES) + return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES })) + const chunks = [first] + let total = first.length + while (total <= MAX_MEDIA_INGEST_BYTES) { + const chunk = yield* file.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total)) + if (Option.isNone(chunk)) break + chunks.push(chunk.value) + total += chunk.value.length + } + if (total > MAX_MEDIA_INGEST_BYTES) + return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES })) + return { + uri: pathToFileURL(real).href, + name: path.basename(real), + content: Buffer.concat( + chunks.map((chunk) => Buffer.from(chunk)), + total, + ).toString("base64"), + encoding: "base64" as const, + mime: "application/pdf", + } + } + if (extensions.has(path.extname(resource).toLowerCase())) return yield* Effect.fail(new BinaryFileError({ resource })) const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined if (!paged) { diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 6961a8609118..c40b69b26923 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -39,16 +39,22 @@ const layer = Layer.effectDiscard( .register({ [name]: Tool.make({ description: - "Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.", + "Read a text file, supported image, or PDF; page through a large UTF-8 text file by line offset; or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.", input: Input, output: Output, toModelOutput: ({ input, output }) => { - if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) - return [] - return [ - { type: "text", text: "Image read successfully" }, - { type: "file", data: output.content, mime: output.mime, name: input.path }, - ] + if (!("encoding" in output) || output.encoding !== "base64") return [] + if (SUPPORTED_IMAGE_MIMES.has(output.mime)) + return [ + { type: "text", text: "Image read successfully" }, + { type: "file", data: output.content, mime: output.mime, name: input.path }, + ] + if (output.mime === "application/pdf") + return [ + { type: "text", text: "PDF read successfully" }, + { type: "file", data: output.content, mime: output.mime, name: input.path }, + ] + return [] }, execute: (input, context) => { return Effect.gen(function* () { @@ -88,7 +94,7 @@ const layer = Layer.effectDiscard( .normalize(resource, { ...content, encoding: "base64" }) .pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content))) } - if ("encoding" in content && content.encoding === "base64") + if ("encoding" in content && content.encoding === "base64" && content.mime !== "application/pdf") return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource })) return content }).pipe(