diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 57ea5b33eb6..fab9e4c0e46 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -19,6 +19,11 @@ import { MAX_STEPS_PROMPT } from "./runner/max-steps" import PROMPT_DEFAULT from "./runner/prompt/base.txt" import { toLLMMessages } from "./runner/to-llm-message" +const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB +const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB +const IMAGE_REMOVED = + "[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]" + /** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */ export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError @@ -94,6 +99,56 @@ export const unsupportedParts = (messages: LLMRequest["messages"], capabilities: }), ) +export const boundImages = (messages: LLMRequest["messages"]) => { + const isImage = (mime: string) => mime.toLowerCase().startsWith("image/") + const size = (data: string | Uint8Array) => + typeof data === "string" ? Buffer.byteLength(data) : Math.ceil(data.byteLength / 3) * 4 + const imageBytes = messages.reduce( + (total, message) => + total + + message.content.reduce((sum, part) => { + if (part.type === "media" && isImage(part.mediaType)) return sum + size(part.data) + if (part.type !== "tool-result" || part.result.type !== "content") return sum + return ( + sum + + part.result.value.reduce( + (bytes: number, item: Content) => + bytes + (item.type === "file" && isImage(item.mime) ? Buffer.byteLength(item.uri) : 0), + 0, + ) + ) + }, 0), + 0, + ) + if (imageBytes <= IMAGE_BYTES_TRIGGER) return messages + + let removed = 0 + return messages.map((message) => + Message.make({ + ...message, + content: message.content.map((part) => { + if (part.type === "media" && isImage(part.mediaType) && imageBytes - removed > IMAGE_BYTES_TARGET) { + removed += size(part.data) + return Message.text(IMAGE_REMOVED) + } + if (part.type !== "tool-result" || part.result.type !== "content") return part + return { + ...part, + result: { + ...part.result, + value: part.result.value.map((item: Content) => { + if (item.type !== "file" || !isImage(item.mime) || imageBytes - removed <= IMAGE_BYTES_TARGET) + return item + removed += Buffer.byteLength(item.uri) + return { type: "text" as const, text: IMAGE_REMOVED } + }), + }, + } + }), + }), + ) +} + /** * Builds an outbound model request and captures the tool-call capability that * must remain paired with it. It does not execute the request or mutate @@ -160,7 +215,7 @@ export const layer = Layer.effect( }, providerOptions: { [providerMetadataKey]: { promptCacheKey } }, system: contextEvent.system, - messages: unsupportedParts(contextEvent.messages, resolved.capabilities), + messages: boundImages(unsupportedParts(contextEvent.messages, resolved.capabilities)), tools: hookedTools, toolChoice: stepLimitReached ? "none" : undefined, }) diff --git a/packages/core/test/session-model-request.test.ts b/packages/core/test/session-model-request.test.ts index 418c9b24197..e2c77ed69ce 100644 --- a/packages/core/test/session-model-request.test.ts +++ b/packages/core/test/session-model-request.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Message, ToolResultPart } from "@opencode-ai/ai" -import { unsupportedParts } from "@opencode-ai/core/session/model-request" +import { boundImages, unsupportedParts } from "@opencode-ai/core/session/model-request" const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] }) @@ -62,3 +62,51 @@ describe("SessionModelRequest.unsupportedParts", () => { expect(unsupportedParts([message], capabilities(["text", "image"]))[0]?.content).toEqual(message.content) }) }) + +describe("SessionModelRequest.boundImages", () => { + test("preserves images below the trigger", () => { + const messages = [Message.user({ type: "media", mediaType: "image/png", data: "aGVsbG8=" })] + expect(boundImages(messages)).toBe(messages) + }) + + test("replaces oldest images until the retained payload reaches the target", () => { + const image = "a".repeat(9 * 1024 * 1024) + const messages = [ + Message.user({ type: "media", mediaType: "image/png", data: image, filename: "first.png" }), + Message.user({ type: "media", mediaType: "image/png", data: image, filename: "second.png" }), + Message.user({ type: "media", mediaType: "image/png", data: image, filename: "third.png" }), + ] + const result = boundImages(messages) + + expect(result[0]?.content[0]).toMatchObject({ type: "text" }) + expect(result[1]?.content[0]).toMatchObject({ type: "text" }) + expect(result[2]?.content[0]).toMatchObject({ type: "media", filename: "third.png" }) + }) + + test("replaces images nested in tool results", () => { + const image = "a".repeat(13 * 1024 * 1024) + const result = boundImages([ + Message.tool( + ToolResultPart.make({ + id: "call_1", + name: "read", + result: { + type: "content", + value: [ + { type: "file", uri: `data:image/png;base64,${image}`, mime: "image/png", name: "first.png" }, + { type: "file", uri: `data:image/png;base64,${image}`, mime: "image/png", name: "second.png" }, + ], + }, + }), + ), + ]) + + expect(result[0]?.content[0]).toMatchObject({ + type: "tool-result", + result: { + type: "content", + value: [{ type: "text" }, { type: "file", name: "second.png" }], + }, + }) + }) +})