Compare commits

...
Author SHA1 Message Date
Aiden Cline 0c553a08b7 fix(core): preserve structured compaction tails 2026-09-02 11:41:01 -05:00
18 changed files with 300 additions and 57 deletions
+1
View File
@@ -967,6 +967,7 @@ export type SessionLogOutput =
readonly reason: "auto" | "manual"
readonly text: string
readonly recent: string
readonly retained?: { readonly from: SessionMessage.ID; readonly through: SessionMessage.ID } | undefined
}
}
| {
+11 -1
View File
@@ -147,6 +147,7 @@ export type SessionMessageCompactionCompleted = {
reason: "auto" | "manual"
summary: string
recent: string
retained?: { from: string; through: string }
}
export type SessionActive = { type: "running" }
@@ -816,7 +817,13 @@ export type SessionCompactionEnded = {
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; reason: "auto" | "manual"; text: string; recent: string }
data: {
sessionID: string
reason: "auto" | "manual"
text: string
recent: string
retained?: { from: string; through: string }
}
}
export type SessionCompactionFailed = {
@@ -3075,6 +3082,7 @@ export type SessionImportInput = {
readonly reason: "auto" | "manual"
readonly summary: string
readonly recent: string
readonly retained?: { readonly from: string; readonly through: string }
}
| {
readonly type: "compaction"
@@ -3352,6 +3360,7 @@ export type SessionImportInput = {
readonly reason: "auto" | "manual"
readonly summary: string
readonly recent: string
readonly retained?: { readonly from: string; readonly through: string }
}
| {
readonly type: "compaction"
@@ -3629,6 +3638,7 @@ export type SessionImportInput = {
readonly reason: "auto" | "manual"
readonly summary: string
readonly recent: string
readonly retained?: { readonly from: string; readonly through: string }
}
| {
readonly type: "compaction"
+2
View File
@@ -1103,6 +1103,7 @@ export function createData(config: CreateDataInput) {
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
retained: event.data.retained,
})
return
}
@@ -1113,6 +1114,7 @@ export function createData(config: CreateDataInput) {
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
retained: event.data.retained,
time: { created: event.created },
})
})
+13 -2
View File
@@ -101,10 +101,21 @@ test.each(["started", "cancelled", "failed"])(
fixture.emit({
...event,
type: "session.compaction.ended",
data: { sessionID, reason: "manual", text: "Summary", recent: "Recent" },
data: {
sessionID,
reason: "manual",
text: "Summary",
recent: "",
retained: { from: "msg_retained_user", through: "msg_retained_assistant" },
},
})
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
{ type: "compaction", status: "completed", summary: "Summary" },
{
type: "compaction",
status: "completed",
summary: "Summary",
retained: { from: "msg_retained_user", through: "msg_retained_assistant" },
},
])
}
},
+27 -7
View File
@@ -126,8 +126,10 @@ export interface Interface extends State.Transformable<Draft> {
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {}
export const estimateTokens = (input: RequiredInput) => {
const through = compactedThrough(input.messages)
const index = input.messages.findLastIndex(
(message) =>
(message, index) =>
index > through &&
message.type === "assistant" &&
!message.error &&
message.tokens !== undefined &&
@@ -255,9 +257,16 @@ const serializeRecentMessage = (message: SessionMessage.Info) => {
const splitHistory = (messages: readonly SessionMessage.Info[], keepTokens: number) => {
const tailStart = findTailStart(messages, keepTokens)
if (tailStart === undefined) return
// Messages after a delivered manual checkpoint already replay without a retained range.
const end = messages.findLastIndex((message) => message.type === "compaction" && message.status === "running")
const tail = messages
.slice(tailStart, end < 0 ? undefined : end)
.filter((message) => message.type !== "compaction" && message.type !== "system")
const first = tail.at(0)
const last = tail.at(-1)
return {
messages: messages.slice(0, tailStart),
recent: messages.slice(tailStart).map(serializeRecentMessage).filter(Boolean).join("\n\n"),
retained: first && last ? { from: first.id, through: last.id } : undefined,
}
}
@@ -291,7 +300,18 @@ const findTailStart = (messages: readonly SessionMessage.Info[], keepTokens: num
message.type === "compaction" && message.status === "completed",
)
// Without an older retained tail to summarize, summarize everything and retain nothing.
return previousSummary?.recent ? conversation[0].index : messages.length
return previousSummary?.recent || previousSummary?.retained ? conversation[0].index : messages.length
}
// Usage on retained assistants still describes the larger, pre-compaction request.
const compactedThrough = (messages: readonly SessionMessage.Info[]) => {
const index = messages.findLastIndex((message) => message.type === "compaction" && message.status === "completed")
const checkpoint = messages[index]
if (checkpoint?.type !== "compaction" || checkpoint.status !== "completed" || !checkpoint.retained) return index
return Math.max(
index,
messages.findIndex((message) => message.id === checkpoint.retained?.through),
)
}
export const buildPrompt = (update: boolean) => {
@@ -360,7 +380,7 @@ export const layer = Layer.effect(
yield* bus.publish(SessionEvent.Compaction.Started, {
sessionID: context.session.id,
reason: input.reason,
recent: history.recent,
recent: "",
inputID: input.inputID,
})
@@ -483,7 +503,8 @@ export const layer = Layer.effect(
sessionID: context.session.id,
reason: input.reason,
text: summary,
recent: history.recent,
recent: "",
retained: history.retained,
})
return { status: "completed" as const }
})
@@ -492,8 +513,7 @@ export const layer = Layer.effect(
const config = state.get()
if (!config.auto) return false
// Run the completed checkpoint before considering another automatic compaction.
const last = input.messages.at(-1)
if (last?.type === "compaction" && last.status === "completed") return false
if (input.messages.length > 0 && compactedThrough(input.messages) === input.messages.length - 1) return false
const limit = input.resolved.limit
const context = limit.context
if (context <= 0) return false
+41 -5
View File
@@ -1,4 +1,4 @@
import { and, asc, desc, eq, gte, sql } from "drizzle-orm"
import { and, asc, desc, eq, gte, inArray, lte, notInArray, or, sql } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { Database } from "../database/database.js"
import { MessageDecodeError } from "./error.js"
@@ -14,7 +14,7 @@ const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select({ seq: SessionMessageTable.seq })
.select()
.from(SessionMessageTable)
.where(
and(
@@ -42,20 +42,56 @@ export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =
const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const compaction = yield* latestCompaction(db, sessionID)
const checkpoint = compaction ? yield* decodeMessageRow(compaction) : undefined
const retained =
checkpoint?.type === "compaction" && checkpoint.status === "completed" ? checkpoint.retained : undefined
const boundaries = retained
? yield* db
.select({ id: SessionMessageTable.id, seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
inArray(SessionMessageTable.id, [retained.from, retained.through]),
),
)
.all()
.pipe(Effect.orDie)
: []
const from = boundaries.find((row) => row.id === retained?.from)
const through = boundaries.find((row) => row.id === retained?.through)
if (retained && (!from || !through || from.seq > through.seq))
return yield* Effect.die(new Error(`Compaction retained history is unavailable: ${sessionID}`))
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
compaction ? gte(SessionMessageTable.seq, compaction.seq) : undefined,
compaction
? or(
gte(SessionMessageTable.seq, compaction.seq),
from && through
? and(
gte(SessionMessageTable.seq, from.seq),
lte(SessionMessageTable.seq, through.seq),
// Instruction updates are folded into the new baseline; older checkpoints are replaced.
notInArray(SessionMessageTable.type, ["system", "compaction"]),
)
: undefined,
)
: undefined,
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
return yield* Effect.forEach(rows, (row) =>
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
const index = rows.findIndex((row) => row.id === compaction?.id)
const ordered = index > 0 ? [rows[index], ...rows.slice(0, index), ...rows.slice(index + 1)] : rows
return yield* Effect.forEach(ordered, (row) =>
(checkpoint && row.id === checkpoint.id ? Effect.succeed(checkpoint) : decodeMessageRow(row)).pipe(
Effect.map((message) => ({ seq: row.seq, message })),
),
)
})
@@ -412,6 +412,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
retained: event.data.retained,
})
return
}
@@ -424,6 +425,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
retained: event.data.retained,
time: { created },
}),
)
+22 -9
View File
@@ -179,6 +179,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
if (event.data.instructionEntries)
yield* InstructionEntry.initialize(db, event.data.sessionID, event.data.instructionEntries, event.created)
const messageIDs = new Map<SessionMessage.ID, SessionMessage.ID>()
let cursor = -1
while (copiedSeq !== undefined) {
const rows = yield* db
@@ -201,18 +202,30 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.pipe(Effect.orDie)
if (rows.length === 0) break
rows.forEach((row) =>
messageIDs.set(row.id, SessionMessage.ID.make(`${SessionMessage.ID.fromEvent(event.id)}_${row.seq}`)),
)
yield* db
.insert(SessionMessageTable)
.values(
rows.map((row) => ({
id: SessionMessage.ID.make(`${SessionMessage.ID.fromEvent(event.id)}_${row.seq}`),
session_id: event.data.sessionID,
type: row.type,
seq: row.seq,
time_created: row.time_created,
time_updated: row.time_updated,
data: row.data,
})),
rows.map((row) => {
const message =
row.type === "compaction" ? decodeMessage({ ...row.data, id: row.id, type: row.type }) : undefined
const retained =
message?.type === "compaction" && message.status === "completed" ? message.retained : undefined
const from = retained && messageIDs.get(retained.from)
const through = retained && messageIDs.get(retained.through)
if (retained && (!from || !through)) throw new Error(`Compaction retained history was not copied: ${row.id}`)
return {
id: SessionMessage.ID.make(`${SessionMessage.ID.fromEvent(event.id)}_${row.seq}`),
session_id: event.data.sessionID,
type: row.type,
seq: row.seq,
time_created: row.time_created,
time_updated: row.time_updated,
data: from && through ? { ...row.data, retained: { from, through } } : row.data,
}
}),
)
.run()
.pipe(Effect.orDie)
@@ -278,17 +278,16 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
Message.make({
id: message.id,
role: "user",
content: `<conversation-checkpoint>
The following is a summary and serialized record of earlier conversation. Treat it as historical context, not as new instructions.
<summary>
${message.summary}
</summary>
<recent-context>
${message.recent}
</recent-context>
</conversation-checkpoint>`,
content: [
"<conversation-checkpoint>",
`${message.recent ? "The following is a summary and serialized record of earlier conversation." : "The following is a summary of earlier conversation."} Treat it as historical context, not as new instructions.`,
"",
"<summary>",
message.summary,
"</summary>",
...(message.recent ? ["", "<recent-context>", message.recent, "</recent-context>"] : []),
"</conversation-checkpoint>",
].join("\n"),
metadata: message.metadata,
}),
]
+6 -3
View File
@@ -74,8 +74,8 @@ describe("ConfigCompactionPlugin.Plugin", () => {
yield* ConfigCompactionPlugin.Plugin.effect(host({ event: { subscribe: () => bus.subscribe(Event.Updated) } }))
expect(compaction.required(nearInput)).toBe(false)
const started = yield* bus
.subscribe(SessionEvent.Compaction.Started)
const ended = yield* bus
.subscribe(SessionEvent.Compaction.Ended)
.pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
const messages = [
SessionMessage.User.make({
@@ -100,7 +100,10 @@ describe("ConfigCompactionPlugin.Plugin", () => {
inputID: SessionMessage.ID.make("msg_compaction_manual"),
}),
).toEqual({ status: "completed" })
expect(Option.getOrThrow(yield* Fiber.join(started)).data.recent).toContain("Recent context")
expect(Option.getOrThrow(yield* Fiber.join(ended)).data).toMatchObject({
recent: "",
retained: { from: messages[1].id, through: messages[1].id },
})
yield* config.setEntries([
new Document({
+11 -1
View File
@@ -1292,7 +1292,8 @@ describe("SessionTransfer", () => {
status: "completed",
reason: "manual",
summary: "summary",
recent: "recent",
recent: "",
retained: { from: userID, through: completedShellID },
time: { created: DateTime.makeUnsafe(9) },
},
],
@@ -1307,6 +1308,15 @@ describe("SessionTransfer", () => {
completedCompactionID,
])
expect(yield* Bus.latestSequence(db, sessionID)).toBe(4)
expect((yield* session.context(sessionID)).map((message) => message.id)).toEqual([
completedCompactionID,
userID,
completedAssistantID,
completedShellID,
])
expect((yield* transfer.export({ sessionID })).messages.at(-1)).toMatchObject({
retained: { from: userID, through: completedShellID },
})
}),
)
+92 -9
View File
@@ -36,6 +36,7 @@ import { Session } from "@opencode-ai/core/session"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
@@ -2307,6 +2308,76 @@ describe("SessionRunnerLLM", () => {
).toHaveLength(1)
})
scenario("preserves structured retained history through compaction, replay, and forks", function* (s) {
yield* s.llm.push(TestLLM.text("Older answer", "older-answer"))
yield* s.runPrompt("Older question ".repeat(700))
const recent = yield* s.session.prompt({
sessionID,
text: "Recent question",
files: [{ uri: "data:image/png;base64,aGVsbG8=", name: "hello.png" }],
resume: false,
})
yield* s.llm.push(
TestLLM.toolCalls(
LLMEvent.reasoningStart({ id: "retained-reasoning" }),
LLMEvent.reasoningDelta({ id: "retained-reasoning", text: "Retained reasoning" }),
LLMEvent.reasoningEnd({
id: "retained-reasoning",
providerMetadata: { fake: { signature: "retained-signature" } },
}),
LLMEvent.toolCall({ id: "call-retained", name: "echo", input: { text: "x".repeat(12_000) } }),
),
TestLLM.textWithUsage("Recent answer", "recent-answer", 199_000),
)
const active = yield* s.resumePaused
s.systemBaseline = "Updated retained instructions"
yield* active.finish
const messages = yield* s.context
const retained = messages
.slice(messages.findIndex((message) => message.id === recent.id))
.filter((message) => message.type !== "system")
yield* s.llm.push(TestLLM.text("## Objective\n- Summary", "structured-summary"))
const compact = yield* s.session.compact({ sessionID })
const summarizing = yield* s.resumePaused
yield* s.bus.publish(SessionEvent.Synthetic, { sessionID, text: "Arrived during compaction" })
yield* summarizing.finish
const after = yield* s.context
expect(after[0]).toMatchObject({
id: compact.id,
type: "compaction",
status: "completed",
recent: "",
retained: { from: recent.id, through: retained.at(-1)?.id },
})
expect(after.slice(1, -1)).toEqual(retained)
expect(after.at(-1)).toMatchObject({ type: "synthetic", text: "Arrived during compaction" })
expect(after.some((message) => message.type === "system")).toBe(false)
yield* replaySessionProjection(sessionID)
expect(yield* s.context).toEqual(after)
s.requests.length = 0
yield* s.llm.push(TestLLM.text("Continued", "structured-continued"))
const continued = yield* s.runPrompt("Continue")
expect(s.requests).toHaveLength(1)
expect(s.requests[0].messages.slice(1, -2)).toEqual(
toLLMMessages(retained, { id: ID.make(model.id), providerID: Provider.ID.make(model.provider) }),
)
expect(userTexts(s.requests[0])[0]).not.toContain("<recent-context>")
expect(s.requests[0].system.map((part) => part.text)).toContain("Updated retained instructions")
expect(s.executions).toEqual(["x".repeat(12_000)])
const fork = yield* s.session.fork({ sessionID, boundary: { type: "before", messageID: continued.id } })
const copied = yield* s.session.context(fork.id)
expect(copied.slice(1).map(({ id, ...message }) => message)).toEqual(
after.slice(1).map(({ id, ...message }) => message),
)
expect(copied[0]).toMatchObject({ retained: { from: copied[1].id, through: copied.at(-2)?.id } })
yield* s.db.delete(SessionTable).where(eq(SessionTable.id, fork.id)).run()
yield* replaySessionProjection(fork.id)
expect(yield* s.session.context(fork.id)).toEqual(copied)
})
scenario("automatically compacts into a completed summary and retained recent turn", function* (s) {
const store = yield* SessionStore.Service
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "text-first", 3_950))
@@ -2318,20 +2389,22 @@ describe("SessionRunnerLLM", () => {
TestLLM.text("## Objective\n- Preserve the task", "text-summary"),
TestLLM.textWithUsage("Continued", "text-final", 3_950),
)
yield* s.runPrompt("Recent exact request ".repeat(180))
const recent = yield* s.runPrompt("Recent exact request ".repeat(180))
expect(s.requests).toHaveLength(2)
expect(userTexts(s.requests[0]).at(-1)).toContain("## Objective")
expect(userTexts(s.requests[1])).toHaveLength(1)
expect(userTexts(s.requests[1])).toHaveLength(2)
expect(userTexts(s.requests[1])[0]).toContain("<summary>\n## Objective\n- Preserve the task\n</summary>")
expect(userTexts(s.requests[1])[0]).toContain(`[User]: ${"Recent exact request ".repeat(180)}`)
expect(userTexts(s.requests[1])[0]).not.toContain("<recent-context>")
expect(userTexts(s.requests[1])[1]).toBe("Recent exact request ".repeat(180))
const context = yield* store.context(sessionID)
expect(context.map((message) => message.type)).toEqual(["compaction", "assistant"])
expect(context.map((message) => message.type)).toEqual(["compaction", "user", "assistant"])
expect(context[0]).toMatchObject({
type: "compaction",
summary: "## Objective\n- Preserve the task",
recent: `[User]: ${"Recent exact request ".repeat(180)}`,
recent: "",
retained: { from: recent.id, through: recent.id },
})
s.requests.length = 0
@@ -2340,16 +2413,17 @@ describe("SessionRunnerLLM", () => {
TestLLM.text("## Objective\n- Preserve the updated task", "text-summary-2"),
TestLLM.text("Continued again", "text-final-2"),
)
yield* s.runPrompt("Newest exact request ".repeat(180))
const newest = yield* s.runPrompt("Newest exact request ".repeat(180))
expect(s.requests).toHaveLength(2)
expect(userTexts(s.requests[0])[0]).toContain("<summary>\n## Objective\n- Preserve the task\n</summary>")
expect(userTexts(s.requests[0])[0]).toContain("Recent exact request")
expect(userTexts(s.requests[0])).toContain("Recent exact request ".repeat(180))
expect(userTexts(s.requests[0]).at(-1)).toBe(SessionCompaction.buildPrompt(true))
expect((yield* store.context(sessionID))[0]).toMatchObject({
type: "compaction",
summary: "## Objective\n- Preserve the updated task",
recent: `[User]: ${"Newest exact request ".repeat(180)}`,
recent: "",
retained: { from: newest.id, through: newest.id },
})
})
@@ -2409,10 +2483,15 @@ describe("SessionRunnerLLM", () => {
expect(userTexts(s.requests[2])[0]).toContain("<summary>\n## Objective\n- Recover overflow\n</summary>")
expect(yield* s.context).toMatchObject([
{ type: "compaction", summary: "## Objective\n- Recover overflow" },
{ type: "user", text: "Continue" },
{ type: "assistant", finish: "stop" },
])
yield* replaySessionProjection(sessionID)
expect(yield* s.context).toMatchObject([{ type: "compaction" }, { type: "assistant", finish: "stop" }])
expect(yield* s.context).toMatchObject([
{ type: "compaction" },
{ type: "user", text: "Continue" },
{ type: "assistant", finish: "stop" },
])
})
scenario("refreshes preparation after overflow compaction without promoting new input", function* (s) {
@@ -2520,6 +2599,7 @@ describe("SessionRunnerLLM", () => {
expect(s.requests).toHaveLength(3)
expect(yield* s.context).toMatchObject([
{ type: "compaction", summary: "## Objective\n- Recover unknown limit" },
{ type: "user", text: "Continue" },
{ type: "assistant", finish: "stop" },
])
})
@@ -2536,6 +2616,7 @@ describe("SessionRunnerLLM", () => {
expect(s.requests).toHaveLength(2)
expect(yield* s.context).toMatchObject([
{ type: "compaction", summary: "## Objective\n- Recover undersized limit" },
{ type: "user", text: "Continue" },
{ type: "assistant", finish: "stop" },
])
})
@@ -2553,6 +2634,7 @@ describe("SessionRunnerLLM", () => {
expect(s.requests).toHaveLength(3)
expect(yield* s.context).toMatchObject([
{ type: "compaction" },
{ type: "user", text: "Continue" },
{ type: "assistant", finish: "error", error: { message: "prompt too long" } },
])
})
@@ -2578,6 +2660,7 @@ describe("SessionRunnerLLM", () => {
expect(s.requests).toHaveLength(3)
expect(yield* s.context).toMatchObject([
{ type: "compaction", summary: "## Objective\n- Recover raw overflow" },
{ type: "user", text: "Continue" },
{ type: "assistant", finish: "stop" },
])
})
+15
View File
@@ -18221,6 +18221,21 @@
},
"recent": {
"type": "string"
},
"retained": {
"type": "object",
"properties": {
"from": {
"type": "string",
"pattern": "^msg_"
},
"through": {
"type": "string",
"pattern": "^msg_"
}
},
"required": ["from", "through"],
"additionalProperties": false
}
},
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
+1
View File
@@ -587,6 +587,7 @@ export namespace Compaction {
reason: Started.data.fields.reason,
text: Schema.String,
recent: Schema.String,
retained: SessionMessage.CompactionCompleted.fields.retained,
},
})
export type Ended = typeof Ended.Type
+3
View File
@@ -251,7 +251,10 @@ export const CompactionCompleted = Schema.Struct({
status: Schema.tag("completed"),
reason: Schema.Literals(["auto", "manual"]),
summary: Schema.String,
/** Legacy serialized tail. New checkpoints leave this empty and reference retained messages. */
recent: Schema.String,
/** Original messages retained after the summary, inclusive of both boundaries. */
retained: Schema.Struct({ from: ID, through: ID }).pipe(optional),
}).annotate({ identifier: "Session.Message.Compaction.Completed" })
export interface CompactionFailed extends Schema.Schema.Type<typeof CompactionFailed> {}
+15
View File
@@ -18221,6 +18221,21 @@
},
"recent": {
"type": "string"
},
"retained": {
"type": "object",
"properties": {
"from": {
"type": "string",
"pattern": "^msg_"
},
"through": {
"type": "string",
"pattern": "^msg_"
}
},
"required": ["from", "through"],
"additionalProperties": false
}
},
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
+15
View File
@@ -18221,6 +18221,21 @@
},
"recent": {
"type": "string"
},
"retained": {
"type": "object",
"properties": {
"from": {
"type": "string",
"pattern": "^msg_"
},
"through": {
"type": "string",
"pattern": "^msg_"
}
},
"required": ["from", "through"],
"additionalProperties": false
}
},
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
+13 -9
View File
@@ -3,13 +3,13 @@ title: "Compaction"
---
Compaction replaces the active model context from an older part of a session
with a generated checkpoint. The checkpoint contains a structured summary and
a serialized tail of recent context, so the agent can continue with more room
with a generated checkpoint. The checkpoint contains a summary and references
to retained recent messages, so the agent can continue with more room
in the model's context window.
Compaction is lossy, but it does not delete the earlier durable session
messages. After a successful compaction, V2 builds model requests from the
latest completed checkpoint and the messages that follow it.
latest completed checkpoint, its retained messages, and the messages that follow it.
## Automatic compaction
@@ -68,7 +68,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
| Field | Default | V2 behavior |
| ------------- | ------: | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `auto` | `true` | Enables preflight context-size checks and one-shot provider-overflow recovery. Disabling it does not affect manual compaction. |
| `keep.tokens` | `15000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
| `keep.tokens` | `15000` | Approximate retention allowance used to select recent messages to keep after the summary. |
| `buffer` | `20000` | Safety reserve below an explicit input limit. Without one, it is the minimum context reserve and the model output allowance wins when larger. |
`keep.tokens` and `buffer` accept non-negative integers. Larger `keep.tokens`
@@ -91,11 +91,15 @@ Provider-hosted tools remain subject to the selected provider's behavior.
The summary records the objective, requirements, decisions, completed and active
work, blockers, next moves, relevant files, and additional context.
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 file or media attachments become textual descriptors rather than embedded
data. On later compactions, V2 updates the previous summary and carries forward
its retained recent context before selecting a new tail.
Recent messages are retained separately and pass through normal model-request
conversion. Their roles, tool calls and results, attachments, and compatible
reasoning metadata are preserved rather than flattened into summary text.
Instruction updates already folded into the new baseline are not repeated.
The existing selection heuristic estimates serialized text and aligns the cut
to a user message, so `keep.tokens` is not a hard limit on the retained content.
On later compactions, retained messages can become part of the next summarized
prefix. Older checkpoints with serialized recent context remain readable.
The completed compaction is presented to the model as historical conversation
context, explicitly not as new instructions. Running and failed compactions are