diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 899e464929..8f73f7e90c 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -6,7 +6,6 @@ import { ToolFailure } from "@opencode-ai/ai" import { Effect, Schema } from "effect" import { FileSystem } from "../filesystem" import { FSUtil } from "../fs-util" -import { Image } from "../image" import { Location } from "../location" import { LocationMutation } from "../location-mutation" import { PermissionV2 } from "../permission" @@ -35,7 +34,6 @@ export const Plugin = { effect: Effect.fn("ReadTool.Plugin")(function* (ctx: PluginContext) { const reader = yield* ReadToolFileSystem.Service const mutation = yield* LocationMutation.Service - const image = yield* Image.Service const permission = yield* PermissionV2.Service const sessionInstructions = yield* SessionInstructions.Service const fs = yield* FSUtil.Service @@ -50,6 +48,12 @@ export const Plugin = { "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.", input: Input, output: Output, + structured: Schema.toEncoded(Output), + // Image base64 reaches the model through content items (normalized generically + // at tool settlement); persisting a second copy in structured would store the + // original unresized bytes in the message row. + toStructuredOutput: ({ output }) => + "encoding" in output && output.encoding === "base64" ? { ...output, content: "" } : output, toModelOutput: ({ input, output }) => { if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) return [] @@ -117,21 +121,14 @@ export const Plugin = { Effect.catch(() => Effect.void), Effect.catchDefect(() => Effect.void), ) - if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) { - return yield* image - .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" && !SUPPORTED_IMAGE_MIMES.has(content.mime)) return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource })) return content }).pipe( Effect.mapError((error) => { const message = error instanceof ReadToolFileSystem.BinaryFileError || - error instanceof ReadToolFileSystem.MediaIngestLimitError || - error instanceof Image.DecodeError || - error instanceof Image.SizeError + error instanceof ReadToolFileSystem.MediaIngestLimitError ? error.message : `Unable to read ${input.path}` return new ToolFailure({ message, error }) diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index c5e7eaac48..811cd2f020 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -291,6 +291,9 @@ describe("ReadTool", () => { name: "pixel.png", mime: "image/png", encoding: "base64", + // Image base64 is carried by the content file item only; structured is slimmed + // so the original bytes are never persisted twice. + content: "", }) expect(settled.output?.content).toMatchObject([ { type: "text", text: "Image read successfully" }, @@ -364,7 +367,7 @@ describe("ReadTool", () => { }), ) - it.effect("rejects invalid image data returned by the filesystem", () => + it.effect("drops undecodable image data at settlement", () => Effect.gen(function* () { readResult = { uri: "file:///truncated.png", @@ -381,11 +384,17 @@ describe("ReadTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-truncated-image", name: "read", input: { path: "truncated.png" } }, }), - ).toEqual({ type: "error", value: "Image could not be decoded: truncated.png" }) + ).toEqual({ + type: "content", + value: [ + { type: "text", text: "Image read successfully" }, + { type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" }, + ], + }) }), ) - it.effect("rejects oversized images when resizing is disabled", () => + it.effect("drops oversized images at settlement when resizing is disabled", () => Effect.gen(function* () { const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node")) const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 16 * 4 }, () => 255)), 16, 1) @@ -409,14 +418,20 @@ describe("ReadTool", () => { }), ] const registry = yield* ToolRegistry.Service - const result = yield* executeTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } }, - }) - expect(result.type).toBe("error") - if (result.type === "error") expect(result.value).toContain("exceeding configured limits 4x2000") + expect( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } }, + }), + ).toEqual({ + type: "content", + value: [ + { type: "text", text: "Image read successfully" }, + { type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" }, + ], + }) }), ) @@ -460,7 +475,7 @@ describe("ReadTool", () => { }), ) - it.effect("enforces max base64 bytes after resize attempts", () => + it.effect("drops images that cannot fit max base64 bytes after resize attempts", () => Effect.gen(function* () { const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" readResult = { @@ -481,14 +496,20 @@ describe("ReadTool", () => { }), ] const registry = yield* ToolRegistry.Service - const result = yield* executeTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } }, - }) - expect(result.type).toBe("error") - if (result.type === "error") expect(result.value).toContain("/1 bytes") + expect( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } }, + }), + ).toEqual({ + type: "content", + value: [ + { type: "text", text: "Image read successfully" }, + { type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" }, + ], + }) }), )