fix(core): harden compaction recovery

This commit is contained in:
Aiden Cline 2026-07-09 18:46:17 -05:00
commit 71751ac339
2 changed files with 49 additions and 20 deletions

View file

@ -61,7 +61,7 @@ type Dependencies = {
readonly llm: { readonly llm: {
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError> readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
} }
readonly config: readonly Config.Entry[] readonly config: Settings
} }
export type AutoInput = { export type AutoInput = {
@ -193,7 +193,7 @@ export const buildPrompt = (input: { readonly previousSummary?: string; readonly
].join("\n\n") ].join("\n\n")
const make = (dependencies: Dependencies) => { const make = (dependencies: Dependencies) => {
const config = settings(dependencies.config) const config = dependencies.config
const compact = Effect.fn("SessionCompaction.compact")(function* (input: { const compact = Effect.fn("SessionCompaction.compact")(function* (input: {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly model: Model readonly model: Model
@ -232,6 +232,8 @@ const make = (dependencies: Dependencies) => {
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error", type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
message: event.message, message: event.message,
} }
if (LLMEvent.is.finish(event) && event.reason === "length")
failure = { type: "compaction.failed", message: "Compaction reached the model output limit" }
if (LLMEvent.is.textDelta(event)) { if (LLMEvent.is.textDelta(event)) {
chunks.push(event.text) chunks.push(event.text)
return dependencies.events.publish(SessionEvent.Compaction.Delta, { return dependencies.events.publish(SessionEvent.Compaction.Delta, {
@ -284,16 +286,7 @@ const make = (dependencies: Dependencies) => {
}, },
) { ) {
const selected = select(input.messages, config.tokens) const selected = select(input.messages, config.tokens)
if (!selected) { if (!selected) return false
if (input.inputID === undefined) return false
yield* dependencies.events.publish(SessionEvent.Compaction.Failed, {
sessionID: input.sessionID,
reason: input.reason,
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: input.inputID,
})
return false
}
const previousSummary = input.messages.find( const previousSummary = input.messages.find(
(message) => message.type === "compaction" && message.status === "completed", (message) => message.type === "compaction" && message.status === "completed",
) )
@ -318,7 +311,6 @@ const make = (dependencies: Dependencies) => {
messages: input.messages, messages: input.messages,
model: input.request.model, model: input.request.model,
reason: "auto", reason: "auto",
output: input.request.generation?.maxTokens ?? input.request.model.route.defaults.limits?.output ?? 0,
}) })
}) })
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: CompactInput) { const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: CompactInput) {
@ -377,12 +369,22 @@ export const layer = Layer.effect(
const llm = yield* LLMClient.Service const llm = yield* LLMClient.Service
const config = yield* Config.Service const config = yield* Config.Service
const models = yield* SessionRunnerModel.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({ return Service.of({
compactIfNeeded: compaction.compactIfNeeded, compactIfNeeded: compaction.compactIfNeeded,
compactAfterOverflow: compaction.compactAfterOverflow, compactAfterOverflow: compaction.compactAfterOverflow,
compactManual: Effect.fn("SessionCompaction.compactManual")(function* (input) { compactManual: Effect.fn("SessionCompaction.compactManual")(function* (input) {
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( const resolved = yield* models.resolve(input.session).pipe(
Effect.catch((error) => Effect.catch((error) =>
events events

View file

@ -128,10 +128,10 @@ const compactModel = Model.make({
provider: "fake", provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 4_000, output: 50 } }), route: OpenAIChat.route.with({ limits: { context: 4_000, output: 50 } }),
}) })
const undersizedModel = Model.make({ const undersizedContextModel = Model.make({
id: "undersized", id: "undersized-context",
provider: "fake", provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 1, output: 1 } }), route: OpenAIChat.route.with({ limits: { context: 1, output: 1_000 } }),
}) })
const recoveryModel = Model.make({ const recoveryModel = Model.make({
id: "recovery", id: "recovery",
@ -1544,6 +1544,7 @@ describe("SessionRunnerLLM", () => {
yield* setup yield* setup
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
const compaction = yield* session.compact({ sessionID }) const compaction = yield* session.compact({ sessionID })
modelResolveHook = Effect.die("model resolution should not run")
yield* session.resume(sessionID) yield* session.resume(sessionID)
@ -1622,10 +1623,35 @@ describe("SessionRunnerLLM", () => {
}), }),
) )
it.effect("rejects a manual compaction truncated by the model output limit", () =>
Effect.gen(function* () {
const session = yield* setup
response = reply.text("Earlier answer", "text-manual-length-history")
yield* admit(session, "Earlier question")
yield* session.resume(sessionID)
response = [
LLMEvent.textDelta({ id: "summary", text: "Partial summary" }),
LLMEvent.finish({ reason: "length" }),
]
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: "compaction.failed", message: "Compaction reached the model output limit" },
})
}),
)
it.effect("settles an admitted manual compaction when pre-start resolution throws", () => it.effect("settles an admitted manual compaction when pre-start resolution throws", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* setup const session = yield* setup
const session = yield* SessionV2.Service response = reply.text("Earlier answer", "text-manual-resolution-history")
yield* admit(session, "Earlier question")
yield* session.resume(sessionID)
const compaction = yield* session.compact({ sessionID }) const compaction = yield* session.compact({ sessionID })
modelResolveHook = Effect.die("model resolution failed") modelResolveHook = Effect.die("model resolution failed")
@ -1747,7 +1773,7 @@ describe("SessionRunnerLLM", () => {
it.effect("recovers from provider context overflow despite an undersized configured limit", () => it.effect("recovers from provider context overflow despite an undersized configured limit", () =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* setupOverflowRecovery const session = yield* setupOverflowRecovery
currentModel = undersizedModel currentModel = undersizedContextModel
responses = [ responses = [
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })], [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
reply.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"), reply.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"),
@ -1757,6 +1783,7 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID) yield* session.resume(sessionID)
expect(requests).toHaveLength(3) expect(requests).toHaveLength(3)
expect(requests[1].generation?.maxTokens).toBe(1_000)
expect(yield* session.context(sessionID)).toMatchObject([ expect(yield* session.context(sessionID)).toMatchObject([
{ type: "compaction", summary: "## Objective\n- Recover undersized limit" }, { type: "compaction", summary: "## Objective\n- Recover undersized limit" },
{ type: "assistant", finish: "stop" }, { type: "assistant", finish: "stop" },