Skip to content

Commit 4fdea1f

Browse files
Apply PR #21822: refactor: improve compaction to retain recent conversation context
2 parents e8030f9 + 9819eb0 commit 4fdea1f

6 files changed

Lines changed: 610 additions & 16 deletions

File tree

packages/opencode/src/agent/prompt/compaction.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
You are a helpful AI assistant tasked with summarizing conversations.
22

3-
When asked to summarize, provide a detailed but concise summary of the conversation.
3+
When asked to summarize, provide a detailed but concise summary of the older conversation history.
4+
The most recent turns may be preserved verbatim outside your summary, so focus on information that would still be needed to continue the work with that recent context available.
45
Focus on information that would be helpful for continuing the conversation, including:
56
- What was done
67
- What is currently being worked on

packages/opencode/src/config/config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1074,6 +1074,18 @@ export namespace Config {
10741074
.object({
10751075
auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"),
10761076
prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"),
1077+
tail_turns: z
1078+
.number()
1079+
.int()
1080+
.min(0)
1081+
.optional()
1082+
.describe("Number of recent real user turns to keep verbatim during compaction (default: 2)"),
1083+
tail_tokens: z
1084+
.number()
1085+
.int()
1086+
.min(0)
1087+
.optional()
1088+
.describe("Token budget for retained recent turns during compaction"),
10771089
reserved: z
10781090
.number()
10791091
.int()

packages/opencode/src/session/compaction.ts

Lines changed: 110 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Session } from "."
44
import { SessionID, MessageID, PartID } from "./schema"
55
import { Instance } from "../project/instance"
66
import { Provider } from "../provider/provider"
7+
import { ProviderTransform } from "../provider/transform"
78
import { MessageV2 } from "./message-v2"
89
import z from "zod"
910
import { Token } from "../util/token"
@@ -35,6 +36,46 @@ export namespace SessionCompaction {
3536
export const PRUNE_MINIMUM = 20_000
3637
export const PRUNE_PROTECT = 40_000
3738
const PRUNE_PROTECTED_TOOLS = ["skill"]
39+
const DEFAULT_TAIL_TURNS = 2
40+
const MIN_TAIL_TOKENS = 2_000
41+
const MAX_TAIL_TOKENS = 8_000
42+
type Turn = {
43+
start: number
44+
end: number
45+
id: MessageID
46+
}
47+
48+
function usable(input: { cfg: Config.Info; model: Provider.Model }) {
49+
const reserved = input.cfg.compaction?.reserved ?? Math.min(20_000, ProviderTransform.maxOutputTokens(input.model))
50+
return input.model.limit.input
51+
? Math.max(0, input.model.limit.input - reserved)
52+
: Math.max(0, input.model.limit.context - ProviderTransform.maxOutputTokens(input.model))
53+
}
54+
55+
function tailBudget(input: { cfg: Config.Info; model: Provider.Model }) {
56+
return (
57+
input.cfg.compaction?.tail_tokens ??
58+
Math.min(MAX_TAIL_TOKENS, Math.max(MIN_TAIL_TOKENS, Math.floor(usable(input) * 0.25)))
59+
)
60+
}
61+
62+
function turns(messages: MessageV2.WithParts[]) {
63+
const result: Turn[] = []
64+
for (let i = 0; i < messages.length; i++) {
65+
const msg = messages[i]
66+
if (msg.info.role !== "user") continue
67+
if (msg.parts.some((part) => part.type === "compaction")) continue
68+
result.push({
69+
start: i,
70+
end: messages.length,
71+
id: msg.info.id,
72+
})
73+
}
74+
for (let i = 0; i < result.length - 1; i++) {
75+
result[i].end = result[i + 1].start
76+
}
77+
return result
78+
}
3879

3980
export interface Interface {
4081
readonly isOverflow: (input: {
@@ -88,11 +129,60 @@ export namespace SessionCompaction {
88129
return overflow({ cfg: yield* config.get(), tokens: input.tokens, model: input.model })
89130
})
90131

132+
const estimate = Effect.fn("SessionCompaction.estimate")(function* (input: {
133+
messages: MessageV2.WithParts[]
134+
model: Provider.Model
135+
}) {
136+
const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model, { stripMedia: true })
137+
return Token.estimate(JSON.stringify(msgs))
138+
})
139+
140+
const select = Effect.fn("SessionCompaction.select")(function* (input: {
141+
messages: MessageV2.WithParts[]
142+
cfg: Config.Info
143+
model: Provider.Model
144+
}) {
145+
const limit = input.cfg.compaction?.tail_turns ?? DEFAULT_TAIL_TURNS
146+
if (limit <= 0) return { head: input.messages, tail_start_id: undefined }
147+
const budget = tailBudget({ cfg: input.cfg, model: input.model })
148+
const all = turns(input.messages)
149+
if (!all.length) return { head: input.messages, tail_start_id: undefined }
150+
const recent = all.slice(-limit)
151+
const sizes = yield* Effect.forEach(
152+
recent,
153+
(turn) =>
154+
estimate({
155+
messages: input.messages.slice(turn.start, turn.end),
156+
model: input.model,
157+
}),
158+
{ concurrency: 1 },
159+
)
160+
if (sizes.at(-1)! > budget) {
161+
log.info("tail fallback", { budget, size: sizes.at(-1) })
162+
return { head: input.messages, tail_start_id: undefined }
163+
}
164+
165+
let total = 0
166+
let keep: Turn | undefined
167+
for (let i = recent.length - 1; i >= 0; i--) {
168+
const size = sizes[i]
169+
if (total + size > budget) break
170+
total += size
171+
keep = recent[i]
172+
}
173+
174+
if (!keep || keep.start === 0) return { head: input.messages, tail_start_id: undefined }
175+
return {
176+
head: input.messages.slice(0, keep.start),
177+
tail_start_id: keep.id,
178+
}
179+
})
180+
91181
// goes backwards through parts until there are PRUNE_PROTECT tokens worth of tool
92182
// calls, then erases output of older tool calls to free context space
93183
const prune = Effect.fn("SessionCompaction.prune")(function* (input: { sessionID: SessionID }) {
94184
const cfg = yield* config.get()
95-
if (cfg.compaction?.prune === false) return
185+
if (cfg.compaction?.prune !== true) return
96186
log.info("pruning")
97187

98188
const msgs = yield* session
@@ -150,6 +240,7 @@ export namespace SessionCompaction {
150240
throw new Error(`Compaction parent must be a user message: ${input.parentID}`)
151241
}
152242
const userMessage = parent.info
243+
const compactionPart = parent.parts.find((part): part is MessageV2.CompactionPart => part.type === "compaction")
153244

154245
let messages = input.messages
155246
let replay:
@@ -180,14 +271,22 @@ export namespace SessionCompaction {
180271
const model = agent.model
181272
? yield* provider.getModel(agent.model.providerID, agent.model.modelID)
182273
: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
274+
const cfg = yield* config.get()
275+
const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages
276+
const selected = yield* select({
277+
messages: history,
278+
cfg,
279+
model,
280+
})
183281
// Allow plugins to inject context or replace compaction prompt.
184282
const compacting = yield* plugin.trigger(
185283
"experimental.session.compacting",
186284
{ sessionID: input.sessionID },
187285
{ context: [], prompt: undefined },
188286
)
189-
const defaultPrompt = `Provide a detailed prompt for continuing our conversation above.
190-
Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.
287+
const defaultPrompt = `Summarize the older conversation history so another agent can continue the work with the retained recent turns.
288+
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.
289+
Include what we did, what we're doing, which files we're working on, and what we're going to do next.
191290
The summary that you construct will be used so that another agent can read it and continue the work.
192291
Do not call any tools. Respond only with the summary text.
193292
Respond in the same language as the user's messages in the conversation.
@@ -217,7 +316,7 @@ When constructing the summary, try to stick to this template:
217316
---`
218317

219318
const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
220-
const msgs = structuredClone(messages)
319+
const msgs = structuredClone(selected.head)
221320
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
222321
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true })
223322
const ctx = yield* InstanceState.context
@@ -280,6 +379,13 @@ When constructing the summary, try to stick to this template:
280379
return "stop"
281380
}
282381

382+
if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) {
383+
yield* session.updatePart({
384+
...compactionPart,
385+
tail_start_id: selected.tail_start_id,
386+
})
387+
}
388+
283389
if (result === "continue" && input.auto) {
284390
if (replay) {
285391
const original = replay.info

packages/opencode/src/session/message-v2.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ export namespace MessageV2 {
208208
type: z.literal("compaction"),
209209
auto: z.boolean(),
210210
overflow: z.boolean().optional(),
211+
tail_start_id: MessageID.zod.optional(),
211212
}).meta({
212213
ref: "CompactionPart",
213214
})
@@ -926,14 +927,21 @@ export namespace MessageV2 {
926927
export function filterCompacted(msgs: Iterable<MessageV2.WithParts>) {
927928
const result = [] as MessageV2.WithParts[]
928929
const completed = new Set<string>()
930+
let retain: MessageID | undefined
929931
for (const msg of msgs) {
930932
result.push(msg)
931-
if (
932-
msg.info.role === "user" &&
933-
completed.has(msg.info.id) &&
934-
msg.parts.some((part) => part.type === "compaction")
935-
)
936-
break
933+
if (retain) {
934+
if (msg.info.id === retain) break
935+
continue
936+
}
937+
if (msg.info.role === "user" && completed.has(msg.info.id)) {
938+
const part = msg.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction")
939+
if (!part) continue
940+
if (!part.tail_start_id) break
941+
retain = part.tail_start_id
942+
if (msg.info.id === retain) break
943+
continue
944+
}
937945
if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error)
938946
completed.add(msg.info.parentID)
939947
}

0 commit comments

Comments
 (0)