diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index bbce833c03..0d3cd10bc9 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -80,17 +80,28 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/SessionCompaction") {} -const estimate = (value: unknown) => - Token.estimate( - JSON.stringify(value, (_key, item: unknown) => { - if (typeof item !== "object" || item === null || !("type" in item)) return item - // Providers account for native media separately; its base64 encoding is not prompt text. - if (item.type === "media" && "data" in item) return { ...item, data: "[media bytes]" } - if (item.type === "file" && "uri" in item && typeof item.uri === "string" && item.uri.startsWith("data:")) - return { ...item, uri: item.uri.slice(0, item.uri.indexOf(",") + 1) + "[media bytes]" } - return item - }), - ) +const estimate = (value: unknown) => Token.estimate(JSON.stringify(value)) + +const textContext = (request: LLMRequest) => ({ + system: request.system, + // TODO: Replace blanket attachment exclusion with model-aware media and file token accounting. + messages: request.messages + .filter((message) => message.metadata?.attachment === undefined) + .map((message) => ({ + id: message.id, + role: message.role, + content: message.content.flatMap((part) => { + if (part.type === "media") return [] + if (part.type !== "tool-result" || part.result.type !== "content") return [part] + return [ + { ...part, result: { ...part.result, value: part.result.value.filter((item) => item.type === "text") } }, + ] + }), + metadata: message.metadata, + native: message.native, + })), + tools: request.tools, +}) const truncate = (value: string) => value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]` @@ -289,11 +300,7 @@ const make = (dependencies: Dependencies) => { const context = input.request.model.route.defaults.limits?.context if (context === undefined || context <= 0) return false const output = input.request.generation?.maxTokens ?? input.request.model.route.defaults.limits?.output ?? 0 - if ( - estimate({ system: input.request.system, messages: input.request.messages, tools: input.request.tools }) <= - context - Math.max(output, config.buffer) - ) - return false + if (estimate(textContext(input.request)) <= context - Math.max(output, config.buffer)) return false return yield* compactAfterOverflow(input) }) return { diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index a89f50b70c..7af86834b0 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { LLM, LLMClient, LLMEvent, Message, Model, type LLMRequest } from "@opencode-ai/llm" +import { LLM, LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/llm" import { OpenAIChat } from "@opencode-ai/llm/protocols" import { Base64, FileAttachment } from "@opencode-ai/schema/prompt" import { Config } from "@opencode-ai/core/config" @@ -13,12 +13,15 @@ import { SessionCompaction } from "@opencode-ai/core/session/compaction" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionProjector } from "@opencode-ai/core/session/projector" +import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { SessionV2 } from "@opencode-ai/core/session" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { DateTime, Effect, Layer, Stream } from "effect" import { asc, eq } from "drizzle-orm" @@ -69,23 +72,33 @@ test("compaction describes tool media without embedding base64", () => { expect(serialized).not.toContain(base64) }) -it.effect("does not count media base64 as text context", () => +it.effect("does not count file attachments as text context", () => Effect.gen(function* () { requests = [] const compaction = yield* SessionCompaction.Service const text = "context ".repeat(4_000) const data = Base64.make(Buffer.alloc(64 * 1024).toString("base64")) - const file = FileAttachment.make({ + const image = FileAttachment.make({ data, mime: "image/png", source: { type: "inline" }, name: "screenshot.png", }) + const document = FileAttachment.make({ + data: Base64.make(Buffer.alloc(64 * 1024, "a").toString("base64")), + mime: "text/plain", + source: { type: "inline" }, + name: "notes.txt", + }) const inputModel = Model.make({ id: "media-model", provider: "test", route: OpenAIChat.route.with({ limits: { context: 30_000, output: 1_000 } }), }) + const inputModelRef = ModelV2.Ref.make({ + id: ModelV2.ID.make(inputModel.id), + providerID: ProviderV2.ID.make(inputModel.provider), + }) const messages = [ SessionMessage.User.make({ id: SessionMessage.ID.create(), @@ -97,25 +110,31 @@ it.effect("does not count media base64 as text context", () => id: SessionMessage.ID.create(), type: "user", text: "Inspect this image", - files: [file], + files: [document, image], time: { created: DateTime.makeUnsafe(1) }, }), ] + const request = LLM.request({ + model: inputModel, + messages: toLLMMessages(messages, inputModelRef), + }) + + expect(request.messages.flatMap((message) => message.content)).toContainEqual({ + type: "media", + mediaType: "image/png", + data, + filename: "screenshot.png", + }) + expect(request.messages[1]?.content[0]).toMatchObject({ + type: "text", + text: expect.stringContaining("Attached file: notes.txt"), + }) expect( yield* compaction.compactIfNeeded({ sessionID: SessionV2.ID.make("ses_media_compaction"), messages, - request: LLM.request({ - model: inputModel, - messages: [ - Message.user(text), - Message.user([ - { type: "text", text: "Inspect this image" }, - { type: "media", mediaType: "image/png", data, filename: "screenshot.png" }, - ]), - ], - }), + request, }), ).toBe(false) expect(requests).toHaveLength(0)