Skip to content

Commit 2d0d3d5

Browse files
authored
feat(compaction): serialize compaction tail (#26830)
1 parent 3bd98ea commit 2d0d3d5

5 files changed

Lines changed: 77 additions & 95 deletions

File tree

packages/opencode/src/config/config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,10 +273,10 @@ export const Info = Schema.Struct({
273273
}),
274274
tail_turns: Schema.optional(NonNegativeInt).annotate({
275275
description:
276-
"Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)",
276+
"Number of recent user turns, including their following assistant/tool responses, to serialize into the compaction summary (default: 2)",
277277
}),
278278
preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({
279-
description: "Maximum number of tokens from recent turns to preserve verbatim after compaction",
279+
description: "Maximum number of tokens from recent turns to serialize into the compaction summary",
280280
}),
281281
reserved: Schema.optional(NonNegativeInt).annotate({
282282
description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.",

packages/opencode/src/session/compaction.ts

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,10 @@ Rules:
7979
type Turn = {
8080
start: number
8181
end: number
82-
id: MessageID
8382
}
8483

8584
type Tail = {
8685
start: number
87-
id: MessageID
8886
}
8987

9088
type CompletedCompaction = {
@@ -121,19 +119,41 @@ function completedCompactions(messages: MessageV2.WithParts[]) {
121119
})
122120
}
123121

124-
function buildPrompt(input: { previousSummary?: string; context: string[] }) {
122+
function buildPrompt(input: { previousSummary?: string; context: string[]; tail?: string }) {
123+
const source = input.tail
124+
? "the conversation history above and the serialized recent conversation tail below"
125+
: "the conversation history above"
125126
const anchor = input.previousSummary
126127
? [
127-
"Update the anchored summary below using the conversation history above.",
128+
`Update the anchored summary below using ${source}.`,
128129
"Preserve still-true details, remove stale details, and merge in the new facts.",
129130
"<previous-summary>",
130131
input.previousSummary,
131132
"</previous-summary>",
132133
].join("\n")
133-
: "Create a new anchored summary from the conversation history above."
134-
return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n")
134+
: `Create a new anchored summary from ${source}.`
135+
const tail = input.tail
136+
? [
137+
"Fold this serialized recent conversation tail into the summary; it is not provider message history.",
138+
"<recent-conversation-tail>",
139+
input.tail,
140+
"</recent-conversation-tail>",
141+
].join("\n")
142+
: undefined
143+
return [anchor, ...(tail ? [tail] : []), SUMMARY_TEMPLATE, ...input.context].join("\n\n")
135144
}
136145

146+
const serialize = Effect.fn("SessionCompaction.serialize")(function* (input: {
147+
messages: MessageV2.WithParts[]
148+
model: Provider.Model
149+
}) {
150+
const messages = yield* MessageV2.toModelMessagesEffect(input.messages, input.model, {
151+
stripMedia: true,
152+
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
153+
})
154+
return messages.length ? JSON.stringify(messages, null, 2) : undefined
155+
})
156+
137157
function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) {
138158
return (
139159
input.cfg.compaction?.preserve_recent_tokens ??
@@ -150,7 +170,6 @@ function turns(messages: MessageV2.WithParts[]) {
150170
result.push({
151171
start: i,
152172
end: messages.length,
153-
id: msg.info.id,
154173
})
155174
}
156175
for (let i = 0; i < result.length - 1; i++) {
@@ -177,7 +196,6 @@ function splitTurn(input: {
177196
if (size > input.budget) continue
178197
return {
179198
start,
180-
id: input.messages[start]!.info.id,
181199
} satisfies Tail
182200
}
183201
return undefined
@@ -244,8 +262,7 @@ export const layer: Layer.Layer<
244262
messages: MessageV2.WithParts[]
245263
model: Provider.Model
246264
}) {
247-
const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model)
248-
return Token.estimate(JSON.stringify(msgs))
265+
return Token.estimate((yield* serialize(input)) ?? "")
249266
})
250267

251268
const select = Effect.fn("SessionCompaction.select")(function* (input: {
@@ -254,10 +271,10 @@ export const layer: Layer.Layer<
254271
model: Provider.Model
255272
}) {
256273
const limit = input.cfg.compaction?.tail_turns ?? DEFAULT_TAIL_TURNS
257-
if (limit <= 0) return { head: input.messages, tail_start_id: undefined }
274+
if (limit <= 0) return { head: input.messages, tail: [] }
258275
const budget = preserveRecentBudget({ cfg: input.cfg, model: input.model })
259276
const all = turns(input.messages)
260-
if (!all.length) return { head: input.messages, tail_start_id: undefined }
277+
if (!all.length) return { head: input.messages, tail: [] }
261278
const recent = all.slice(-limit)
262279
const sizes = yield* Effect.forEach(
263280
recent,
@@ -276,7 +293,7 @@ export const layer: Layer.Layer<
276293
const size = sizes[i]
277294
if (total + size <= budget) {
278295
total += size
279-
keep = { start: turn.start, id: turn.id }
296+
keep = { start: turn.start }
280297
continue
281298
}
282299
const remaining = budget - total
@@ -292,10 +309,10 @@ export const layer: Layer.Layer<
292309
break
293310
}
294311

295-
if (!keep || keep.start === 0) return { head: input.messages, tail_start_id: undefined }
312+
if (!keep) return { head: input.messages, tail: [] }
296313
return {
297314
head: input.messages.slice(0, keep.start),
298-
tail_start_id: keep.id,
315+
tail: input.messages.slice(keep.start),
299316
}
300317
})
301318

@@ -406,7 +423,10 @@ export const layer: Layer.Layer<
406423
{ sessionID: input.sessionID },
407424
{ context: [], prompt: undefined },
408425
)
409-
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context })
426+
const tailMessages = structuredClone(selected.tail)
427+
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: tailMessages })
428+
const tail = yield* serialize({ messages: tailMessages, model })
429+
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context, tail })
410430
const msgs = structuredClone(selected.head)
411431
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
412432
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
@@ -473,13 +493,6 @@ export const layer: Layer.Layer<
473493
return "stop"
474494
}
475495

476-
if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) {
477-
yield* session.updatePart({
478-
...compactionPart,
479-
tail_start_id: selected.tail_start_id,
480-
})
481-
}
482-
483496
if (result === "continue" && input.auto) {
484497
if (replay) {
485498
const original = replay.info
@@ -575,7 +588,6 @@ export const layer: Layer.Layer<
575588
sessionID: input.sessionID,
576589
timestamp: DateTime.makeUnsafe(Date.now()),
577590
text: summary ?? "",
578-
include: selected.tail_start_id,
579591
})
580592
}
581593
yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })

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

Lines changed: 3 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -840,12 +840,13 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
840840
return part.metadata?.anthropic?.signature != null
841841
})
842842
for (const part of msg.parts) {
843+
if (msg.info.summary && part.type !== "text") continue
843844
if (part.type === "text") {
844845
const text = part.text === "" && hasSignedReasoning ? " " : part.text
845846
assistantMessage.parts.push({
846847
type: "text",
847848
text,
848-
...(differentModel ? {} : { providerMetadata: part.metadata }),
849+
...(differentModel || msg.info.summary ? {} : { providerMetadata: part.metadata }),
849850
})
850851
}
851852
if (part.type === "step-start")
@@ -1071,53 +1072,16 @@ export function get(input: { sessionID: SessionID; messageID: MessageID }): With
10711072
export function filterCompacted(msgs: Iterable<WithParts>) {
10721073
const result = [] as WithParts[]
10731074
const completed = new Set<string>()
1074-
let retain: MessageID | undefined
10751075
for (const msg of msgs) {
10761076
result.push(msg)
1077-
if (retain) {
1078-
if (msg.info.id === retain) break
1079-
continue
1080-
}
10811077
if (msg.info.role === "user" && completed.has(msg.info.id)) {
1082-
const part = msg.parts.find((item): item is CompactionPart => item.type === "compaction")
1083-
if (!part) continue
1084-
if (!part.tail_start_id) break
1085-
retain = part.tail_start_id
1086-
if (msg.info.id === retain) break
1078+
if (msg.parts.some((item): item is CompactionPart => item.type === "compaction")) break
10871079
continue
10881080
}
1089-
if (msg.info.role === "user" && completed.has(msg.info.id) && msg.parts.some((part) => part.type === "compaction"))
1090-
break
10911081
if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error)
10921082
completed.add(msg.info.parentID)
10931083
}
10941084
result.reverse()
1095-
const compactionIndex = result.findLastIndex(
1096-
(msg) =>
1097-
msg.info.role === "user" &&
1098-
msg.parts.some((item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined),
1099-
)
1100-
const compaction = result[compactionIndex]
1101-
const part = compaction?.parts.find(
1102-
(item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined,
1103-
)
1104-
const summaryIndex = compaction
1105-
? result.findIndex(
1106-
(msg, index) =>
1107-
index > compactionIndex &&
1108-
msg.info.role === "assistant" &&
1109-
msg.info.summary &&
1110-
msg.info.parentID === compaction.info.id,
1111-
)
1112-
: -1
1113-
const tailIndex = part?.tail_start_id ? result.findIndex((msg) => msg.info.id === part.tail_start_id) : -1
1114-
if (tailIndex >= 0 && tailIndex < compactionIndex && summaryIndex > compactionIndex) {
1115-
return [
1116-
...result.slice(compactionIndex, summaryIndex + 1),
1117-
...result.slice(tailIndex, compactionIndex),
1118-
...result.slice(summaryIndex + 1),
1119-
]
1120-
}
11211085
return result
11221086
}
11231087

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

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -926,12 +926,12 @@ describe("session.compaction.process", () => {
926926
)
927927

928928
itCompaction.instance(
929-
"persists tail_start_id for retained recent turns",
929+
"does not persist tail_start_id for serialized recent turns",
930930
Effect.gen(function* () {
931931
const ssn = yield* SessionNs.Service
932932
const session = yield* ssn.create({})
933933
yield* createUserMessage(session.id, "first")
934-
const keep = yield* createUserMessage(session.id, "second")
934+
yield* createUserMessage(session.id, "second")
935935
yield* createUserMessage(session.id, "third")
936936
yield* createSummaryCompaction(session.id)
937937

@@ -947,18 +947,18 @@ describe("session.compaction.process", () => {
947947

948948
const part = yield* readCompactionPart(session.id)
949949
expect(part?.type).toBe("compaction")
950-
expect(part?.tail_start_id).toBe(keep.id)
950+
expect(part?.tail_start_id).toBeUndefined()
951951
}).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })),
952952
)
953953

954954
itCompaction.instance(
955-
"shrinks retained tail to fit preserve token budget",
955+
"does not persist tail_start_id when shrinking serialized tail",
956956
Effect.gen(function* () {
957957
const ssn = yield* SessionNs.Service
958958
const session = yield* ssn.create({})
959959
yield* createUserMessage(session.id, "first")
960960
yield* createUserMessage(session.id, "x".repeat(2_000))
961-
const keep = yield* createUserMessage(session.id, "tiny")
961+
yield* createUserMessage(session.id, "tiny")
962962
yield* createSummaryCompaction(session.id)
963963

964964
const msgs = yield* ssn.messages({ sessionID: session.id })
@@ -973,7 +973,7 @@ describe("session.compaction.process", () => {
973973

974974
const part = yield* readCompactionPart(session.id)
975975
expect(part?.type).toBe("compaction")
976-
expect(part?.tail_start_id).toBe(keep.id)
976+
expect(part?.tail_start_id).toBeUndefined()
977977
}).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) })),
978978
)
979979

@@ -1005,7 +1005,7 @@ describe("session.compaction.process", () => {
10051005
)
10061006

10071007
itCompaction.instance(
1008-
"falls back to full summary when retained tail media exceeds preserve token budget",
1008+
"serializes retained tail media as text in the summary input",
10091009
() => {
10101010
const stub = llm()
10111011
let captured = ""
@@ -1078,15 +1078,16 @@ describe("session.compaction.process", () => {
10781078

10791079
const part = yield* readCompactionPart(session.id)
10801080
expect(part?.type).toBe("compaction")
1081-
expect(part?.tail_start_id).toBe(keep.id)
1081+
expect(part?.tail_start_id).toBeUndefined()
10821082
expect(captured).toContain("zzzz")
1083-
expect(captured).not.toContain("keep tail")
1083+
expect(captured).toContain("keep tail")
10841084

10851085
const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id))
1086-
expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id])
1086+
expect(filtered.map((msg) => msg.info.id)).toEqual([parent!, expect.any(String)])
10871087
expect(filtered[1]?.info.role).toBe("assistant")
10881088
expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true)
10891089
expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id)
1090+
expect(filtered.map((msg) => msg.info.id)).not.toContain(keep.id)
10901091
}).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }))
10911092
},
10921093
{ git: true },
@@ -1353,13 +1354,13 @@ describe("session.compaction.process", () => {
13531354
)
13541355

13551356
itCompaction.instance(
1356-
"summarizes only the head while keeping recent tail out of summary input",
1357+
"summarizes the head while serializing recent tail into summary input",
13571358
() => {
13581359
const stub = llm()
1359-
let captured = ""
1360+
let captured: LLM.StreamInput["messages"] = []
13601361
stub.push(
13611362
reply("summary", (input) => {
1362-
captured = JSON.stringify(input.messages)
1363+
captured = input.messages
13631364
}),
13641365
)
13651366
return Effect.gen(function* () {
@@ -1380,10 +1381,15 @@ describe("session.compaction.process", () => {
13801381
auto: false,
13811382
})
13821383

1383-
expect(captured).toContain("older context")
1384-
expect(captured).not.toContain("keep this turn")
1385-
expect(captured).not.toContain("and this one too")
1386-
expect(captured).not.toContain("What did we do so far?")
1384+
const head = JSON.stringify(captured.slice(0, -1))
1385+
const prompt = JSON.stringify(captured.at(-1))
1386+
expect(head).toContain("older context")
1387+
expect(head).not.toContain("keep this turn")
1388+
expect(head).not.toContain("and this one too")
1389+
expect(prompt).toContain("keep this turn")
1390+
expect(prompt).toContain("and this one too")
1391+
expect(prompt).toContain("recent-conversation-tail")
1392+
expect(prompt).not.toContain("What did we do so far?")
13871393
}).pipe(withCompaction({ llm: stub.layer }))
13881394
},
13891395
{ git: true },
@@ -1431,7 +1437,7 @@ describe("session.compaction.process", () => {
14311437
{ git: true },
14321438
)
14331439

1434-
itCompaction.instance("keeps recent pre-compaction turns across repeated compactions", () => {
1440+
itCompaction.instance("does not replay recent pre-compaction turns across repeated compactions", () => {
14351441
const stub = llm()
14361442
stub.push(reply("summary one"))
14371443
stub.push(reply("summary two"))
@@ -1462,8 +1468,8 @@ describe("session.compaction.process", () => {
14621468

14631469
expect(ids).not.toContain(u1.id)
14641470
expect(ids).not.toContain(u2.id)
1465-
expect(ids).toContain(u3.id)
1466-
expect(ids).toContain(u4.id)
1471+
expect(ids).not.toContain(u3.id)
1472+
expect(ids).not.toContain(u4.id)
14671473
expect(filtered.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true)
14681474
expect(
14691475
filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")),
@@ -1472,7 +1478,7 @@ describe("session.compaction.process", () => {
14721478
})
14731479

14741480
itCompaction.instance(
1475-
"ignores previous summaries when sizing the retained tail",
1481+
"ignores previous summaries when sizing the serialized tail",
14761482
Effect.gen(function* () {
14771483
const ssn = yield* SessionNs.Service
14781484
const test = yield* TestInstance
@@ -1511,7 +1517,7 @@ describe("session.compaction.process", () => {
15111517

15121518
const part = yield* readCompactionPart(session.id)
15131519
expect(part?.type).toBe("compaction")
1514-
expect(part?.tail_start_id).toBe(keep.id)
1520+
expect(part?.tail_start_id).toBeUndefined()
15151521
}).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 500 }) })),
15161522
)
15171523
})

0 commit comments

Comments
 (0)