feat(core): compact v2 session context (#30986)

This commit is contained in:
Kit Langton 2026-06-05 13:17:23 -04:00 committed by GitHub
parent a7bd1cd0d0
commit beae7290f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 571 additions and 298 deletions

View File

@ -4,7 +4,6 @@ import { Schema } from "effect"
import { NonNegativeInt } from "../schema"
export class Keep extends Schema.Class<Keep>("ConfigV2.Compaction.Keep")({
turns: NonNegativeInt.pipe(Schema.optional),
tokens: NonNegativeInt.pipe(Schema.optional),
}) {}

View File

@ -0,0 +1,224 @@
export * as SessionCompaction from "./compaction"
import { LLM, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
import { DateTime, Effect, Stream } from "effect"
import type { Config } from "../config"
import type { EventV2 } from "../event"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
import { Token } from "../util/token"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 8_000
const TOOL_OUTPUT_MAX_CHARS = 2_000
const SUMMARY_OUTPUT_TOKENS = 4_096
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
<template>
## Goal
- [single-sentence task summary]
## Constraints & Preferences
- [user constraints, preferences, specs, or "(none)"]
## Progress
### Done
- [completed work or "(none)"]
### In Progress
- [current work or "(none)"]
### Blocked
- [blockers or "(none)"]
## Key Decisions
- [decision and why, or "(none)"]
## Next Steps
- [ordered next actions or "(none)"]
## Critical Context
- [important technical facts, errors, open questions, or "(none)"]
## Relevant Files
- [file or directory path: why it matters, or "(none)"]
</template>
Rules:
- Keep every section, even when empty.
- Use terse bullets, not prose paragraphs.
- Preserve exact file paths, commands, error strings, and identifiers when known.
- Do not mention the summary process or that context was compacted.`
type Entry = {
readonly seq: number
readonly message: SessionMessage.Message
}
type Settings = {
readonly auto: boolean
readonly buffer: number
readonly tokens: number
}
type Dependencies = {
readonly events: EventV2.Interface
readonly llm: {
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
}
readonly config: readonly Config.Entry[]
}
const estimate = (value: unknown) => Token.estimate(JSON.stringify(value))
const truncate = (value: string) =>
value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]`
const serialize = (message: SessionMessage.Message) => {
if (message.type === "user") {
const files = message.files?.map((file) => `[Attached ${file.mime}: ${file.name ?? file.uri}]`) ?? []
return [`[User]: ${message.text}`, ...files].join("\n")
}
if (message.type === "assistant") {
return message.content
.flatMap((part) => {
if (part.type === "text") return [`[Assistant]: ${part.text}`]
if (part.type === "reasoning") return part.text ? [`[Assistant reasoning]: ${part.text}`] : []
const input = typeof part.state.input === "string" ? part.state.input : JSON.stringify(part.state.input)
if (part.state.status === "completed")
return [
`[Assistant tool call]: ${part.name}(${input})`,
`[Tool result]: ${truncate(JSON.stringify(part.state.content))}`,
]
if (part.state.status === "error")
return [`[Assistant tool call]: ${part.name}(${input})`, `[Tool error]: ${part.state.error.message}`]
return [`[Assistant tool call]: ${part.name}(${input})`]
})
.join("\n")
}
if (message.type === "system") return `[System update]: ${message.text}`
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output)}`
return ""
}
const settings = (documents: readonly Config.Entry[]) => {
const configured = documents
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
return configured.reduce<Settings>(
(result, current) => ({
auto: current.auto ?? result.auto,
buffer: current.buffer ?? result.buffer,
tokens: current.keep?.tokens ?? result.tokens,
}),
{ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS },
)
}
const select = (
entries: readonly Entry[],
tokens: number,
): { readonly head: string; readonly recent: string } | undefined => {
const conversation = entries
.filter((entry) => entry.message.type !== "compaction")
.map((entry) => serialize(entry.message))
.filter(Boolean)
if (conversation.length === 0) return
let total = 0
let split = conversation.length
let splitPrefix = ""
let splitSuffix = ""
for (let index = conversation.length - 1; index >= 0; index--) {
const next = total + Token.estimate(conversation[index])
if (next > tokens) {
const remaining = Math.max(0, tokens - total) * 4
if (remaining > 0) {
splitPrefix = conversation[index].slice(0, -remaining)
splitSuffix = conversation[index].slice(-remaining)
split = index + 1
}
break
}
total = next
split = index
}
return {
head: [...conversation.slice(0, split), splitPrefix].filter(Boolean).join("\n\n"),
recent: [splitSuffix, ...conversation.slice(split)].filter(Boolean).join("\n\n"),
}
}
export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) =>
[
input.previousSummary
? `Update the anchored summary below using the conversation history above.\nPreserve still-true details, remove stale details, and merge in the new facts.\n<previous-summary>\n${input.previousSummary}\n</previous-summary>`
: "Create a new anchored summary from the conversation history.",
SUMMARY_TEMPLATE,
...input.context,
].join("\n\n")
export const make = (dependencies: Dependencies) => {
const config = settings(dependencies.config)
return Effect.fn("SessionCompaction.compactIfNeeded")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly entries: readonly Entry[]
readonly model: Model
readonly request: LLMRequest
}) {
const context = input.model.route.defaults.limits?.context
if (!config.auto || context === undefined || context <= 0) return false
const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0
if (
estimate({ system: input.request.system, messages: input.request.messages, tools: input.request.tools }) <=
context - Math.max(output, config.buffer)
)
return false
const selected = select(input.entries, config.tokens)
const previousSummary = input.entries.find((entry) => entry.message.type === "compaction")?.message
if (!selected || (selected.head.length === 0 && previousSummary?.type !== "compaction")) return false
const summaryPrompt = buildPrompt({
previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
context: [previousSummary?.type === "compaction" ? previousSummary.recent : "", selected.head].filter(Boolean),
})
const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS)
if (Token.estimate(summaryPrompt) > context - summaryOutput) return false
const messageID = SessionMessage.ID.create()
yield* dependencies.events.publish(SessionEvent.Compaction.Started, {
sessionID: input.sessionID,
messageID,
timestamp: yield* DateTime.now,
reason: "auto",
})
const chunks: string[] = []
yield* dependencies.llm
.stream(
LLM.request({
model: input.model,
messages: [Message.user(summaryPrompt)],
tools: [],
generation: { maxTokens: summaryOutput },
}),
)
.pipe(
Stream.runForEach((event) => {
if (!LLMEvent.is.textDelta(event)) return Effect.void
chunks.push(event.text)
return Effect.void
}),
)
const summary = chunks.join("")
if (!summary.trim()) return yield* Effect.die("Compaction returned an empty summary")
yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
sessionID: input.sessionID,
messageID,
timestamp: yield* DateTime.now,
reason: "auto",
text: summary,
recent: selected.recent,
})
return true
})
}

View File

@ -435,15 +435,16 @@ export namespace Compaction {
export const Delta = EventV2.define({
type: "session.next.compaction.delta",
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
text: Schema.String,
},
})
export type Delta = typeof Delta.Type
export const Ended = EventV2.define({
// Retain the unpublished v1 decoder so stored beta events remain replayable.
export const EndedV1 = EventV2.define({
type: "session.next.compaction.ended",
...options,
schema: {
@ -452,6 +453,18 @@ export namespace Compaction {
include: Schema.String.pipe(Schema.optional),
},
})
export const Ended = EventV2.define({
type: "session.next.compaction.ended",
sync: { aggregate: "sessionID", version: 2 },
schema: {
...Base,
messageID: SessionMessageID.ID,
reason: Started.data.fields.reason,
text: Schema.String,
recent: Schema.String,
},
})
export type Ended = typeof Ended.Type
}
@ -482,10 +495,9 @@ const DurableDefinitions = [
Reasoning.Ended,
Retried,
Compaction.Started,
Compaction.Delta,
Compaction.Ended,
] as const
const EphemeralDefinitions = [Text.Delta, Tool.Input.Delta, Reasoning.Delta] as const
const EphemeralDefinitions = [Text.Delta, Tool.Input.Delta, Reasoning.Delta, Compaction.Delta] as const
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
export type DurableEvent = typeof Durable.Type

View File

@ -12,7 +12,7 @@ const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select({ seq: SessionMessageTable.seq })
.select()
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
.orderBy(desc(SessionMessageTable.seq))
@ -27,7 +27,7 @@ const messageRows = Effect.fnUntraced(function* (
compaction: { readonly seq: number } | undefined,
baselineSeq?: number,
) {
return yield* db
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(
@ -49,6 +49,7 @@ const messageRows = Effect.fnUntraced(function* (
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
return rows
})
const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
@ -83,9 +84,17 @@ export const loadForRunner = Effect.fn("SessionHistory.loadForRunner")(function*
sessionID: SessionSchema.ID,
baselineSeq: number,
) {
return yield* Effect.forEach(
yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID), baselineSeq),
decodeMessageRow,
return (yield* entriesForRunner(db, sessionID, baselineSeq)).map((entry) => entry.message)
})
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
baselineSeq: number,
) {
const rows = yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID), baselineSeq)
return yield* Effect.forEach(rows, (row) =>
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
)
})

View File

@ -10,10 +10,8 @@ export type MemoryState = {
export interface Adapter {
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined>
readonly getAssistant: (messageID: SessionMessage.ID) => Effect.Effect<SessionMessage.Assistant | undefined>
readonly getCurrentCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined>
readonly getCurrentShell: (callID: string) => Effect.Effect<SessionMessage.Shell | undefined>
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void>
readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect<void>
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void>
readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void>
}
@ -23,7 +21,6 @@ export function memory(state: MemoryState): Adapter {
state.messages.findLastIndex((message) => message.id === messageID)
// A newer turn supersedes stale incomplete rows; never resume an older assistant projection.
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
const activeCompactionIndex = () => state.messages.findLastIndex((message) => message.type === "compaction")
const activeShellIndex = (callID: string) =>
state.messages.findLastIndex((message) => message.type === "shell" && message.callID === callID)
@ -44,14 +41,6 @@ export function memory(state: MemoryState): Adapter {
return assistant?.type === "assistant" ? assistant : undefined
})
},
getCurrentCompaction() {
return Effect.sync(() => {
const index = activeCompactionIndex()
if (index < 0) return
const compaction = state.messages[index]
return compaction?.type === "compaction" ? compaction : undefined
})
},
getCurrentShell(callID) {
return Effect.sync(() => {
const index = activeShellIndex(callID)
@ -69,15 +58,6 @@ export function memory(state: MemoryState): Adapter {
state.messages[index] = assistant
})
},
updateCompaction(compaction) {
return Effect.sync(() => {
const index = activeCompactionIndex()
if (index < 0) return
const current = state.messages[index]
if (current?.type !== "compaction") return
state.messages[index] = compaction
})
},
updateShell(shell) {
return Effect.sync(() => {
const index = activeShellIndex(shell.callID)
@ -387,43 +367,21 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
})
},
"session.next.retried": () => Effect.void,
"session.next.compaction.started": (event) => {
"session.next.compaction.started": () => Effect.void,
"session.next.compaction.delta": () => Effect.void,
"session.next.compaction.ended": (event) => {
return adapter.appendMessage(
new SessionMessage.Compaction({
id: event.data.messageID,
type: "compaction",
metadata: event.metadata,
reason: event.data.reason,
summary: "",
summary: event.data.text,
recent: event.data.recent,
time: { created: event.data.timestamp },
}),
)
},
"session.next.compaction.delta": (event) => {
return Effect.gen(function* () {
const currentCompaction = yield* adapter.getCurrentCompaction()
if (currentCompaction) {
yield* adapter.updateCompaction(
produce(currentCompaction, (draft) => {
draft.summary += event.data.text
}),
)
}
})
},
"session.next.compaction.ended": (event) => {
return Effect.gen(function* () {
const currentCompaction = yield* adapter.getCurrentCompaction()
if (currentCompaction) {
yield* adapter.updateCompaction(
produce(currentCompaction, (draft) => {
draft.summary = event.data.text
draft.include = event.data.include
}),
)
}
})
},
})
})
}

View File

@ -172,7 +172,7 @@ export class Compaction extends Schema.Class<Compaction>("Session.Message.Compac
type: Schema.Literal("compaction"),
reason: SessionEvent.Compaction.Started.data.fields.reason,
summary: Schema.String,
include: Schema.String.pipe(Schema.optional),
recent: Schema.String,
...Base,
}) {}

View File

@ -168,23 +168,6 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
return message.type === "assistant" ? message : undefined
})
},
getCurrentCompaction() {
return Effect.gen(function* () {
const row = yield* db
.select()
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "compaction")),
)
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!row) return
const message = decodeRow(row)
return message.type === "compaction" ? message : undefined
})
},
getCurrentShell(callID) {
return Effect.gen(function* () {
const rows = yield* db
@ -200,7 +183,6 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
})
},
updateAssistant: updateMessage,
updateCompaction: updateMessage,
updateShell: updateMessage,
appendMessage,
}
@ -452,13 +434,14 @@ export const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Delta, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Ended, (event) => {
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
return run(db, event).pipe(
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)),
)
if (event.version === 1) return Effect.void
const seq = event.seq
if (seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
return Effect.gen(function* () {
yield* run(db, event)
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, seq)
})
})
}),
)

View File

@ -1,6 +1,7 @@
import { LLM, LLMClient, LLMError, LLMEvent, SystemPart } from "@opencode-ai/llm"
import { Cause, DateTime, Effect, FiberSet, Layer, Schema, Semaphore, Stream } from "effect"
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
import { Database } from "../../database/database"
import { EventV2 } from "../../event"
import { Location } from "../../location"
@ -12,7 +13,9 @@ import { SystemContextRegistry } from "../../system-context/registry"
import { SkillGuidance } from "../../skill/guidance"
import { ToolRegistry } from "../../tool/registry"
import { SessionContextEpoch } from "../context-epoch"
import { SessionCompaction } from "../compaction"
import { SessionEvent } from "../event"
import { SessionHistory } from "../history"
import { SessionInput } from "../input"
import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
@ -86,7 +89,9 @@ export const layer = Layer.effect(
const location = yield* Location.Service
const systemContext = yield* SystemContextRegistry.Service
const skillGuidance = yield* SkillGuidance.Service
const config = yield* Config.Service
const db = (yield* Database.Service).db
const compact = SessionCompaction.make({ events, llm, config: yield* config.entries() })
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
@ -180,7 +185,8 @@ export const layer = Layer.effect(
if ((yield* agents.select(current.agent)).id !== agent.id || !sameModel(current.model, session.model))
return yield* Effect.die(new RetryTurn(undefined))
const model = yield* models.resolve(session)
const context = yield* store.runnerContext(session.id, system.baselineSeq)
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
const context = entries.map((entry) => entry.message)
const request = LLM.request({
model,
system: [agent.info?.system, system.baseline]
@ -189,6 +195,8 @@ export const layer = Layer.effect(
messages: toLLMMessages(context, model),
tools: yield* tools.definitions(),
})
if (yield* compact({ sessionID: session.id, entries, model, request }))
return yield* Effect.die(new RetryTurn(undefined))
const publisher = createLLMEventPublisher(events, {
sessionID: session.id,
agent: agent.id,

View File

@ -129,7 +129,17 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
Message.make({
id: message.id,
role: "user",
content: `Summary of earlier conversation:\n${message.summary}`,
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>`,
metadata: message.metadata,
}),
]

View File

@ -0,0 +1,5 @@
export * as Token from "./token"
const CHARS_PER_TOKEN = 4
export const estimate = (input: string) => Math.max(0, Math.round(input.length / CHARS_PER_TOKEN))

View File

@ -55,7 +55,6 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
auto: info.compaction.auto,
prune: info.compaction.prune,
keep: {
turns: info.compaction.tail_turns,
tokens: info.compaction.preserve_recent_tokens,
},
buffer: info.compaction.reserved,

View File

@ -318,7 +318,7 @@ describe("Config", () => {
compaction: {
auto: true,
prune: false,
keep: { turns: 3, tokens: 2000 },
keep: { tokens: 2000 },
buffer: 10000,
},
skills: ["./skills", "~/shared-skills", "https://example.com/.well-known/skills/"],
@ -403,7 +403,7 @@ describe("Config", () => {
expect(documents[0]?.info.compaction).toEqual({
auto: true,
prune: false,
keep: { turns: 3, tokens: 2000 },
keep: { tokens: 2000 },
buffer: 10000,
})
expect(documents[0]?.info.skills).toEqual([
@ -542,7 +542,7 @@ describe("Config", () => {
expect(documents[0]?.info.compaction).toEqual({
auto: true,
prune: undefined,
keep: { turns: 3, tokens: 2000 },
keep: { tokens: 2000 },
buffer: 10000,
})
expect(documents[0]?.info.mcp).toMatchObject({

View File

@ -3,6 +3,7 @@ import { DateTime, Effect, Layer, Schema } from "effect"
import { asc, eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { ModelV2 } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
@ -218,18 +219,42 @@ describe("SessionProjector", () => {
callID: "shell-1",
output: "/project",
})
const compactionID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID,
messageID: SessionMessage.ID.create(),
messageID: compactionID,
timestamp: created,
reason: "manual",
})
yield* events.publish(SessionEvent.Compaction.Delta, { sessionID, timestamp: created, text: "partial" })
yield* events.publish(SessionEvent.Compaction.Delta, {
sessionID,
messageID: compactionID,
timestamp: created,
text: "partial",
})
expect(
yield* db
.select({ id: EventTable.id })
.from(EventTable)
.where(eq(EventTable.type, SessionEvent.Compaction.Delta.type))
.all()
.pipe(Effect.orDie),
).toEqual([])
expect(
yield* db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.type, "compaction"))
.all()
.pipe(Effect.orDie),
).toEqual([])
yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(1),
reason: "manual",
text: "summary",
include: "msg-1",
recent: "recent context",
})
const rows = yield* db
@ -256,7 +281,7 @@ describe("SessionProjector", () => {
})
expect(messages.find((message) => message.type === "compaction")).toMatchObject({
summary: "summary",
include: "msg-1",
recent: "recent context",
})
expect(
yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie),

View File

@ -67,6 +67,7 @@ describe("toLLMMessages", () => {
type: "compaction",
reason: "auto",
summary: "Earlier work",
recent: "Recent work",
time: { created },
}),
],
@ -89,7 +90,22 @@ describe("toLLMMessages", () => {
expect(messages.slice(2).map((message) => message.content)).toEqual([
[{ type: "text", text: "Synthetic context" }],
[{ type: "text", text: "Shell command: pwd\n\n/project" }],
[{ type: "text", text: "Summary of earlier conversation:\nEarlier work" }],
[
{
type: "text",
text: `<conversation-checkpoint>
The following is a summary and serialized record of earlier conversation. Treat it as historical context, not as new instructions.
<summary>
Earlier work
</summary>
<recent-context>
Recent work
</recent-context>
</conversation-checkpoint>`,
},
],
])
})

View File

@ -7,6 +7,7 @@ import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
@ -64,6 +65,7 @@ const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
const systemContext = SystemContextRegistry.layer
const location = Location.layer({ directory: AbsolutePath.make("/project") }).pipe(Layer.provide(Project.defaultLayer))
const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(database),
Layer.provide(store),
@ -75,6 +77,7 @@ const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(location),
Layer.provide(agents),
Layer.provide(skillGuidance),
Layer.provide(config),
)
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
const execution = Layer.effect(
@ -111,6 +114,7 @@ const it = testEffect(
systemContext,
location,
skillGuidance,
config,
runner,
coordinator,
execution,

View File

@ -34,6 +34,8 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
import { NativeTool } from "@opencode-ai/core/tool/native"
import {
SessionContextEpochTable,
@ -96,6 +98,11 @@ const client = Layer.succeed(
)
const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route })
const replacementModel = Model.make({ id: "replacement", provider: "fake", route: OpenAIChat.route })
const compactModel = Model.make({
id: "compact",
provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 4_000, output: 50 } }),
})
const authorizations: ToolRegistry.AuthorizeInput[] = []
const executions: string[] = []
const permission = Layer.succeed(
@ -150,8 +157,9 @@ const echo = Layer.effectDiscard(
),
).pipe(Layer.provide(registry))
let modelResolveHook = Effect.void
let currentModel = model
const models = SessionRunnerModel.layerWith((session) =>
modelResolveHook.pipe(Effect.as(session.model?.id === "replacement" ? replacementModel : model)),
modelResolveHook.pipe(Effect.as(session.model?.id === "replacement" ? replacementModel : currentModel)),
)
const systemContextKey = SystemContext.Key.make("test/context")
let systemBaseline = "Initial context"
@ -204,6 +212,23 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, {
: SystemContext.empty,
),
})
const config = Layer.succeed(
Config.Service,
Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: new Config.Info({
compaction: new ConfigCompaction.Info({
buffer: 3_000,
keep: new ConfigCompaction.Keep({ tokens: 1_000 }),
}),
}),
}),
]),
}),
)
const runner = SessionRunnerLLM.layer.pipe(
Layer.provide(database),
Layer.provide(store),
@ -215,6 +240,7 @@ const runner = SessionRunnerLLM.layer.pipe(
Layer.provide(location),
Layer.provide(agents),
Layer.provide(skillGuidance),
Layer.provide(config),
)
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
const execution = Layer.effect(
@ -253,6 +279,7 @@ const it = testEffect(
systemContext,
location,
skillGuidance,
config,
runner,
coordinator,
execution,
@ -288,6 +315,7 @@ const setup = Effect.gen(function* () {
systemUnavailable = false
systemLoadHook = Effect.void
modelResolveHook = Effect.void
currentModel = model
skillBaselines.clear()
responses = undefined
streamFailure = undefined
@ -1337,16 +1365,20 @@ describe("SessionRunnerLLM", () => {
requests.length = 0
response = []
yield* session.resume(sessionID)
const compactionID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID,
messageID: SessionMessage.ID.create(),
messageID: compactionID,
timestamp: DateTime.makeUnsafe(1),
reason: "manual",
})
yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(2),
reason: "manual",
text: "summary",
recent: "",
})
systemBaseline = "Replacement context"
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
@ -1362,6 +1394,68 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("automatically compacts into a completed summary and retained recent turn", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
response = fragmentFixture("text", "text-first", ["Earlier answer"]).completeEvents
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Earlier question ".repeat(180) }),
resume: false,
})
yield* session.resume(sessionID)
currentModel = compactModel
requests.length = 0
responses = [
fragmentFixture("text", "text-summary", ["## Goal\n- Preserve the task"]).completeEvents,
fragmentFixture("text", "text-final", ["Continued"]).completeEvents,
]
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Recent exact request ".repeat(180) }),
resume: false,
})
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(userTexts(requests[0])[0]).toContain("## Goal")
expect(userTexts(requests[1])).toHaveLength(1)
expect(userTexts(requests[1])[0]).toContain("<summary>\n## Goal\n- Preserve the task\n</summary>")
expect(userTexts(requests[1])[0]).toContain(`[User]: ${"Recent exact request ".repeat(180)}`)
const context = yield* (yield* SessionStore.Service).context(sessionID)
expect(context.map((message) => message.type)).toEqual(["compaction", "assistant"])
expect(context[0]).toMatchObject({
type: "compaction",
summary: "## Goal\n- Preserve the task",
})
requests.length = 0
responses = [
fragmentFixture("text", "text-summary-2", ["## Goal\n- Preserve the updated task"]).completeEvents,
fragmentFixture("text", "text-final-2", ["Continued again"]).completeEvents,
]
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Newest exact request ".repeat(180) }),
resume: false,
})
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(userTexts(requests[0])[0]).toContain(
"<previous-summary>\n## Goal\n- Preserve the task\n</previous-summary>",
)
expect(userTexts(requests[0])[0]).toContain("Recent exact request")
expect((yield* (yield* SessionStore.Service).context(sessionID))[0]).toMatchObject({
type: "compaction",
summary: "## Goal\n- Preserve the updated task",
})
}),
)
it.effect("preserves effective System updates while compaction replacement is blocked", () =>
Effect.gen(function* () {
yield* setup
@ -1375,16 +1469,20 @@ describe("SessionRunnerLLM", () => {
systemBaseline = "Changed context"
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
const compactionID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID,
messageID: SessionMessage.ID.create(),
messageID: compactionID,
timestamp: DateTime.makeUnsafe(1),
reason: "manual",
})
yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(2),
reason: "manual",
text: "summary",
recent: "",
})
systemUnavailable = true
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })

View File

@ -23,13 +23,6 @@ function ownedAssistant(messages: SessionMessage[], messageID: string) {
return message?.type === "assistant" ? message : undefined
}
function activeCompaction(messages: SessionMessage[]) {
const index = messages.findIndex((message) => message.type === "compaction")
if (index < 0) return
const compaction = messages[index]
return compaction?.type === "compaction" ? compaction : undefined
}
function activeShell(messages: SessionMessage[], callID: string) {
const index = messages.findIndex((message) => message.type === "shell" && message.callID === callID)
if (index < 0) return
@ -410,32 +403,21 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
})
break
case "session.next.retried":
break
case "session.next.compaction.started":
case "session.next.compaction.delta":
break
case "session.next.compaction.ended":
update(event.properties.sessionID, (draft) => {
prepend(draft, {
id: event.properties.messageID,
type: "compaction",
reason: event.properties.reason,
summary: "",
summary: event.properties.text,
recent: event.properties.recent,
time: { created: event.properties.timestamp },
})
})
break
case "session.next.compaction.delta":
update(event.properties.sessionID, (draft) => {
const match = activeCompaction(draft)
if (match) match.summary += event.properties.text
})
break
case "session.next.compaction.ended":
update(event.properties.sessionID, (draft) => {
const match = activeCompaction(draft)
if (!match) return
match.summary = event.properties.text
match.include = event.properties.include
})
break
}
}

View File

@ -231,7 +231,7 @@ function ShellMessage(props: { message: SessionMessageShell }) {
}
function CompactionMessage(props: { message: SessionMessageCompaction }) {
const { theme, syntax } = useTheme()
const { theme } = useTheme()
return (
<box
marginTop={1}
@ -240,23 +240,7 @@ function CompactionMessage(props: { message: SessionMessageCompaction }) {
titleAlignment="center"
borderColor={theme.borderActive}
flexShrink={0}
>
<Show when={props.message.summary}>
{(summary) => (
<box paddingLeft={3} paddingTop={1}>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={false}
syntaxStyle={syntax()}
content={summary().trim()}
conceal={true}
fg={theme.text}
/>
</box>
)}
</Show>
</box>
/>
)
}

View File

@ -24,6 +24,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { EventV2 } from "@opencode-ai/core/event"
import { buildPrompt } from "@opencode-ai/core/session/compaction"
const log = Log.create({ service: "session.compaction" })
@ -43,42 +44,6 @@ const PRUNE_PROTECTED_TOOLS = ["skill"]
const DEFAULT_TAIL_TURNS = 2
const MIN_PRESERVE_RECENT_TOKENS = 2_000
const MAX_PRESERVE_RECENT_TOKENS = 8_000
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
<template>
## Goal
- [single-sentence task summary]
## Constraints & Preferences
- [user constraints, preferences, specs, or "(none)"]
## Progress
### Done
- [completed work or "(none)"]
### In Progress
- [current work or "(none)"]
### Blocked
- [blockers or "(none)"]
## Key Decisions
- [decision and why, or "(none)"]
## Next Steps
- [ordered next actions or "(none)"]
## Critical Context
- [important technical facts, errors, open questions, or "(none)"]
## Relevant Files
- [file or directory path: why it matters, or "(none)"]
</template>
Rules:
- Keep every section, even when empty.
- Use terse bullets, not prose paragraphs.
- Preserve exact file paths, commands, error strings, and identifiers when known.
- Do not mention the summary process or that context was compacted.`
type Turn = {
start: number
end: number
@ -124,19 +89,6 @@ function completedCompactions(messages: SessionV1.WithParts[]) {
})
}
function buildPrompt(input: { previousSummary?: string; context: string[] }) {
const anchor = input.previousSummary
? [
"Update the anchored summary below using the conversation history above.",
"Preserve still-true details, remove stale details, and merge in the new facts.",
"<previous-summary>",
input.previousSummary,
"</previous-summary>",
].join("\n")
: "Create a new anchored summary from the conversation history above."
return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n")
}
function preserveRecentBudget(input: { cfg: ConfigV1.Info; model: Provider.Model }) {
return (
input.cfg.compaction?.preserve_recent_tokens ??
@ -410,6 +362,18 @@ export const layer = Layer.effect(
stripMedia: true,
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
})
const tailIndex = selected.tail_start_id
? history.findIndex((message) => message.info.id === selected.tail_start_id)
: -1
const recent =
tailIndex < 0
? ""
: JSON.stringify(
yield* MessageV2.toModelMessagesEffect(history.slice(tailIndex), model, {
stripMedia: true,
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
}),
)
const ctx = yield* InstanceState.context
const msg: SessionV1.Assistant = {
id: MessageID.ascending(),
@ -572,12 +536,15 @@ export const layer = Layer.effect(
},
)
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID: input.sessionID,
timestamp: DateTime.makeUnsafe(Date.now()),
text: summary ?? "",
include: selected.tail_start_id,
})
if (summary)
yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.make(input.parentID),
timestamp: DateTime.makeUnsafe(Date.now()),
reason: input.auto ? "auto" : "manual",
text: summary ?? "",
recent,
})
}
yield* events.publish(Event.Compacted, { sessionID: input.sessionID })
}
@ -610,7 +577,7 @@ export const layer = Layer.effect(
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
messageID: SessionMessage.ID.make(msg.id),
timestamp: DateTime.makeUnsafe(Date.now()),
reason: input.auto ? "auto" : "manual",
})

View File

@ -1,7 +1 @@
const CHARS_PER_TOKEN = 4
export function estimate(input: string) {
return Math.max(0, Math.round((input || "").length / CHARS_PER_TOKEN))
}
export * as Token from "./token"
export { Token, estimate } from "@opencode-ai/core/util/token"

View File

@ -34,6 +34,8 @@ test.skip("step snapshots carry over to assistant messages", () => {
} satisfies SessionEvent.Event),
)
expect(state.messages).toEqual([])
Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(),
@ -194,10 +196,11 @@ test.skip("tool completion stores completed timestamp", () => {
expect(state.messages[0].content[0].provider).toEqual({ executed: true, metadata: { fake: { status: "done" } } })
})
test.skip("compaction events reduce to compaction message", () => {
test("compaction events reduce to compaction message only when completed", () => {
const state: SessionMessageUpdater.MemoryState = { messages: [] }
const sessionID = SessionID.make("session")
const id = EventV2.ID.create()
const compactionID = SessionMessage.ID.create()
Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
@ -205,19 +208,22 @@ test.skip("compaction events reduce to compaction message", () => {
type: "session.next.compaction.started",
data: {
sessionID,
messageID: SessionMessage.ID.create(),
messageID: compactionID,
timestamp: DateTime.makeUnsafe(1),
reason: "auto",
},
} satisfies SessionEvent.Event),
)
expect(state.messages).toEqual([])
Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(),
type: "session.next.compaction.delta",
data: {
sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(2),
text: "hello ",
},
@ -230,6 +236,7 @@ test.skip("compaction events reduce to compaction message", () => {
type: "session.next.compaction.delta",
data: {
sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(3),
text: "summary",
},
@ -242,20 +249,22 @@ test.skip("compaction events reduce to compaction message", () => {
type: "session.next.compaction.ended",
data: {
sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(4),
reason: "auto",
text: "final summary",
include: "recent context",
recent: "recent context",
},
} satisfies SessionEvent.Event),
)
expect(state.messages).toHaveLength(1)
expect(state.messages[0]).toMatchObject({
id,
id: compactionID,
type: "compaction",
reason: "auto",
summary: "final summary",
include: "recent context",
time: { created: DateTime.makeUnsafe(1) },
recent: "recent context",
time: { created: DateTime.makeUnsafe(4) },
})
})

View File

@ -1177,6 +1177,7 @@ export type GlobalEvent = {
properties: {
timestamp: number
sessionID: string
messageID: string
text: string
}
}
@ -1186,8 +1187,10 @@ export type GlobalEvent = {
properties: {
timestamp: number
sessionID: string
messageID: string
reason: "auto" | "manual"
text: string
include?: string
recent: string
}
}
| {
@ -1655,7 +1658,6 @@ export type GlobalEvent = {
| SyncEventSessionNextToolFailed
| SyncEventSessionNextRetried
| SyncEventSessionNextCompactionStarted
| SyncEventSessionNextCompactionDelta
| SyncEventSessionNextCompactionEnded
}
@ -3690,35 +3692,21 @@ export type SyncEventSessionNextCompactionStarted = {
}
}
export type SyncEventSessionNextCompactionDelta = {
type: "sync"
id: string
syncEvent: {
type: "session.next.compaction.delta.1"
id: string
seq: number
aggregateID: string
data: {
timestamp: number
sessionID: string
text: string
}
}
}
export type SyncEventSessionNextCompactionEnded = {
type: "sync"
id: string
syncEvent: {
type: "session.next.compaction.ended.1"
type: "session.next.compaction.ended.2"
id: string
seq: number
aggregateID: string
data: {
timestamp: number
sessionID: string
messageID: string
reason: "auto" | "manual"
text: string
include?: string
recent: string
}
}
}
@ -4031,7 +4019,7 @@ export type SessionMessageCompaction = {
type: "compaction"
reason: "auto" | "manual"
summary: string
include?: string
recent: string
id: string
metadata?: {
[key: string]: unknown
@ -4737,6 +4725,7 @@ export type EventSessionNextCompactionDelta = {
properties: {
timestamp: number
sessionID: string
messageID: string
text: string
}
}
@ -4747,8 +4736,10 @@ export type EventSessionNextCompactionEnded = {
properties: {
timestamp: number
sessionID: string
messageID: string
reason: "auto" | "manual"
text: string
include?: string
recent: string
}
}

View File

@ -15728,11 +15728,15 @@
"type": "string",
"pattern": "^ses"
},
"messageID": {
"type": "string",
"pattern": "^msg_"
},
"text": {
"type": "string"
}
},
"required": ["timestamp", "sessionID", "text"],
"required": ["timestamp", "sessionID", "messageID", "text"],
"additionalProperties": false
}
},
@ -15760,14 +15764,22 @@
"type": "string",
"pattern": "^ses"
},
"messageID": {
"type": "string",
"pattern": "^msg_"
},
"reason": {
"type": "string",
"enum": ["auto", "manual"]
},
"text": {
"type": "string"
},
"include": {
"recent": {
"type": "string"
}
},
"required": ["timestamp", "sessionID", "text"],
"required": ["timestamp", "sessionID", "messageID", "reason", "text", "recent"],
"additionalProperties": false
}
},
@ -17344,9 +17356,6 @@
{
"$ref": "#/components/schemas/SyncEventSessionNextCompactionStarted"
},
{
"$ref": "#/components/schemas/SyncEventSessionNextCompactionDelta"
},
{
"$ref": "#/components/schemas/SyncEventSessionNextCompactionEnded"
}
@ -23330,59 +23339,6 @@
"required": ["type", "id", "syncEvent"],
"additionalProperties": false
},
"SyncEventSessionNextCompactionDelta": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["sync"]
},
"id": {
"type": "string",
"pattern": "^evt_"
},
"syncEvent": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["session.next.compaction.delta.1"]
},
"id": {
"type": "string",
"pattern": "^evt_"
},
"seq": {
"type": "number"
},
"aggregateID": {
"type": "string"
},
"data": {
"type": "object",
"properties": {
"timestamp": {
"type": "number"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
},
"text": {
"type": "string"
}
},
"required": ["timestamp", "sessionID", "text"],
"additionalProperties": false
}
},
"required": ["type", "id", "seq", "aggregateID", "data"],
"additionalProperties": false
}
},
"required": ["type", "id", "syncEvent"],
"additionalProperties": false
},
"SyncEventSessionNextCompactionEnded": {
"type": "object",
"properties": {
@ -23399,7 +23355,7 @@
"properties": {
"type": {
"type": "string",
"enum": ["session.next.compaction.ended.1"]
"enum": ["session.next.compaction.ended.2"]
},
"id": {
"type": "string",
@ -23421,14 +23377,22 @@
"type": "string",
"pattern": "^ses"
},
"messageID": {
"type": "string",
"pattern": "^msg_"
},
"reason": {
"type": "string",
"enum": ["auto", "manual"]
},
"text": {
"type": "string"
},
"include": {
"recent": {
"type": "string"
}
},
"required": ["timestamp", "sessionID", "text"],
"required": ["timestamp", "sessionID", "messageID", "reason", "text", "recent"],
"additionalProperties": false
}
},
@ -24319,7 +24283,7 @@
"summary": {
"type": "string"
},
"include": {
"recent": {
"type": "string"
},
"id": {
@ -24340,7 +24304,7 @@
"additionalProperties": false
}
},
"required": ["type", "reason", "summary", "id", "time"],
"required": ["type", "reason", "summary", "recent", "id", "time"],
"additionalProperties": false
},
"SessionMessage": {
@ -26485,11 +26449,15 @@
"type": "string",
"pattern": "^ses"
},
"messageID": {
"type": "string",
"pattern": "^msg_"
},
"text": {
"type": "string"
}
},
"required": ["timestamp", "sessionID", "text"],
"required": ["timestamp", "sessionID", "messageID", "text"],
"additionalProperties": false
}
},
@ -26517,14 +26485,22 @@
"type": "string",
"pattern": "^ses"
},
"messageID": {
"type": "string",
"pattern": "^msg_"
},
"reason": {
"type": "string",
"enum": ["auto", "manual"]
},
"text": {
"type": "string"
},
"include": {
"recent": {
"type": "string"
}
},
"required": ["timestamp", "sessionID", "text"],
"required": ["timestamp", "sessionID", "messageID", "reason", "text", "recent"],
"additionalProperties": false
}
},

View File

@ -348,7 +348,7 @@ Behavior affecting long-running conversations and context management.
| ------------ | ----------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------- |
| `compaction` | Automatic compaction, pruning, and context reserve settings | redesign | Group retained verbatim history under `keep` and rename context headroom to `buffer`. |
Retain the compaction capability but redesign the less clear limits. `keep.turns` is the maximum number of recent user turns to preserve verbatim after compaction, and `keep.tokens` is the token budget for those retained turns. `buffer` is the token headroom reserved so automatic compaction triggers before the input window is exhausted.
Retain the compaction capability but redesign the less clear limits. `keep.tokens` is the token budget for recent history serialized into the textual compaction checkpoint. `buffer` is the token headroom reserved so automatic compaction triggers before the input window is exhausted.
```jsonc
{
@ -356,7 +356,6 @@ Retain the compaction capability but redesign the less clear limits. `keep.turns
"auto": true,
"prune": true,
"keep": {
"turns": 2,
"tokens": 2000,
},
"buffer": 10000,

View File

@ -1,5 +1,16 @@
# V2 Schema Changelog
## 2026-06-05: Execute Automatic Session Compaction
- Trigger automatic compaction before provider turns using the complete estimated request and absolute model-aware headroom.
- Preserve the existing structured summary contract and update prior summaries with newly compacted history.
- Store token-bounded recent history as plain serialized text inside the checkpoint instead of replaying provider-native messages.
- Keep compaction starts durable and progress deltas live-only; activate history cutover only from a durable completed summary.
- Version the completed event as `session.next.compaction.ended.2` rather than changing the existing synchronized v1 payload in place.
- Reload the replacement Context Epoch and continue the original pending turn after compaction.
- Preserve full durable history; compaction changes only the active model representation.
- Defer provider-overflow recovery, explicit manual compaction, and deterministic old tool-result pruning.
Record V2 database, durable-event, projected-message, HTTP, and generated SDK schema changes here. Each entry states why the contract changed and whether consumers or stored data need compatibility handling. Commit messages for schema-affecting changes should include the same summary.
This document covers meaningful contract changes introduced on the `feat/opencode-embedded-api` branch since its divergence from `origin/dev`. Mechanical file moves and internal refactors are omitted unless they changed stored data, replay behavior, public HTTP or SDK shapes, or model-facing tool contracts.

View File

@ -98,12 +98,22 @@ Current Context Epoch follow-ups:
- Add configured, remote, and nested instruction sources with explicit precedence and removal semantics.
- Add durable post-crash activity recovery for promoted or provider-dispatched work.
- Integrate actual automatic/context-pressure compaction with epoch replacement.
- Add provider-overflow recovery and explicit manual compaction on top of automatic request-budget compaction.
- Add operational metrics for observation latency, unavailable sources, contention, baseline size, and chronological-update growth.
- Consider watcher-backed per-file caching only if measurements show direct safe-boundary observation is too expensive.
- Expose plugin-defined Context Sources only after plugin reload and scoped cleanup semantics are designed.
- Add clustered Session execution ownership and stale-runtime fencing.
## Automatic Compaction
Before each provider turn, the runner estimates the complete model-visible request and compares it with the selected model's context window minus absolute reserved headroom. The reserve is the greater of the requested/model output allowance and configured `compaction.buffer`. When the request exceeds that budget and older complete turns are available, the runner compacts before executing the pending turn.
Compaction keeps the full transcript durable while replacing its active model representation with one hidden checkpoint containing a structured rolling summary and token-bounded serialized recent context. Provider-native assistant, reasoning, and tool messages never survive across the boundary, avoiding signature and encrypted-reasoning failures when the earlier prefix changes.
`session.next.compaction.started.1` durably identifies the attempt. Compaction deltas are live-only progress. `session.next.compaction.ended.2` durably stores the final summary and serialized recent context; only this completed event projects a model-visible compaction message and requests Context Epoch replacement. A failed or interrupted attempt therefore leaves the previous history boundary active.
Repeated compactions update the previous structured summary with newly compacted messages. The runner then reloads projected history and executes the original pending turn. Provider overflow recovery and deterministic old tool-result pruning remain separate follow-ups.
## V1 Runtime Context Parity
This is the canonical checklist for model-visible runtime context still needed before the V2 runner replaces V1. Keep each behavior in its owning boundary rather than treating all model-visible text as a durable Context Source. Update this table in the PR that changes a status.