mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-08 18:30:00 -04:00
refactor(core): tune retained media budget
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user