From 71751ac3397f4a3221cd32ba24e47ca755897f7b Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 9 Jul 2026 18:46:17 -0500 Subject: [PATCH] fix(core): harden compaction recovery --- packages/core/src/session/compaction.ts | 30 +++++++++-------- packages/core/test/session-runner.test.ts | 39 +++++++++++++++++++---- 2 files changed, 49 insertions(+), 20 deletions(-) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 731df71543..6646e02651 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -61,7 +61,7 @@ type Dependencies = { readonly llm: { readonly stream: (request: LLMRequest) => Stream.Stream } - readonly config: readonly Config.Entry[] + readonly config: Settings } export type AutoInput = { @@ -193,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 @@ -232,6 +232,8 @@ const make = (dependencies: Dependencies) => { type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error", 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)) { chunks.push(event.text) return dependencies.events.publish(SessionEvent.Compaction.Delta, { @@ -284,16 +286,7 @@ const make = (dependencies: Dependencies) => { }, ) { const selected = select(input.messages, config.tokens) - if (!selected) { - 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 - } + if (!selected) return false const previousSummary = input.messages.find( (message) => message.type === "compaction" && message.status === "completed", ) @@ -318,7 +311,6 @@ const make = (dependencies: Dependencies) => { 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) { @@ -377,12 +369,22 @@ 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) { + 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 diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 7781b8ad80..cab496733e 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -128,10 +128,10 @@ const compactModel = Model.make({ provider: "fake", route: OpenAIChat.route.with({ limits: { context: 4_000, output: 50 } }), }) -const undersizedModel = Model.make({ - id: "undersized", +const undersizedContextModel = Model.make({ + id: "undersized-context", 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({ id: "recovery", @@ -1544,6 +1544,7 @@ describe("SessionRunnerLLM", () => { 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) @@ -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", () => 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") @@ -1747,7 +1773,7 @@ describe("SessionRunnerLLM", () => { it.effect("recovers from provider context overflow despite an undersized configured limit", () => Effect.gen(function* () { const session = yield* setupOverflowRecovery - currentModel = undersizedModel + currentModel = undersizedContextModel responses = [ [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })], reply.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"), @@ -1757,6 +1783,7 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) expect(requests).toHaveLength(3) + expect(requests[1].generation?.maxTokens).toBe(1_000) expect(yield* session.context(sessionID)).toMatchObject([ { type: "compaction", summary: "## Objective\n- Recover undersized limit" }, { type: "assistant", finish: "stop" },