Bladeren bron

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

Aiden Cline 1 maand geleden
bovenliggende
commit
2db7ccb453
2 gewijzigde bestanden met toevoegingen van 205 en 39 verwijderingen
  1. 84 34
      packages/core/src/session/compaction.ts
  2. 121 5
      packages/core/test/session-runner.test.ts

+ 84 - 34
packages/core/src/session/compaction.ts

@@ -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,

+ 121 - 5
packages/core/test/session-runner.test.ts

@@ -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" },