Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 55 additions & 6 deletions packages/core/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,60 @@ export interface Interface {

export class Service extends Context.Service<Service, Interface>()("@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]`
Expand Down Expand Up @@ -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 {
Expand Down
149 changes: 147 additions & 2 deletions packages/core/test/session-compaction.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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([
Expand Down
Loading