Skip to content

Commit 42771c1

Browse files
committed
fix(compaction): budget retained tail with media
1 parent 2e18a60 commit 42771c1

4 files changed

Lines changed: 83 additions & 29 deletions

File tree

packages/opencode/src/config/config.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,13 +1009,15 @@ export const Info = z
10091009
.int()
10101010
.min(0)
10111011
.optional()
1012-
.describe("Number of recent real user turns to keep verbatim during compaction (default: 2)"),
1012+
.describe(
1013+
"Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)",
1014+
),
10131015
tail_tokens: z
10141016
.number()
10151017
.int()
10161018
.min(0)
10171019
.optional()
1018-
.describe("Token budget for retained recent turns during compaction"),
1020+
.describe("Token budget for retained recent turn spans during compaction"),
10191021
reserved: z
10201022
.number()
10211023
.int()

packages/opencode/src/session/compaction.ts

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { BusEvent } from "@/bus/bus-event"
22
import { Bus } from "@/bus"
33
import * as Session from "./session"
44
import { SessionID, MessageID, PartID } from "./schema"
5-
import { Provider, ProviderTransform } from "../provider"
5+
import { Provider } from "../provider"
66
import { MessageV2 } from "./message-v2"
77
import z from "zod"
88
import { Token } from "../util"
@@ -17,7 +17,7 @@ import { Effect, Layer, Context } from "effect"
1717
import { InstanceState } from "@/effect"
1818
import { makeRuntime } from "@/effect/run-service"
1919
import { fn } from "@/util/fn"
20-
import { isOverflow as overflow } from "./overflow"
20+
import { isOverflow as overflow, usable } from "./overflow"
2121

2222
export namespace SessionCompaction {
2323
const log = Log.create({ service: "session.compaction" })
@@ -43,13 +43,6 @@ export namespace SessionCompaction {
4343
id: MessageID
4444
}
4545

46-
function usable(input: { cfg: Config.Info; model: Provider.Model }) {
47-
const reserved = input.cfg.compaction?.reserved ?? Math.min(20_000, ProviderTransform.maxOutputTokens(input.model))
48-
return input.model.limit.input
49-
? Math.max(0, input.model.limit.input - reserved)
50-
: Math.max(0, input.model.limit.context - ProviderTransform.maxOutputTokens(input.model))
51-
}
52-
5346
function tailBudget(input: { cfg: Config.Info; model: Provider.Model }) {
5447
return (
5548
input.cfg.compaction?.tail_tokens ??
@@ -131,7 +124,7 @@ export namespace SessionCompaction {
131124
messages: MessageV2.WithParts[]
132125
model: Provider.Model
133126
}) {
134-
const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model, { stripMedia: true })
127+
const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model)
135128
return Token.estimate(JSON.stringify(msgs))
136129
})
137130

@@ -282,14 +275,7 @@ export namespace SessionCompaction {
282275
{ sessionID: input.sessionID },
283276
{ context: [], prompt: undefined },
284277
)
285-
const defaultPrompt = `Summarize the older conversation history so another agent can continue the work with the retained recent turns.
286-
The most recent conversation turns will remain verbatim outside this summary, so focus on older context that is still needed to understand and continue the work.
287-
Include what we did, what we're doing, which files we're working on, and what we're going to do next.
288-
The summary that you construct will be used so that another agent can read it and continue the work.
289-
Do not call any tools. Respond only with the summary text.
290-
Respond in the same language as the user's messages in the conversation.
291-
292-
When constructing the summary, try to stick to this template:
278+
const defaultPrompt = `When constructing the summary, try to stick to this template:
293279
---
294280
## Goal
295281

packages/opencode/src/session/overflow.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,22 @@ import type { MessageV2 } from "./message-v2"
55

66
const COMPACTION_BUFFER = 20_000
77

8+
export function usable(input: { cfg: Config.Info; model: Provider.Model }) {
9+
const context = input.model.limit.context
10+
if (context === 0) return 0
11+
12+
const reserved =
13+
input.cfg.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model))
14+
return input.model.limit.input
15+
? Math.max(0, input.model.limit.input - reserved)
16+
: Math.max(0, context - ProviderTransform.maxOutputTokens(input.model))
17+
}
18+
819
export function isOverflow(input: { cfg: Config.Info; tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
920
if (input.cfg.compaction?.auto === false) return false
10-
const context = input.model.limit.context
11-
if (context === 0) return false
21+
if (input.model.limit.context === 0) return false
1222

1323
const count =
1424
input.tokens.total || input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write
15-
16-
const reserved =
17-
input.cfg.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model))
18-
const usable = input.model.limit.input
19-
? input.model.limit.input - reserved
20-
: context - ProviderTransform.maxOutputTokens(input.model)
21-
return count >= usable
25+
return count >= usable(input)
2226
}

packages/opencode/test/session/compaction.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,6 +1044,68 @@ describe("session.compaction.process", () => {
10441044
})
10451045
})
10461046

1047+
test("falls back to full summary when retained tail media exceeds tail budget", async () => {
1048+
await using tmp = await tmpdir({ git: true })
1049+
const stub = llm()
1050+
let captured = ""
1051+
stub.push(
1052+
reply("summary", (input) => {
1053+
captured = JSON.stringify(input.messages)
1054+
}),
1055+
)
1056+
await Instance.provide({
1057+
directory: tmp.path,
1058+
fn: async () => {
1059+
const session = await svc.create({})
1060+
await user(session.id, "older")
1061+
const recent = await user(session.id, "recent image turn")
1062+
await svc.updatePart({
1063+
id: PartID.ascending(),
1064+
messageID: recent.id,
1065+
sessionID: session.id,
1066+
type: "file",
1067+
mime: "image/png",
1068+
filename: "big.png",
1069+
url: `data:image/png;base64,${"a".repeat(4_000)}`,
1070+
})
1071+
await SessionCompaction.create({
1072+
sessionID: session.id,
1073+
agent: "build",
1074+
model: ref,
1075+
auto: false,
1076+
})
1077+
1078+
const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 1, tail_tokens: 100 }))
1079+
try {
1080+
const msgs = await svc.messages({ sessionID: session.id })
1081+
const parent = msgs.at(-1)?.info.id
1082+
expect(parent).toBeTruthy()
1083+
await rt.runPromise(
1084+
SessionCompaction.Service.use((svc) =>
1085+
svc.process({
1086+
parentID: parent!,
1087+
messages: msgs,
1088+
sessionID: session.id,
1089+
auto: false,
1090+
}),
1091+
),
1092+
)
1093+
1094+
const part = (await svc.messages({ sessionID: session.id }))
1095+
.at(-2)
1096+
?.parts.find((item) => item.type === "compaction")
1097+
1098+
expect(part?.type).toBe("compaction")
1099+
if (part?.type === "compaction") expect(part.tail_start_id).toBeUndefined()
1100+
expect(captured).toContain("recent image turn")
1101+
expect(captured).toContain("Attached image/png: big.png")
1102+
} finally {
1103+
await rt.dispose()
1104+
}
1105+
},
1106+
})
1107+
})
1108+
10471109
test("allows plugins to disable synthetic continue prompt", async () => {
10481110
await using tmp = await tmpdir()
10491111
await Instance.provide({

0 commit comments

Comments
 (0)