Compare commits

...

5 Commits

Author SHA1 Message Date
Aiden Cline caec8d6638 refactor(core): tune retained media budget 2026-08-04 23:39:05 -05:00
Aiden Cline 0c89e24aac fix(core): budget retained image tokens 2026-08-04 22:40:58 -05:00
Aiden Cline b4ec03e5f1 refactor(core): simplify retained media 2026-08-04 22:36:05 -05:00
Aiden Cline af64ffa5d6 refactor(core): generalize retained compaction media 2026-08-04 22:24:56 -05:00
Aiden Cline 1b708aa098 feat(core): preserve compaction tail images 2026-08-04 22:13:21 -05:00
11 changed files with 203 additions and 44 deletions
+8
View File
@@ -764,6 +764,14 @@ export type Endpoint5_26Output =
readonly reason: "auto" | "manual" readonly reason: "auto" | "manual"
readonly text: string readonly text: string
readonly recent: string readonly recent: string
readonly media?:
| ReadonlyArray<{
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | undefined
}>
| undefined
} }
} }
| { | {
+22 -21
View File
@@ -121,17 +121,6 @@ export type SessionMessageCompactionRunning = {
recent: string recent: string
} }
export type SessionMessageCompactionCompleted = {
type: "compaction"
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
summary: string
recent: string
}
export type InstructionEntryKey = string export type InstructionEntryKey = string
export type SessionGenerateResponse = { data: { text: string } } export type SessionGenerateResponse = { data: { text: string } }
@@ -777,16 +766,6 @@ export type SessionCompactionStarted = {
data: { sessionID: string; reason: "auto" | "manual"; recent: string; inputID?: string } data: { sessionID: string; reason: "auto" | "manual"; recent: string; inputID?: string }
} }
export type SessionCompactionEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; reason: "auto" | "manual"; text: string; recent: string }
}
export type SessionRevertCleared = { export type SessionRevertCleared = {
id: string id: string
created: number created: number
@@ -1227,6 +1206,18 @@ export type SessionMessageAssistantReasoning = {
export type ToolContent = ToolTextContent | ToolFileContent export type ToolContent = ToolTextContent | ToolFileContent
export type SessionMessageCompactionCompleted = {
type: "compaction"
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
summary: string
recent: string
media?: Array<ToolFileContent>
}
export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError } export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError }
export type SessionMessageCompactionFailed = { export type SessionMessageCompactionFailed = {
@@ -1389,6 +1380,16 @@ export type SessionToolCalled = {
export type ToolContent1 = ToolTextContent | ToolFileContent1 export type ToolContent1 = ToolTextContent | ToolFileContent1
export type SessionCompactionEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; reason: "auto" | "manual"; text: string; recent: string; media?: Array<ToolFileContent1> }
}
export type ModelCompatibility = { reasoningField?: ModelReasoningField } export type ModelCompatibility = { reasoningField?: ModelReasoningField }
export type ModelCost = { export type ModelCost = {
+61 -7
View File
@@ -2,6 +2,7 @@ export * as SessionCompaction from "./compaction"
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai" import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai"
import { SessionError } from "@opencode-ai/schema/session-error" import { SessionError } from "@opencode-ai/schema/session-error"
import { Tool } from "@opencode-ai/schema/tool"
import { Context, Effect, Layer, Stream } from "effect" import { Context, Effect, Layer, Stream } from "effect"
import { Config } from "../config" import { Config } from "../config"
import { Bus } from "../bus" import { Bus } from "../bus"
@@ -19,9 +20,10 @@ import type { Info } from "../model"
import { SessionUsage } from "./usage" import { SessionUsage } from "./usage"
const DEFAULT_BUFFER = 20_000 const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 8_000 const DEFAULT_KEEP_TOKENS = 15_000
const OUTPUT_TOKEN_MAX = 32_000 const OUTPUT_TOKEN_MAX = 32_000
const TOOL_OUTPUT_MAX_CHARS = 2_000 const TOOL_OUTPUT_MAX_CHARS = 2_000
const MEDIA_TOKEN_ESTIMATE = 1_500
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response. const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
<template> <template>
## Objective ## Objective
@@ -90,6 +92,7 @@ type Plan = {
readonly reason: SessionMessage.Compaction["reason"] readonly reason: SessionMessage.Compaction["reason"]
readonly prompt: string readonly prompt: string
readonly recent: string readonly recent: string
readonly media: readonly Tool.FileContent[]
readonly inputID?: SessionMessage.ID readonly inputID?: SessionMessage.ID
} }
@@ -108,6 +111,15 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
const truncate = (value: string) => const truncate = (value: string) =>
value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]` value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]`
const isMedia = (mime: string) => {
const value = mime.toLowerCase()
return (
value.startsWith("image/") ||
value.startsWith("audio/") ||
value.startsWith("video/") ||
value === "application/pdf"
)
}
export const serializeToolContent = (content: SessionMessage.ToolStateCompleted["content"]) => export const serializeToolContent = (content: SessionMessage.ToolStateCompleted["content"]) =>
content content
.map((item) => .map((item) =>
@@ -115,6 +127,24 @@ export const serializeToolContent = (content: SessionMessage.ToolStateCompleted[
) )
.join("\n") .join("\n")
const isEstimatedMedia = (mime: string) =>
mime.toLowerCase().startsWith("image/") || mime.toLowerCase() === "application/pdf"
export const estimateMediaTokens = (message: SessionMessage.Info) => {
if (message.type === "user")
return (message.files?.filter((file) => isEstimatedMedia(file.mime)).length ?? 0) * MEDIA_TOKEN_ESTIMATE
if (message.type !== "assistant") return 0
return (
message.content
.flatMap((part) =>
part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")
? (part.state.content ?? [])
: [],
)
.filter((content) => content.type === "file" && isEstimatedMedia(content.mime)).length * MEDIA_TOKEN_ESTIMATE
)
}
const serialize = (message: SessionMessage.Info) => { const serialize = (message: SessionMessage.Info) => {
if (message.type === "user") { if (message.type === "user") {
const files = const files =
@@ -162,7 +192,11 @@ const settings = (documents: readonly Config.Entry[]) => {
const select = ( const select = (
messages: readonly SessionMessage.Info[], messages: readonly SessionMessage.Info[],
tokens: number, tokens: number,
): { readonly head: string; readonly recent: string } | undefined => { ): {
readonly head: string
readonly recent: string
readonly media: readonly Tool.FileContent[]
} | undefined => {
const conversation = messages const conversation = messages
.filter((message) => message.type !== "compaction" && message.type !== "system") .filter((message) => message.type !== "compaction" && message.type !== "system")
.flatMap((message) => { .flatMap((message) => {
@@ -173,7 +207,7 @@ const select = (
let total = 0 let total = 0
let split = conversation.length let split = conversation.length
for (let index = conversation.length - 1; index >= 0; index--) { for (let index = conversation.length - 1; index >= 0; index--) {
const next = total + Token.estimate(conversation[index].text) const next = total + Token.estimate(conversation[index].text) + estimateMediaTokens(conversation[index].message)
if (split < conversation.length && next > tokens) break if (split < conversation.length && next > tokens) break
total = next total = next
split = index split = index
@@ -183,15 +217,33 @@ const select = (
const latestUser = conversation.findLastIndex((item) => item.message.type === "user") const latestUser = conversation.findLastIndex((item) => item.message.type === "user")
if (latestUser > 0) split = latestUser if (latestUser > 0) split = latestUser
} }
const tail = conversation.slice(split)
return { return {
head: conversation head: conversation
.slice(0, split) .slice(0, split)
.map((item) => item.text) .map((item) => item.text)
.join("\n\n"), .join("\n\n"),
recent: conversation recent: tail.map((item) => item.text).join("\n\n"),
.slice(split) media: tail.flatMap((item) => {
.map((item) => item.text) if (item.message.type === "user")
.join("\n\n"), return (
item.message.files
?.filter((file) => isMedia(file.mime))
.map((file) => ({
type: "file" as const,
uri: `data:${file.mime};base64,${file.data}`,
mime: file.mime,
name: file.name,
})) ?? []
)
if (item.message.type !== "assistant") return []
return item.message.content.flatMap((part) => {
if (part.type !== "tool" || (part.state.status !== "completed" && part.state.status !== "error")) return []
return (part.state.content ?? []).flatMap((content) =>
content.type === "file" && isMedia(content.mime) ? [content] : [],
)
})
}),
} }
} }
@@ -219,6 +271,7 @@ const planContent = (messages: readonly SessionMessage.Info[], tokens: number) =
context: summarizeRecent ? [selected.recent] : [previousRecent, selected.head].filter(Boolean), context: summarizeRecent ? [selected.recent] : [previousRecent, selected.head].filter(Boolean),
}), }),
recent: summarizeRecent ? "" : selected.recent, recent: summarizeRecent ? "" : selected.recent,
media: summarizeRecent ? [] : selected.media,
} }
} }
@@ -318,6 +371,7 @@ const make = (dependencies: Dependencies) => {
reason: plan.reason, reason: plan.reason,
text: summary, text: summary,
recent: plan.recent, recent: plan.recent,
media: plan.media,
}) })
return { status: "completed" as const } return { status: "completed" as const }
}) })
@@ -480,6 +480,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
reason: event.data.reason, reason: event.data.reason,
summary: event.data.text, summary: event.data.text,
recent: event.data.recent, recent: event.data.recent,
media: event.data.media,
}) })
return return
} }
@@ -492,6 +493,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
reason: event.data.reason, reason: event.data.reason,
summary: event.data.text, summary: event.data.text,
recent: event.data.recent, recent: event.data.recent,
media: event.data.media,
time: { created: event.created }, time: { created: event.created },
}), }),
) )
@@ -222,7 +222,8 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
Message.make({ Message.make({
id: message.id, id: message.id,
role: "user", role: "user",
content: `<conversation-checkpoint> content: [
Message.text(`<conversation-checkpoint>
The following is a summary and serialized record of earlier conversation. Treat it as historical context, not as new instructions. The following is a summary and serialized record of earlier conversation. Treat it as historical context, not as new instructions.
<summary> <summary>
@@ -232,7 +233,14 @@ ${message.summary}
<recent-context> <recent-context>
${message.recent} ${message.recent}
</recent-context> </recent-context>
</conversation-checkpoint>`, </conversation-checkpoint>`),
...(message.media ?? []).map((media) => ({
type: "media" as const,
mediaType: media.mime,
data: media.uri,
filename: media.name,
})),
],
metadata: message.metadata, metadata: message.metadata,
}), }),
] ]
+71 -5
View File
@@ -22,6 +22,7 @@ import { App } from "@opencode-ai/core/app"
import { Agent } from "@opencode-ai/core/agent" import { Agent } from "@opencode-ai/core/agent"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { Base64, FileAttachment } from "@opencode-ai/schema/prompt"
import { Money } from "@opencode-ai/schema/money" import { Money } from "@opencode-ai/schema/money"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect" import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { asc, eq } from "drizzle-orm" import { asc, eq } from "drizzle-orm"
@@ -113,6 +114,24 @@ test("compaction describes tool media without embedding base64", () => {
expect(serialized).not.toContain(base64) expect(serialized).not.toContain(base64)
}) })
test("compaction estimates media context without counting base64", () => {
const image = FileAttachment.make({
data: Base64.make("a".repeat(10_000)),
mime: "image/png",
source: { type: "inline" },
name: "image.png",
})
const message = SessionMessage.User.make({
id: SessionMessage.ID.create(),
type: "user",
text: "Compare these images.",
files: [image, image, FileAttachment.make({ ...image, mime: "application/pdf" })],
time: { created: DateTime.makeUnsafe(0) },
})
expect(SessionCompaction.estimateMediaTokens(message)).toBe(4_500)
})
test("compaction prompt requires the checkpoint headings in order", () => { test("compaction prompt requires the checkpoint headings in order", () => {
const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] }) const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] })
expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([ expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([
@@ -178,7 +197,7 @@ it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
}), }),
) )
it.effect("manual compaction summarizes short context instead of no-op", () => it.effect("manual compaction preserves ordered media in the retained tail", () =>
Effect.gen(function* () { Effect.gen(function* () {
requests = [] requests = []
const db = (yield* Database.Service).db const db = (yield* Database.Service).db
@@ -190,9 +209,35 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
const userMessage = { const userMessage = {
id: SessionMessage.ID.create(), id: SessionMessage.ID.create(),
type: "user" as const, type: "user" as const,
text: "Manual compaction should include this short conversation.", text: `Manual compaction should include this older conversation. ${"older context ".repeat(4_500)}`,
time: { created: DateTime.makeUnsafe(0) }, time: { created: DateTime.makeUnsafe(0) },
} }
const recentMessage = SessionMessage.User.make({
id: SessionMessage.ID.create(),
type: "user",
text: "Compare the retained media.",
files: [
FileAttachment.make({
data: Base64.make("aW1hZ2U="),
mime: "application/pdf",
source: { type: "inline" },
name: "prompt.pdf",
}),
FileAttachment.make({
data: Base64.make("aW1hZ2U="),
mime: "image/png",
source: { type: "inline" },
name: "prompt.png",
}),
],
time: { created: DateTime.makeUnsafe(1) },
})
const latestMessage = SessionMessage.User.make({
id: SessionMessage.ID.create(),
type: "user",
text: "Newest text after the retained media.",
time: { created: DateTime.makeUnsafe(2) },
})
yield* db yield* db
.insert(ProjectTable) .insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
@@ -228,7 +273,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
expect( expect(
yield* compaction.compactManual({ yield* compaction.compactManual({
session, session,
messages: [userMessage], messages: [userMessage, recentMessage, latestMessage],
inputID: SessionMessage.ID.make("msg_manual_compaction"), inputID: SessionMessage.ID.make("msg_manual_compaction"),
}), }),
).toEqual({ status: "completed" }) ).toEqual({ status: "completed" })
@@ -245,9 +290,30 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
"x-opencode-client": "opencode", "x-opencode-client": "opencode",
}) })
expect(requests[0]?.generation).toBeUndefined() expect(requests[0]?.generation).toBeUndefined()
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.") expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this older conversation.")
expect(yield* store.context(sessionID)).toMatchObject([ expect(yield* store.context(sessionID)).toMatchObject([
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" }, {
type: "compaction",
reason: "manual",
summary: "manual summary",
recent: expect.stringMatching(
/\[User\]: Compare the retained media\.\n\[Attached application\/pdf: prompt\.pdf\]\n\[Attached image\/png: prompt\.png\]\n\n\[User\]: Newest text after the retained media\./,
),
media: [
{
type: "file",
uri: "data:application/pdf;base64,aW1hZ2U=",
mime: "application/pdf",
name: "prompt.pdf",
},
{
type: "file",
uri: "data:image/png;base64,aW1hZ2U=",
mime: "image/png",
name: "prompt.png",
},
],
},
]) ])
expect(yield* store.get(sessionID)).toMatchObject({ expect(yield* store.get(sessionID)).toMatchObject({
cost: 0.0000233, cost: 0.0000233,
@@ -102,7 +102,15 @@ describe("toLLMMessages", () => {
status: "completed", status: "completed",
reason: "auto", reason: "auto",
summary: "Earlier work", summary: "Earlier work",
recent: "Recent work", recent: "Recent work\n[Attached image/png: retained.png]",
media: [
{
type: "file",
uri: "data:image/png;base64,aGVsbG8=",
mime: "image/png",
name: "retained.png",
},
],
time: { created }, time: { created },
}), }),
], ],
@@ -142,9 +150,16 @@ Earlier work
<recent-context> <recent-context>
Recent work Recent work
[Attached image/png: retained.png]
</recent-context> </recent-context>
</conversation-checkpoint>`, </conversation-checkpoint>`,
}, },
{
type: "media",
mediaType: "image/png",
data: "data:image/png;base64,aGVsbG8=",
filename: "retained.png",
},
], ],
]) ])
}) })
+2 -1
View File
@@ -4,7 +4,7 @@ import { Schema } from "effect"
import { optional } from "./schema.js" import { optional } from "./schema.js"
import { Event } from "./event.js" import { Event } from "./event.js"
import { FinishReason } from "./llm.js" import { FinishReason } from "./llm.js"
import { Content } from "./tool.js" import { Content, FileContent } from "./tool.js"
import { Model } from "./model.js" import { Model } from "./model.js"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
import { FileAttachment } from "./prompt.js" import { FileAttachment } from "./prompt.js"
@@ -515,6 +515,7 @@ export namespace Compaction {
reason: Started.data.fields.reason, reason: Started.data.fields.reason,
text: Schema.String, text: Schema.String,
recent: Schema.String, recent: Schema.String,
media: Schema.Array(FileContent).pipe(optional),
}, },
}) })
export type Ended = typeof Ended.Type export type Ended = typeof Ended.Type
+2 -1
View File
@@ -2,7 +2,7 @@ export * as SessionMessage from "./session-message.js"
import { Schema } from "effect" import { Schema } from "effect"
import { optional } from "./schema.js" import { optional } from "./schema.js"
import { Content } from "./tool.js" import { Content, FileContent } from "./tool.js"
import { Model } from "./model.js" import { Model } from "./model.js"
import { Prompt } from "./prompt.js" import { Prompt } from "./prompt.js"
import { DateTimeUtcFromMillis, PositiveInt, RelativePath, statics } from "./schema.js" import { DateTimeUtcFromMillis, PositiveInt, RelativePath, statics } from "./schema.js"
@@ -222,6 +222,7 @@ export const CompactionCompleted = Schema.Struct({
reason: Schema.Literals(["auto", "manual"]), reason: Schema.Literals(["auto", "manual"]),
summary: Schema.String, summary: Schema.String,
recent: Schema.String, recent: Schema.String,
media: Schema.Array(FileContent).pipe(optional),
}).annotate({ identifier: "Session.Message.Compaction.Completed" }) }).annotate({ identifier: "Session.Message.Compaction.Completed" })
export interface CompactionFailed extends Schema.Schema.Type<typeof CompactionFailed> {} export interface CompactionFailed extends Schema.Schema.Type<typeof CompactionFailed> {}
@@ -83,7 +83,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
"auto": true, "auto": true,
"prune": false, "prune": false,
"keep": { "keep": {
"tokens": 8000 "tokens": 15000
}, },
"buffer": 20000 "buffer": 20000
} }
@@ -94,7 +94,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
| --- | ---: | --- | | --- | ---: | --- |
| `auto` | `true` | Runs the preflight context-size check. It does not disable manual compaction or one-shot provider-overflow recovery. | | `auto` | `true` | Runs the preflight context-size check. It does not disable manual compaction or one-shot provider-overflow recovery. |
| `prune` | None | Accepted by the V2 schema, but currently has no runtime effect. V2 does not prune old tool outputs in place. | | `prune` | None | Accepted by the V2 schema, but currently has no runtime effect. V2 does not prune old tool outputs in place. |
| `keep.tokens` | `8000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. | | `keep.tokens` | `15000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
| `buffer` | `20000` | Safety reserve below an explicit input limit. Without one, it is the minimum context reserve and the model output allowance wins when larger. | | `buffer` | `20000` | Safety reserve below an explicit input limit. Without one, it is the minimum context reserve and the model output allowance wins when larger. |
`keep.tokens` and `buffer` accept non-negative integers. Larger `keep.tokens` `keep.tokens` and `buffer` accept non-negative integers. Larger `keep.tokens`
@@ -110,9 +110,12 @@ and relevant files.
The newest serialized context up to `keep.tokens` is retained separately. This The newest serialized context up to `keep.tokens` is retained separately. This
is not a byte-for-byte transcript: tool output is limited to 2000 characters, is not a byte-for-byte transcript: tool output is limited to 2000 characters,
and file or media attachments become textual descriptors rather than embedded and non-media attachments become textual descriptors. Media in the retained
data. On later compactions, V2 updates the previous summary and carries forward context is attached to the checkpoint in the same order as its descriptors.
its retained recent context before selecting a new tail. Tail selection budgets 1500 additional tokens per image or PDF as a
provider-neutral planning estimate; it does not count base64 request bytes as
text tokens. On later compactions, V2 updates the previous summary and carries
forward its retained recent context before selecting a new tail.
The completed compaction is presented to the model as historical conversation The completed compaction is presented to the model as historical conversation
context, explicitly not as new instructions. Running and failed compactions are context, explicitly not as new instructions. Running and failed compactions are
+1 -1
View File
@@ -329,7 +329,7 @@ Control automatic context compaction and how much recent context it preserves.
"compaction": { "compaction": {
"auto": true, "auto": true,
"keep": { "keep": {
"tokens": 8000 "tokens": 15000
}, },
"buffer": 20000 "buffer": 20000
} }