Skip to content

Commit 6f5a3d3

Browse files
committed
keep recent turns during session compaction
1 parent 91786d2 commit 6f5a3d3

6 files changed

Lines changed: 587 additions & 15 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
@@ -1069,6 +1069,18 @@ export namespace Config {
10691069
.object({
10701070
auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"),
10711071
prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"),
1072+
tail_turns: z
1073+
.number()
1074+
.int()
1075+
.min(0)
1076+
.optional()
1077+
.describe("Number of recent real user turns to keep verbatim during compaction (default: 2)"),
1078+
tail_tokens: z
1079+
.number()
1080+
.int()
1081+
.min(0)
1082+
.optional()
1083+
.describe("Token budget for retained recent turns during compaction"),
10721084
reserved: z
10731085
.number()
10741086
.int()

packages/opencode/src/session/compaction.ts

Lines changed: 87 additions & 3 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,24 @@ 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+
43+
function usable(input: { cfg: Config.Info; model: Provider.Model }) {
44+
const reserved =
45+
input.cfg.compaction?.reserved ?? Math.min(20_000, ProviderTransform.maxOutputTokens(input.model))
46+
return input.model.limit.input
47+
? Math.max(0, input.model.limit.input - reserved)
48+
: Math.max(0, input.model.limit.context - ProviderTransform.maxOutputTokens(input.model))
49+
}
50+
51+
function tailBudget(input: { cfg: Config.Info; model: Provider.Model }) {
52+
return (
53+
input.cfg.compaction?.tail_tokens ??
54+
Math.min(MAX_TAIL_TOKENS, Math.max(MIN_TAIL_TOKENS, Math.floor(usable(input) * 0.25)))
55+
)
56+
}
3857

3958
export interface Interface {
4059
readonly isOverflow: (input: {
@@ -88,6 +107,55 @@ export namespace SessionCompaction {
88107
return overflow({ cfg: yield* config.get(), tokens: input.tokens, model: input.model })
89108
})
90109

110+
const estimate = Effect.fn("SessionCompaction.estimate")(function* (input: {
111+
messages: MessageV2.WithParts[]
112+
model: Provider.Model
113+
}) {
114+
const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model, { stripMedia: true })
115+
return Token.estimate(JSON.stringify(msgs))
116+
})
117+
118+
const select = Effect.fn("SessionCompaction.select")(function* (input: {
119+
messages: MessageV2.WithParts[]
120+
cfg: Config.Info
121+
model: Provider.Model
122+
}) {
123+
const limit = input.cfg.compaction?.tail_turns ?? DEFAULT_TAIL_TURNS
124+
if (limit <= 0) return { head: input.messages, tail_start_id: undefined }
125+
const budget = tailBudget({ cfg: input.cfg, model: input.model })
126+
const turns = input.messages.flatMap((msg, idx) =>
127+
msg.info.role === "user" && !msg.parts.some((part) => part.type === "compaction") ? [idx] : [],
128+
)
129+
if (!turns.length) return { head: input.messages, tail_start_id: undefined }
130+
131+
let total = 0
132+
let start = input.messages.length
133+
let kept = 0
134+
135+
for (let i = turns.length - 1; i >= 0 && kept < limit; i--) {
136+
const idx = turns[i]
137+
const end = i + 1 < turns.length ? turns[i + 1] : input.messages.length
138+
const size = yield* estimate({
139+
messages: input.messages.slice(idx, end),
140+
model: input.model,
141+
})
142+
if (kept === 0 && size > budget) {
143+
log.info("tail fallback", { budget, size })
144+
return { head: input.messages, tail_start_id: undefined }
145+
}
146+
if (total + size > budget) break
147+
total += size
148+
start = idx
149+
kept++
150+
}
151+
152+
if (kept === 0 || start === 0) return { head: input.messages, tail_start_id: undefined }
153+
return {
154+
head: input.messages.slice(0, start),
155+
tail_start_id: input.messages[start]?.info.id,
156+
}
157+
})
158+
91159
// goes backwards through parts until there are PRUNE_PROTECT tokens worth of tool
92160
// calls, then erases output of older tool calls to free context space
93161
const prune = Effect.fn("SessionCompaction.prune")(function* (input: { sessionID: SessionID }) {
@@ -150,6 +218,7 @@ export namespace SessionCompaction {
150218
throw new Error(`Compaction parent must be a user message: ${input.parentID}`)
151219
}
152220
const userMessage = parent.info
221+
const compactionPart = parent.parts.find((part): part is MessageV2.CompactionPart => part.type === "compaction")
153222

154223
let messages = input.messages
155224
let replay:
@@ -180,14 +249,22 @@ export namespace SessionCompaction {
180249
const model = agent.model
181250
? yield* provider.getModel(agent.model.providerID, agent.model.modelID)
182251
: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
252+
const cfg = yield* config.get()
253+
const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages
254+
const selected = yield* select({
255+
messages: history,
256+
cfg,
257+
model,
258+
})
183259
// Allow plugins to inject context or replace compaction prompt.
184260
const compacting = yield* plugin.trigger(
185261
"experimental.session.compacting",
186262
{ sessionID: input.sessionID },
187263
{ context: [], prompt: undefined },
188264
)
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.
265+
const defaultPrompt = `Summarize the older conversation history so another agent can continue the work with the retained recent turns.
266+
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.
267+
Include what we did, what we're doing, which files we're working on, and what we're going to do next.
191268
The summary that you construct will be used so that another agent can read it and continue the work.
192269
Do not call any tools. Respond only with the summary text.
193270
Respond in the same language as the user's messages in the conversation.
@@ -217,7 +294,7 @@ When constructing the summary, try to stick to this template:
217294
---`
218295

219296
const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
220-
const msgs = structuredClone(messages)
297+
const msgs = structuredClone(selected.head)
221298
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
222299
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true })
223300
const ctx = yield* InstanceState.context
@@ -280,6 +357,13 @@ When constructing the summary, try to stick to this template:
280357
return "stop"
281358
}
282359

360+
if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) {
361+
yield* session.updatePart({
362+
...compactionPart,
363+
tail_start_id: selected.tail_start_id,
364+
})
365+
}
366+
283367
if (result === "continue" && input.auto) {
284368
if (replay) {
285369
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)