diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 63f26a18620f..d3bb0a5cc64b 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -80,7 +80,60 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/SessionCompaction") {} -const estimate = (value: unknown) => Token.estimate(JSON.stringify(value)) +const serializeString = (value: unknown) => { + try { + return String(value) + } catch { + return "[unserializable]" + } +} + +const serializeJson = (value: unknown) => { + try { + return JSON.stringify(value) ?? serializeString(value) + } catch { + return serializeString(value) + } +} + +const serializeError = (value: unknown) => { + try { + const prototype = + typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) + const structured = Array.isArray(value) || prototype === Object.prototype || prototype === null + return structured ? serializeJson(value) : serializeString(value) + } catch { + return serializeString(value) + } +} + +const serializeContent = (part: LLMRequest["messages"][number]["content"][number]) => { + if (part.type === "text" || part.type === "reasoning") return part.text + if (part.type === "media") return "" + if (part.type === "tool-call") return `${part.name}\n${serializeJson(part.input)}` + // OpenAI replays hosted image generations by item reference; the opaque JSON result contains the image bytes. + if ( + part.providerExecuted && + part.name === "image_generation" && + part.result.type === "json" && + typeof part.providerMetadata?.openai?.itemId === "string" + ) + return part.name + if (part.result.type === "content") + return [part.name, ...part.result.value.flatMap((item) => (item.type === "text" ? [item.text] : []))].join("\n") + if (part.result.type === "text") return `${part.name}\n${serializeString(part.result.value)}` + if (part.result.type === "error") return `${part.name}\n${serializeError(part.result.value)}` + return `${part.name}\n${serializeJson(part.result.value)}` +} + +const estimate = (request: LLMRequest) => + Token.estimate( + [ + ...request.system.map((part) => part.text), + serializeJson(request.tools), + ...request.messages.flatMap((message) => message.content.map(serializeContent).filter(Boolean)), + ].join("\n"), + ) const truncate = (value: string) => value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]` @@ -285,11 +338,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(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 1cfd353f545d..47f5608cbf1d 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -1,6 +1,16 @@ import { expect, test } from "bun:test" -import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/llm" -import { OpenAIChat } from "@opencode-ai/llm/protocols" +import { + LLM, + LLMClient, + LLMEvent, + Message, + Model, + ToolCallPart, + ToolResultPart, + type LLMRequest, +} from "@opencode-ai/llm" +import { OpenAIChat, OpenAIResponses } from "@opencode-ai/llm/protocols" +import { Base64, FileAttachment } from "@opencode-ai/schema/prompt" import { Config } from "@opencode-ai/core/config" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -12,12 +22,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, Fiber, Layer, Stream } from "effect" import { asc, eq } from "drizzle-orm" @@ -68,6 +81,138 @@ test("compaction describes tool media without embedding base64", () => { expect(serialized).not.toContain(base64) }) +it.effect("does not count image 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 image = FileAttachment.make({ + data, + mime: "image/png", + source: { type: "inline" }, + name: "screenshot.png", + }) + const inputModel = Model.make({ + id: "media-model", + provider: "test", + route: OpenAIResponses.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(), + type: "user", + text, + time: { created: DateTime.makeUnsafe(0) }, + }), + SessionMessage.User.make({ + id: SessionMessage.ID.create(), + type: "user", + text: "Inspect this image", + files: [image], + time: { created: DateTime.makeUnsafe(1) }, + }), + ] + const request = LLM.request({ + model: inputModel, + messages: [ + ...toLLMMessages(messages, inputModelRef), + Message.assistant( + ToolResultPart.make({ + id: "image_generation_1", + name: "image_generation", + result: { type: "image_generation_call", output: Buffer.alloc(64 * 1024).toString("base64") }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "image_generation_1" } }, + }), + ), + ], + }) + + expect(request.messages.flatMap((message) => message.content)).toContainEqual({ + type: "media", + mediaType: "image/png", + data, + filename: "screenshot.png", + }) + + expect( + yield* compaction.compactIfNeeded({ + sessionID: SessionV2.ID.make("ses_media_compaction"), + messages, + request, + }), + ).toBe(false) + expect(requests).toHaveLength(0) + }), +) + +it.effect("counts tool-call inputs as text context", () => + Effect.gen(function* () { + requests = [] + const db = (yield* Database.Service).db + const compaction = yield* SessionCompaction.Service + const text = "context ".repeat(4_500) + const sessionID = SessionV2.ID.make("ses_tool_input_compaction") + const inputModel = Model.make({ + id: "tool-input-model", + provider: "test", + route: OpenAIChat.route.with({ limits: { context: 30_000, output: 1_000 } }), + }) + const messages = [ + SessionMessage.User.make({ + id: SessionMessage.ID.create(), + type: "user", + text, + time: { created: DateTime.makeUnsafe(0) }, + }), + SessionMessage.User.make({ + id: SessionMessage.ID.create(), + type: "user", + text: "Continue", + time: { created: DateTime.makeUnsafe(1) }, + }), + ] + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "tool-input-compaction", + directory: "/project", + title: "Tool input compaction", + version: "test", + }) + .run() + .pipe(Effect.orDie) + + expect( + yield* compaction.compactIfNeeded({ + sessionID, + messages, + request: LLM.request({ + model: inputModel, + messages: [ + Message.user(text), + Message.assistant(ToolCallPart.make({ id: "call_read", name: "read", input: { path: "x".repeat(8_000) } })), + ], + }), + }), + ).toBe(true) + expect(requests).toHaveLength(1) + }), +) + test("compaction prompt requires the checkpoint headings in order", () => { const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] }) expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([