diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 24907f1e825e..a6308ea1ad4c 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -3,7 +3,12 @@ export { ImageClient } from "./image-client.js" export { Auth } from "./route/auth.js" export { Provider } from "./provider.js" export { ProviderPackage } from "./provider-package.js" -export { isContextOverflow, isContextOverflowFailure } from "./provider-error.js" +export { + isContextOverflow, + isContextOverflowFailure, + isStaleReasoning, + isStaleReasoningFailure, +} from "./provider-error.js" export type { RouteLanguageModelInput, RouteRoutedLanguageModelInput, diff --git a/packages/ai/src/provider-error.ts b/packages/ai/src/provider-error.ts index 06162f04241c..c8cdc4442b71 100644 --- a/packages/ai/src/provider-error.ts +++ b/packages/ai/src/provider-error.ts @@ -45,6 +45,17 @@ const patterns = [ const payloadPatterns = [/request entity too large/i, /payload too large/i, /request too large/i] +const STALE_REASONING_PATTERNS = [ + /reasoning.*encrypted_content.*not issued to this caller/i, + /was not issued to this caller/i, + /invalid_encrypted_content/i, + /encrypted.*content.*could not be decrypted/i, + /referenced reasoning item .* was not found/i, + /referenced reasoning item .* has expired/i, + /reasoning item .* was not found or has expired/i, + /item .* of type ['"]reasoning['"] was provided without its required following item/i, +] + const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i] export const isContextOverflow = (message: string) => @@ -53,11 +64,19 @@ export const isContextOverflow = (message: string) => export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message)) +export const isStaleReasoning = (message: string) => STALE_REASONING_PATTERNS.some((pattern) => pattern.test(message)) + export const isContextOverflowFailure = (failure: unknown) => failure instanceof AIError ? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow" : Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow" +export const isStaleReasoningFailure = (failure: unknown) => + failure instanceof AIError + ? (failure.reason._tag === "InvalidRequest" && failure.reason.classification === "stale-reasoning") || + isStaleReasoning([failure.message, failure.reason.message, failure.reason.body ?? ""].filter(Boolean).join("\n")) + : Schema.is(ProviderErrorEvent)(failure) && failure.classification === "stale-reasoning" + const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)) const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"]) const AUTH_CODES = new Set(["authentication_error", "permission_error"]) @@ -120,6 +139,8 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason return new InvalidRequestError({ ...details, classification: "context-overflow" }) if (input.status === 413 || isPayloadTooLarge(text)) return new InvalidRequestError({ ...details, classification: "payload-too-large" }) + if (clientScoped && isStaleReasoning(text)) + return new InvalidRequestError({ ...details, classification: "stale-reasoning" }) if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyError(details) if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text))) return new QuotaExceededError(details) diff --git a/packages/ai/src/schema/errors.ts b/packages/ai/src/schema/errors.ts index f284d23603d6..ed6f4effbe5b 100644 --- a/packages/ai/src/schema/errors.ts +++ b/packages/ai/src/schema/errors.ts @@ -2,7 +2,11 @@ import { Schema } from "effect" import { Tool } from "@opencode/schema/tool" import { ModelID, ProviderID, RouteID } from "./ids.js" -export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"]) +export const ProviderFailureClassification = Schema.Literals([ + "context-overflow", + "payload-too-large", + "stale-reasoning", +]) export type ProviderFailureClassification = typeof ProviderFailureClassification.Type export class HttpContext extends Schema.Class("AI.HttpContext")({ diff --git a/packages/ai/test/provider-error.test.ts b/packages/ai/test/provider-error.test.ts index f8c74cb39fe8..dcd823af79dc 100644 --- a/packages/ai/test/provider-error.test.ts +++ b/packages/ai/test/provider-error.test.ts @@ -202,4 +202,19 @@ describe("provider error rawBody classification", () => { classifyProviderFailure({ message: "Request failed", rawBody: '{"error":{"code":"insufficient_quota"}}' })._tag, ).toBe("QuotaExceeded") }) + + test("classifies stale and invalid encrypted reasoning errors", () => { + const cases = [ + "Error from provider (Console): Upstream request failed: [invalid_request_error] reasoning `encrypted_content` was not issued to this caller", + "Upstream request failed: [invalid_request_error] reasoning 'encrypted_content' was not issued to this caller", + "Upstream request failed: [invalid_encrypted_content] The encrypted content could not be verified. Reason: Encrypted content could not be decrypted or parsed.", + "Referenced reasoning item 'rs_123' was not found or has expired", + "Item 'rs_0a1b' of type 'reasoning' was provided without its required following item.", + ] + for (const message of cases) { + const reason = classifyProviderFailure({ message, status: 400 }) + expect(reason._tag).toBe("InvalidRequest") + expect(reason).toMatchObject({ classification: "stale-reasoning" }) + } + }) }) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 1c8103e73e64..b5838e76f99f 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -2,7 +2,7 @@ export * as SessionRunnerLLM from "./llm.js" import { Message } from "@opencode/ai" import { and, desc, eq, sql } from "drizzle-orm" -import { Cause, Effect, Exit, FiberMap, Layer } from "effect" +import { Cause, Effect, Exit, FiberMap, Layer, Schema } from "effect" import { Database } from "../../database/database.js" import { Bus } from "../../bus.js" import { InstructionState } from "../instruction-state.js" @@ -33,6 +33,30 @@ import { MAX_STEPS_PROMPT } from "./max-steps.js" const CONTINUE_AFTER_INCOMPLETE_STREAM = "The previous response was interrupted. Continue from where you left off without repeating completed content." +const encodeAssistantContent = Schema.encodeSync(Schema.Array(SessionMessage.AssistantContent)) + +const sanitizeReasoning = (messages: readonly SessionMessage.Info[]): SessionMessage.Assistant[] => { + const modified: SessionMessage.Assistant[] = [] + for (const message of messages) { + if (message.type !== "assistant") continue + let changed = false + for (const item of message.content) { + if (item.type === "reasoning" && item.state) { + if ("reasoningEncryptedContent" in item.state || "itemId" in item.state) { + const state = { ...item.state } as Record + delete state.reasoningEncryptedContent + delete state.itemId + ;(item as { state?: Record }).state = + Object.keys(state).length > 0 ? state : undefined + changed = true + } + } + } + if (changed) modified.push(message) + } + return modified +} + const layer = Layer.effect( Service, Effect.gen(function* () { @@ -203,6 +227,7 @@ const layer = Layer.effect( let initial: SessionContext.Loaded | undefined = first let recoverOverflow = true let recoverContinuation = true + let recoverStaleReasoning = true while (true) { // Reuse boundary preparation once; retries refresh context without delivering more input. const loaded = initial ?? (yield* prepareContext(sessionID).pipe(Effect.flatMap(context.load))) @@ -255,6 +280,7 @@ const layer = Layer.effect( retry: proposed, }), recoverContinuation, + recoverStaleReasoning, recoverOverflow: Effect.suspend(() => recoverOverflow && compaction.enabled() ? compaction @@ -287,6 +313,18 @@ const layer = Layer.effect( RecoverFull: Effect.fnUntraced(function* () { recoverContinuation = false }), + RecoverStaleReasoning: Effect.fnUntraced(function* () { + recoverStaleReasoning = false + const modified = sanitizeReasoning(loaded.messages) + for (const message of modified) { + const content = encodeAssistantContent(message.content) + yield* bus.publish(SessionEvent.MessageContentUpdated, { + sessionID, + messageID: message.id, + content, + }) + } + }), }) if (completed !== undefined) return completed } diff --git a/packages/core/src/session/runner/step.ts b/packages/core/src/session/runner/step.ts index b43def2c35a6..5614ff70b4b1 100644 --- a/packages/core/src/session/runner/step.ts +++ b/packages/core/src/session/runner/step.ts @@ -6,6 +6,7 @@ import { LLMClient, LLMEvent, isContextOverflowFailure, + isStaleReasoningFailure, type ProviderErrorEvent, type ToolCall, } from "@opencode/ai" @@ -37,6 +38,7 @@ export type Outcome = Data.TaggedEnum<{ readonly decision: SessionRunnerRetry.Decision } RecoverFull: {} + RecoverStaleReasoning: {} Compacted: {} }> export const Outcome = Data.taggedEnum() @@ -53,6 +55,7 @@ interface Input { retry: boolean, ) => Effect.Effect<{ readonly retry: false } | SessionRunnerRetry.Decision> readonly recoverContinuation: boolean + readonly recoverStaleReasoning: boolean /** The runner owns compaction policy; the attempt invokes it only before durable output. */ readonly recoverOverflow: Effect.Effect } @@ -169,6 +172,12 @@ export const make = Effect.gen(function* () { !recorded.outputStarted ) return Outcome.RecoverFull() + if ( + input.recoverStaleReasoning && + isStaleReasoningFailure(llmFailure) && + !recorded.outputStarted + ) + return Outcome.RecoverStaleReasoning() const retry = llmFailure && llmError && !isContextOverflowFailure(llmFailure) ? yield* restore( diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 608fe723d699..53bfd6990975 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -3435,6 +3435,172 @@ describe("SessionRunnerLLM", () => { ]) }) + scenario("recovers from stale encrypted reasoning without failing the session", function* (s) { + yield* s.admit("Think first") + + yield* s.llm.push( + TestLLM.stop( + LLMEvent.reasoningStart({ id: "reasoning-openai" }), + LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }), + LLMEvent.reasoningEnd({ + id: "reasoning-openai", + providerMetadata: { + openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" }, + }, + }), + LLMEvent.textStart({ id: "text-1" }), + LLMEvent.textDelta({ id: "text-1", text: "First answer" }), + LLMEvent.textEnd({ id: "text-1" }), + ), + ) + yield* s.resume + yield* replaySessionProjection(sessionID) + + expect(yield* s.context).toMatchObject([ + Expected.user("Think first"), + Expected.assistant({}, [ + { + type: "reasoning", + text: "Encrypted thought", + state: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" }, + }, + { + type: "text", + text: "First answer", + }, + ]), + ]) + + yield* s.admit("Continue") + yield* s.llm.push( + Stream.fail( + new AIError({ + reason: new InvalidRequestError({ + message: "reasoning `encrypted_content` was not issued to this caller", + classification: "stale-reasoning", + }), + }), + ), + TestLLM.text("Recovered answer", "text-recovered"), + ) + yield* s.resume + + expect(s.requests).toHaveLength(3) + expect(s.requests[1]?.messages[1]?.content).toEqual([ + { + type: "reasoning", + text: "Encrypted thought", + providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, + }, + { + type: "text", + text: "First answer", + }, + ]) + expect(s.requests[2]?.messages[1]?.content).toEqual([ + { + type: "reasoning", + text: "Encrypted thought", + providerMetadata: undefined, + }, + { + type: "text", + text: "First answer", + }, + ]) + + yield* replaySessionProjection(sessionID) + const context1 = yield* s.context + const firstAssistant1 = requireAssistant(context1) + const reasoning1 = firstAssistant1.content.find((item) => item.type === "reasoning") + expect(reasoning1?.state).toBeUndefined() + expect(context1).toMatchObject([ + Expected.user("Think first"), + Expected.assistant({}, [ + { + type: "reasoning", + text: "Encrypted thought", + }, + { + type: "text", + text: "First answer", + }, + ]), + Expected.user("Continue"), + Expected.assistant({}, [ + { + type: "text", + text: "Recovered answer", + }, + ]), + ]) + }) + + scenario("recovers from stale encrypted reasoning after tool execution without failing", function* (s) { + yield* s.admit("Echo this") + + yield* s.llm.push( + TestLLM.toolCalls( + LLMEvent.reasoningStart({ id: "reasoning-tool" }), + LLMEvent.reasoningDelta({ id: "reasoning-tool", text: "Tool thought" }), + LLMEvent.reasoningEnd({ + id: "reasoning-tool", + providerMetadata: { + openai: { itemId: "rs_tool", reasoningEncryptedContent: "tool-encrypted-state" }, + }, + }), + LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }), + ), + Stream.fail( + new AIError({ + reason: new InvalidRequestError({ + message: "Referenced reasoning item 'rs_tool' was not found or has expired", + classification: "stale-reasoning", + }), + }), + ), + TestLLM.text("Done after tool", "text-after-tool"), + ) + + yield* s.resume + + // 1st request: user prompt -> returns reasoning + tool call + // 2nd request: continuation with tool result + stale reasoning metadata -> fails with stale-reasoning + // 3rd request: retried continuation with tool result + sanitized reasoning -> succeeds with text + expect(s.requests).toHaveLength(3) + expect(s.executions).toEqual(["hello"]) + expect(s.requests[1]?.messages[1]?.content).toContainEqual({ + type: "reasoning", + text: "Tool thought", + providerMetadata: { openai: { itemId: "rs_tool", reasoningEncryptedContent: "tool-encrypted-state" } }, + }) + expect(s.requests[2]?.messages[1]?.content).toContainEqual({ + type: "reasoning", + text: "Tool thought", + providerMetadata: undefined, + }) + + yield* replaySessionProjection(sessionID) + const context2 = yield* s.context + const firstAssistant2 = requireAssistant(context2) + const reasoning2 = firstAssistant2.content.find((item) => item.type === "reasoning") + expect(reasoning2?.state).toBeUndefined() + expect(context2).toMatchObject([ + Expected.user("Echo this"), + Expected.assistant({ finish: "tool-calls" }, [ + { + type: "reasoning", + text: "Tool thought", + }, + Expected.completedTool( + { id: "call-echo", name: "echo" }, + { input: { text: "hello" }, content: [Expected.text("hello")] }, + ), + ]), + Expected.assistant({ finish: "stop" }, [Expected.text("Done after tool")]), + ]) + }) + scenario("keeps one durable reasoning part when reasoning closes after text", function* (s) { yield* s.admit("Think and answer") diff --git a/packages/core/test/session-step.test.ts b/packages/core/test/session-step.test.ts index 5c6150b549b6..a684e2604405 100644 --- a/packages/core/test/session-step.test.ts +++ b/packages/core/test/session-step.test.ts @@ -117,6 +117,7 @@ for (const fixture of [ retry: (_cause, _error, retry) => Effect.succeed(retry ? { retry: true, attempt: 2, delay: 0 } : { retry: false }), recoverContinuation: true, + recoverStaleReasoning: true, recoverOverflow: Effect.succeed(false), }) .pipe(Effect.exit)