fix(core): restore resilient compaction (#36163)

This commit is contained in:
Aiden Cline 2026-07-09 19:13:11 -05:00 committed by GitHub
parent 629b304c48
commit 2db7ccb453
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 205 additions and 39 deletions

View file

@ -1,7 +1,8 @@
export * as SessionCompaction from "./compaction"
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
import { Config } from "../config"
import { EventV2 } from "../event"
import { makeLocationNode } from "../effect/app-node"
@ -10,6 +11,7 @@ import { SessionEvent } from "./event"
import type { SessionMessage } from "./message"
import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
import { toSessionError } from "./to-session-error"
import { Token } from "../util/token"
const DEFAULT_BUFFER = 20_000
@ -59,7 +61,7 @@ type Dependencies = {
readonly llm: {
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
}
readonly config: readonly Config.Entry[]
readonly config: Settings
}
export type AutoInput = {
@ -191,7 +193,7 @@ export const buildPrompt = (input: { readonly previousSummary?: string; readonly
].join("\n\n")
const make = (dependencies: Dependencies) => {
const config = settings(dependencies.config)
const config = dependencies.config
const compact = Effect.fn("SessionCompaction.compact")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly model: Model
@ -202,12 +204,9 @@ const make = (dependencies: Dependencies) => {
readonly output?: number
readonly inputID?: SessionMessage.ID
}) {
const context = input.model.route.defaults.limits?.context
if (context === undefined || context <= 0) return false
const output = input.output ?? input.model.route.defaults.limits?.output ?? 0
const summaryPrompt = buildPrompt({ previousSummary: input.previousSummary, context: input.context })
const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS)
if (Token.estimate(summaryPrompt) > context - summaryOutput) return false
yield* dependencies.events.publish(SessionEvent.Compaction.Started, {
sessionID: input.sessionID,
reason: input.reason,
@ -216,7 +215,7 @@ const make = (dependencies: Dependencies) => {
})
const chunks: string[] = []
let failed = false
let failure: SessionError.Error | undefined
const summarized = yield* dependencies.llm
.stream(
LLM.request({
@ -228,7 +227,11 @@ const make = (dependencies: Dependencies) => {
)
.pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event)) failed = true
if (LLMEvent.is.providerError(event))
failure = {
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
message: event.message,
}
if (LLMEvent.is.textDelta(event)) {
chunks.push(event.text)
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
@ -239,7 +242,12 @@ const make = (dependencies: Dependencies) => {
return Effect.void
}),
Effect.as(true),
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
Effect.catchTag("LLM.Error", (error) =>
Effect.sync(() => {
failure = toSessionError(error)
return false
}),
),
Effect.onInterrupt(() =>
input.reason === "auto"
? dependencies.events.publish(SessionEvent.Compaction.Failed, {
@ -252,11 +260,11 @@ const make = (dependencies: Dependencies) => {
),
)
const summary = chunks.join("")
if (!summarized || failed || !summary.trim()) {
if (!summarized || failure || !summary.trim()) {
yield* dependencies.events.publish(SessionEvent.Compaction.Failed, {
sessionID: input.sessionID,
reason: input.reason,
error: { type: "compaction.failed", message: "Compaction produced no summary" },
error: failure ?? { type: "compaction.failed", message: "Compaction produced no summary" },
inputID: input.inputID,
})
return false
@ -269,49 +277,43 @@ const make = (dependencies: Dependencies) => {
})
return true
})
const compactAfterOverflow = Effect.fn("SessionCompaction.compactAfterOverflow")(function* (input: AutoInput) {
return yield* compactSelected({
sessionID: input.sessionID,
messages: input.messages,
model: input.request.model,
reason: "auto",
force: false,
output: input.request.generation?.maxTokens ?? input.request.model.route.defaults.limits?.output ?? 0,
})
})
const compactSelected = Effect.fn("SessionCompaction.compactSelected")(function* (
const compactAvailable = Effect.fn("SessionCompaction.compactAvailable")(function* (
input: CompactInput & {
readonly reason: SessionMessage.Compaction["reason"]
readonly force: boolean
readonly output?: number
},
) {
const context = input.model.route.defaults.limits?.context
if (context === undefined || context <= 0) return false
const selected = select(input.messages, config.tokens)
if (!selected) return false
const previousSummary = input.messages.find(
(message) => message.type === "compaction" && message.status === "completed",
)
const hasHead = selected.head.length > 0
if (!hasHead && previousSummary?.type !== "compaction" && !input.force) return false
const forcedShortContext = input.force && !hasHead
const summarizeRecent = selected.head.length === 0
const previousRecent = previousSummary?.type === "compaction" ? previousSummary.recent : ""
return yield* compact({
sessionID: input.sessionID,
model: input.model,
reason: input.reason,
previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
context: (forcedShortContext ? [previousRecent, selected.recent] : [previousRecent, selected.head]).filter(
context: (summarizeRecent ? [previousRecent, selected.recent] : [previousRecent, selected.head]).filter(
Boolean,
),
recent: forcedShortContext ? "" : selected.recent,
recent: summarizeRecent ? "" : selected.recent,
output: input.output,
inputID: input.inputID,
})
})
const compactAfterOverflow = Effect.fn("SessionCompaction.compactAfterOverflow")(function* (input: AutoInput) {
return yield* compactAvailable({
sessionID: input.sessionID,
messages: input.messages,
model: input.request.model,
reason: "auto",
output: input.request.generation?.maxTokens ?? input.request.model.route.defaults.limits?.output ?? 0,
})
})
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: CompactInput) {
return yield* compactSelected({ ...input, reason: "manual", force: true })
return yield* compactAvailable({ ...input, reason: "manual" })
})
const compactIfNeeded = Effect.fn("SessionCompaction.compactIfNeeded")(function* (input: AutoInput) {
if (!config.auto) return false
@ -323,7 +325,34 @@ const make = (dependencies: Dependencies) => {
context - Math.max(output, config.buffer)
)
return false
return yield* compactAfterOverflow(input)
const selected = select(input.messages, config.tokens)
if (!selected) return false
const previousSummary = input.messages.find(
(message) => message.type === "compaction" && message.status === "completed",
)
if (!selected.head && previousSummary?.type !== "compaction") return false
const previousRecent = previousSummary?.type === "compaction" ? previousSummary.recent : ""
const summaryContext = [previousRecent, selected.head].filter(Boolean)
const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS)
if (
Token.estimate(
buildPrompt({
previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
context: summaryContext,
}),
) >
context - summaryOutput
)
return false
return yield* compact({
sessionID: input.sessionID,
model: input.request.model,
reason: "auto",
previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
context: summaryContext,
recent: selected.recent,
output,
})
})
return {
compactIfNeeded,
@ -339,13 +368,34 @@ export const layer = Layer.effect(
const llm = yield* LLMClient.Service
const config = yield* Config.Service
const models = yield* SessionRunnerModel.Service
const compaction = make({ events, llm, config: yield* config.entries() })
const configured = settings(yield* config.entries())
const compaction = make({ events, llm, config: configured })
return Service.of({
compactIfNeeded: compaction.compactIfNeeded,
compactAfterOverflow: compaction.compactAfterOverflow,
compactManual: Effect.fn("SessionCompaction.compactManual")(function* (input) {
const resolved = yield* models.resolve(input.session).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!select(input.messages, configured.tokens)) {
yield* events.publish(SessionEvent.Compaction.Failed, {
sessionID: input.session.id,
reason: "manual",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: input.inputID,
})
return false
}
const resolved = yield* models.resolve(input.session).pipe(
Effect.catch((error) =>
events
.publish(SessionEvent.Compaction.Failed, {
sessionID: input.session.id,
reason: "manual",
error: toSessionError(error),
inputID: input.inputID,
})
.pipe(Effect.as(undefined)),
),
)
if (!resolved) return false
return yield* compaction.compactManual({
sessionID: input.session.id,

View file

@ -128,6 +128,11 @@ const compactModel = Model.make({
provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 4_000, output: 50 } }),
})
const undersizedContextModel = Model.make({
id: "undersized-context",
provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 1, output: 1_000 } }),
})
const recoveryModel = Model.make({
id: "recovery",
provider: "fake",
@ -1518,11 +1523,12 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("settles an admitted manual compaction that cannot start", () =>
it.effect("explains when manual compaction has no history", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const compaction = yield* session.compact({ sessionID })
modelResolveHook = Effect.die("model resolution should not run")
yield* session.resume(sessionID)
@ -1531,7 +1537,7 @@ describe("SessionRunnerLLM", () => {
type: "compaction",
status: "failed",
reason: "manual",
error: { message: "Compaction could not start" },
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
})
expect(
(yield* recordedEventTypes(sessionID)).filter(
@ -1541,10 +1547,73 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("manually compacts when the model has no context limit", () =>
Effect.gen(function* () {
const session = yield* setup
response = reply.text("Earlier answer", "text-manual-unknown-history")
yield* admit(session, "Earlier question")
yield* session.resume(sessionID)
requests.length = 0
response = reply.text("Manual summary", "text-manual-unknown-summary")
const compaction = yield* session.compact({ sessionID })
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(userTexts(requests[0])[0]).toContain("Earlier question")
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "completed",
summary: "Manual summary",
})
}),
)
it.effect("preserves provider errors from manual compaction", () =>
Effect.gen(function* () {
const session = yield* setup
response = reply.text("Earlier answer", "text-manual-provider-history")
yield* admit(session, "Earlier question")
yield* session.resume(sessionID)
response = [LLMEvent.providerError({ message: "summary unavailable" })]
const compaction = yield* session.compact({ sessionID })
yield* session.resume(sessionID)
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
error: { type: "provider.error", message: "summary unavailable" },
})
}),
)
it.effect("preserves typed provider failures from manual compaction", () =>
Effect.gen(function* () {
const session = yield* setup
response = reply.text("Earlier answer", "text-manual-failure-history")
yield* admit(session, "Earlier question")
yield* session.resume(sessionID)
responseStream = Stream.fail(providerUnavailable())
const compaction = yield* session.compact({ sessionID })
yield* session.resume(sessionID)
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
error: { type: "provider.transport", message: "Provider unavailable" },
})
}),
)
it.effect("settles an admitted manual compaction when pre-start resolution throws", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const session = yield* setup
response = reply.text("Earlier answer", "text-manual-resolution-history")
yield* admit(session, "Earlier question")
yield* session.resume(sessionID)
const compaction = yield* session.compact({ sessionID })
modelResolveHook = Effect.die("model resolution failed")
@ -1643,6 +1712,46 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("recovers from provider context overflow without a configured context limit", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
currentModel = model
responses = [
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
reply.text("## Objective\n- Recover unknown limit", "text-summary-unknown-limit"),
reply.text("Recovered", "text-final-unknown-limit"),
]
yield* admit(session, "Continue")
yield* session.resume(sessionID)
expect(requests).toHaveLength(3)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "compaction", summary: "## Objective\n- Recover unknown limit" },
{ type: "assistant", finish: "stop" },
])
}),
)
it.effect("recovers from provider context overflow despite an undersized configured context limit", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
currentModel = undersizedContextModel
responses = [
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
reply.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"),
reply.text("Recovered", "text-final-undersized-limit"),
]
yield* admit(session, "Continue")
yield* session.resume(sessionID)
expect(requests).toHaveLength(3)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "compaction", summary: "## Objective\n- Recover undersized limit" },
{ type: "assistant", finish: "stop" },
])
}),
)
it.effect("persists a second context overflow after one recovery", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
@ -1702,7 +1811,14 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2)
const context = yield* session.context(sessionID)
expect(context).toContainEqual(expect.objectContaining({ type: "compaction", status: "failed", reason: "auto" }))
expect(context).toContainEqual(
expect.objectContaining({
type: "compaction",
status: "failed",
reason: "auto",
error: { type: "provider.error", message: "summary unavailable" },
}),
)
expect(context.slice(-3)).toMatchObject([
{ type: "user", text: "Continue" },
{ type: "compaction", status: "failed", reason: "auto" },