Compare commits

...

7 Commits

Author SHA1 Message Date
Aiden Cline 2acf08cca0 fix(core): harden semantic context estimation 2026-07-06 18:41:04 -05:00
Aiden Cline a48d38b6cd fix(core): estimate semantic request context 2026-07-06 18:15:18 -05:00
Aiden Cline ad550c544e fix(core): preserve text attachments in context estimate 2026-07-06 17:58:17 -05:00
Aiden Cline f953a8f7e3 docs(core): explain attachment estimate filtering 2026-07-06 17:22:08 -05:00
Aiden Cline 6cc8195572 Merge remote-tracking branch 'origin/v2' into media-attachment
# Conflicts:
#	packages/core/test/session-compaction.test.ts
2026-07-06 17:15:50 -05:00
Aiden Cline 7b022ed71f fix(core): filter attachments from context estimate 2026-07-06 17:12:43 -05:00
Aiden Cline 3963453f8d fix(core): exclude media bytes from compaction estimate 2026-07-06 16:45:30 -05:00
2 changed files with 202 additions and 8 deletions
+55 -6
View File
@@ -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]`
@@ -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 {
+147 -2
View File
@@ -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([