refactor(core): tune retained media budget

This commit is contained in:
Aiden Cline
2026-08-04 23:39:05 -05:00
parent 0c89e24aac
commit caec8d6638
3 changed files with 40 additions and 18 deletions
+8 -6
View File
@@ -23,7 +23,7 @@ const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 15_000
const OUTPUT_TOKEN_MAX = 32_000
const TOOL_OUTPUT_MAX_CHARS = 2_000
const IMAGE_TOKEN_ESTIMATE = 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.
<template>
## Objective
@@ -127,9 +127,12 @@ export const serializeToolContent = (content: SessionMessage.ToolStateCompleted[
)
.join("\n")
export const estimateImageTokens = (message: SessionMessage.Info) => {
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) => file.mime.toLowerCase().startsWith("image/")).length ?? 0) * IMAGE_TOKEN_ESTIMATE
return (message.files?.filter((file) => isEstimatedMedia(file.mime)).length ?? 0) * MEDIA_TOKEN_ESTIMATE
if (message.type !== "assistant") return 0
return (
message.content
@@ -138,8 +141,7 @@ export const estimateImageTokens = (message: SessionMessage.Info) => {
? (part.state.content ?? [])
: [],
)
.filter((content) => content.type === "file" && content.mime.toLowerCase().startsWith("image/")).length *
IMAGE_TOKEN_ESTIMATE
.filter((content) => content.type === "file" && isEstimatedMedia(content.mime)).length * MEDIA_TOKEN_ESTIMATE
)
}
@@ -205,7 +207,7 @@ const select = (
let total = 0
let split = conversation.length
for (let index = conversation.length - 1; index >= 0; index--) {
const next = total + Token.estimate(conversation[index].text) + estimateImageTokens(conversation[index].message)
const next = total + Token.estimate(conversation[index].text) + estimateMediaTokens(conversation[index].message)
if (split < conversation.length && next > tokens) break
total = next
split = index
+28 -8
View File
@@ -114,7 +114,7 @@ test("compaction describes tool media without embedding base64", () => {
expect(serialized).not.toContain(base64)
})
test("compaction estimates image context without counting base64", () => {
test("compaction estimates media context without counting base64", () => {
const image = FileAttachment.make({
data: Base64.make("a".repeat(10_000)),
mime: "image/png",
@@ -125,11 +125,11 @@ test("compaction estimates image context without counting base64", () => {
id: SessionMessage.ID.create(),
type: "user",
text: "Compare these images.",
files: [image, image],
files: [image, image, FileAttachment.make({ ...image, mime: "application/pdf" })],
time: { created: DateTime.makeUnsafe(0) },
})
expect(SessionCompaction.estimateImageTokens(message)).toBe(4_000)
expect(SessionCompaction.estimateMediaTokens(message)).toBe(4_500)
})
test("compaction prompt requires the checkpoint headings in order", () => {
@@ -197,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* () {
requests = []
const db = (yield* Database.Service).db
@@ -209,7 +209,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
const userMessage = {
id: SessionMessage.ID.create(),
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) },
}
const recentMessage = SessionMessage.User.make({
@@ -223,9 +223,21 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
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
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
@@ -261,7 +273,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
expect(
yield* compaction.compactManual({
session,
messages: [userMessage, recentMessage],
messages: [userMessage, recentMessage, latestMessage],
inputID: SessionMessage.ID.make("msg_manual_compaction"),
}),
).toEqual({ status: "completed" })
@@ -278,13 +290,15 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
"x-opencode-client": "opencode",
})
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([
{
type: "compaction",
reason: "manual",
summary: "manual summary",
recent: expect.stringContaining("[Attached application/pdf: prompt.pdf]"),
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",
@@ -292,6 +306,12 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
mime: "application/pdf",
name: "prompt.pdf",
},
{
type: "file",
uri: "data:image/png;base64,aW1hZ2U=",
mime: "image/png",
name: "prompt.png",
},
],
},
])
@@ -112,10 +112,10 @@ 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,
and non-media attachments become textual descriptors. Media in the retained
context is attached to the checkpoint in the same order as its descriptors.
Tail selection budgets 2000 additional tokens per image 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.
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
context, explicitly not as new instructions. Running and failed compactions are