Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion packages/core/src/session/runner/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Message,
SystemPart,
isContextOverflowFailure,
isStaleReasoningFailure,
type ProviderErrorEvent,
} from "@opencode-ai/llm"
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
Expand All @@ -30,6 +31,7 @@ import { SessionEvent } from "../event"
import { SessionHistory } from "../history"
import { SessionInput } from "../input"
import { SessionSchema } from "../schema"
import { SessionStaleReasoning } from "../stale-reasoning"
import { SessionStore } from "../store"
import { type RunError, Service } from "./index"
import { SessionRunnerModel } from "./model"
Expand Down Expand Up @@ -154,6 +156,8 @@ const layer = Layer.effect(
| { readonly _tag: "ContinueAfterCompaction"; readonly step: number }
// Overflow compaction completed; rebuild once through the path without overflow recovery.
| { readonly _tag: "ContinueAfterOverflowCompaction"; readonly step: number }
// Stale encrypted reasoning was stripped; rebuild once without those caller-bound blobs.
| { readonly _tag: "ContinueAfterStaleReasoning"; readonly step: number }

class TurnTransitionError extends Error {
constructor(readonly transition: TurnTransition) {
Expand All @@ -164,6 +168,8 @@ const layer = Layer.effect(
const continueAfterCompaction = (step: number) => new TurnTransitionError({ _tag: "ContinueAfterCompaction", step })
const continueAfterOverflowCompaction = (step: number) =>
new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step })
const continueAfterStaleReasoning = (step: number) =>
new TurnTransitionError({ _tag: "ContinueAfterStaleReasoning", step })

const loadSystemContext = (agent: AgentV2.Selection) =>
Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], {
Expand All @@ -175,6 +181,7 @@ const layer = Layer.effect(
promotion: SessionInput.Delivery | undefined,
step: number,
recoverOverflow?: typeof compaction.compactAfterOverflow,
recoverStaleReasoning = true,
) {
const session = yield* getSession(sessionID)
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
Expand Down Expand Up @@ -236,15 +243,20 @@ const layer = Layer.effect(
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
withPublication(publisher.publish(event, outputPaths))
let overflowFailure: ProviderErrorEvent | undefined
let staleReasoningFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(request).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
if (overflowFailure || staleReasoningFailure || publisher.hasProviderError()) return
if (LLMEvent.is.providerError(event)) {
if (isContextOverflowFailure(event) && !publisher.hasAssistantStarted()) {
overflowFailure = event
return
}
if (recoverStaleReasoning && isStaleReasoningFailure(event) && !publisher.hasAssistantStarted()) {
staleReasoningFailure = event
return
}
}
yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
Expand Down Expand Up @@ -293,7 +305,16 @@ const layer = Layer.effect(
(yield* restore(recoverOverflow({ sessionID: session.id, entries, model, request })))
)
return yield* Effect.die(continueAfterOverflowCompaction(currentStep))
if (
recoverStaleReasoning &&
!publisher.hasAssistantStarted() &&
isStaleReasoningFailure(staleReasoningFailure ?? failure)
) {
yield* restore(SessionStaleReasoning.persist(db, session.id, context))
return yield* Effect.die(continueAfterStaleReasoning(currentStep))
}
if (overflowFailure) yield* publish(overflowFailure)
if (staleReasoningFailure) yield* publish(staleReasoningFailure)
const llmFailure = failure instanceof LLMError ? failure : undefined
if (llmFailure && !publisher.hasProviderError()) {
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
Expand Down Expand Up @@ -366,6 +387,8 @@ const layer = Layer.effect(
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow")
if (defect.transition._tag === "ContinueAfterStaleReasoning")
return yield* runTurnAttempt(sessionID, undefined, defect.transition.step, undefined, false)
yield* Effect.yieldNow
return yield* runAfterOverflowCompaction(sessionID, undefined, defect.transition.step)
}),
Expand All @@ -381,6 +404,14 @@ const layer = Layer.effect(
yield* Effect.yieldNow
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
return yield* runAfterOverflowCompaction(sessionID, undefined, defect.transition.step)
if (defect.transition._tag === "ContinueAfterStaleReasoning")
return yield* runTurnAttempt(
sessionID,
undefined,
defect.transition.step,
compaction.compactAfterOverflow,
false,
)
return yield* runTurn(sessionID, undefined, defect.transition.step)
}),
),
Expand Down
54 changes: 54 additions & 0 deletions packages/core/src/session/stale-reasoning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { and, eq } from "drizzle-orm"
import { Effect, Schema } from "effect"
import type { Database } from "../database/database"
import { SessionMessage } from "./message"
import type { SessionSchema } from "./schema"
import { SessionMessageTable } from "./sql"

const encodeMessage = Schema.encodeSync(SessionMessage.Message)

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)

const stripOpenaiReplay = (metadata: Record<string, unknown> | undefined) => {
if (!metadata) return { metadata, changed: false }
const openai = metadata.openai
if (!isRecord(openai)) return { metadata, changed: false }
if (!("reasoningEncryptedContent" in openai) && !("itemId" in openai)) return { metadata, changed: false }
const nextOpenai = { ...openai }
delete nextOpenai.reasoningEncryptedContent
delete nextOpenai.itemId
const next = { ...metadata }
if (Object.keys(nextOpenai).length === 0) delete next.openai
else next.openai = nextOpenai
return { metadata: Object.keys(next).length === 0 ? undefined : next, changed: true }
}

export const persist = Effect.fn("SessionStaleReasoning.persist")(function* (
db: Database.Interface["db"],
sessionID: SessionSchema.ID,
messages: readonly SessionMessage.Message[],
) {
for (const message of messages) {
if (message.type !== "assistant") continue
let changed = false
const content = message.content.map((item) => {
if (item.type !== "reasoning") return item
const stripped = stripOpenaiReplay(item.providerMetadata as Record<string, unknown> | undefined)
if (!stripped.changed) return item
changed = true
return { ...item, providerMetadata: stripped.metadata }
})
if (!changed) continue
const encoded = encodeMessage({ ...message, content })
const data = Object.fromEntries(Object.entries(encoded).filter(([key]) => key !== "id" && key !== "type"))
yield* db
.update(SessionMessageTable)
.set({ data: data as typeof SessionMessageTable.$inferInsert.data })
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.id, message.id)))
.run()
.pipe(Effect.orDie)
}
})

export * as SessionStaleReasoning from "./stale-reasoning"
138 changes: 138 additions & 0 deletions packages/core/test/session-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1289,6 +1289,112 @@ describe("SessionRunnerLLM", () => {
}),
)

it.effect("strips stale encrypted reasoning and retries once", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Think first" }), resume: false })
requests.length = 0
response = [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.reasoningStart({
id: "reasoning-openai",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
}),
LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }),
LLMEvent.reasoningEnd({
id: "reasoning-openai",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
}),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
]
yield* session.resume(sessionID)

requests.length = 0
responses = [
[
LLMEvent.providerError({
message: "reasoning `encrypted_content` was not issued to this caller",
classification: "stale-reasoning",
}),
],
fragmentFixture("text", "text-recovered", ["Recovered"]).completeEvents,
]
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
yield* session.resume(sessionID)

expect(requests).toHaveLength(2)
expect(requests[0]?.messages[1]?.content).toEqual([
{
type: "reasoning",
text: "Encrypted thought",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
expect(requests[1]?.messages[1]?.content).toEqual([{ type: "reasoning", text: "Encrypted thought" }])
const stored = yield* session.context(sessionID)
const firstAssistant = stored.find((message) => message.type === "assistant")
expect(firstAssistant).toMatchObject({
type: "assistant",
content: [{ type: "reasoning", text: "Encrypted thought" }],
})
if (firstAssistant?.type === "assistant") {
const reasoning = firstAssistant.content.find((item) => item.type === "reasoning")
expect(reasoning && "providerMetadata" in reasoning ? reasoning.providerMetadata?.openai : undefined).toBeUndefined()
}
expect(stored.slice(-1)).toMatchObject([{ type: "assistant", finish: "stop" }])
}),
)

it.effect("recovers once from a raw stale encrypted reasoning failure", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Think first" }), resume: false })
requests.length = 0
response = [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.reasoningStart({
id: "reasoning-openai",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
}),
LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }),
LLMEvent.reasoningEnd({
id: "reasoning-openai",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
}),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
]
yield* session.resume(sessionID)

requests.length = 0
responseStream = Stream.fail(
new LLMError({
module: "test",
method: "stream",
reason: new InvalidRequestReason({
message:
"Error from provider (Console): Upstream request failed: [invalid_request_error] reasoning `encrypted_content` was not issued to this caller",
}),
}),
)
responses = [fragmentFixture("text", "text-recovered", ["Recovered"]).completeEvents]
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
yield* session.resume(sessionID)

expect(requests).toHaveLength(2)
expect(requests[1]?.messages[1]?.content).toEqual([{ type: "reasoning", text: "Encrypted thought" }])
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Think first" },
{ type: "assistant" },
{ type: "user", text: "Continue" },
{ type: "assistant", finish: "stop" },
])
}),
)

it.effect("publishes the original overflow when recovery summarization fails", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
Expand Down Expand Up @@ -3193,6 +3299,38 @@ describe("SessionRunnerLLM", () => {
}),
)

it.effect("does not recover stale encrypted reasoning after durable assistant output", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail after output" }), resume: false })

requests.length = 0
response = [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "text-partial" }),
LLMEvent.textDelta({ id: "text-partial", text: "Partial" }),
LLMEvent.textEnd({ id: "text-partial" }),
LLMEvent.providerError({
message: "reasoning `encrypted_content` was not issued to this caller",
classification: "stale-reasoning",
}),
]
yield* session.resume(sessionID)

expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Fail after output" },
{
type: "assistant",
finish: "error",
error: { message: "reasoning `encrypted_content` was not issued to this caller" },
content: [{ type: "text", text: "Partial" }],
},
])
}),
)

it.effect("does not recover context overflow after durable assistant output", () =>
Effect.gen(function* () {
yield* setup
Expand Down
2 changes: 1 addition & 1 deletion packages/llm/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export { LLMClient } from "./route/client"
export { Auth } from "./route/auth"
export { Provider } from "./provider"
export { isContextOverflow, isContextOverflowFailure } from "./provider-error"
export { isContextOverflow, isContextOverflowFailure, isStaleReasoning, isStaleReasoningFailure } from "./provider-error"
export type {
RouteModelInput,
RouteRoutedModelInput,
Expand Down
9 changes: 7 additions & 2 deletions packages/llm/src/protocols/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
type ToolResultPart,
} from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
import { isContextOverflow } from "../provider-error"
import { isContextOverflow, isStaleReasoning } from "../provider-error"
import { OpenAIOptions } from "./utils/openai-options"
import { Lifecycle } from "./utils/lifecycle"
import { ToolSchemaProjection } from "./utils/tool-schema"
Expand Down Expand Up @@ -906,7 +906,12 @@ const providerError = (event: OpenAIResponsesEvent, fallback: string) => {
const message = providerErrorMessage(event, fallback)
return LLMEvent.providerError({
message,
classification: code === "context_length_exceeded" || isContextOverflow(message) ? "context-overflow" : undefined,
classification:
code === "invalid_encrypted_content" || isStaleReasoning(message)
? "stale-reasoning"
: code === "context_length_exceeded" || isContextOverflow(message)
? "context-overflow"
: undefined,
})
}

Expand Down
29 changes: 29 additions & 0 deletions packages/llm/src/provider-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,40 @@ const patterns = [

const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]

const staleReasoningPatterns = [
/reasoning.*encrypted_content.*not issued to this caller/i,
/encrypted_content.*was not issued to this caller/i,
/invalid_encrypted_content/i,
/encrypted content could not be (verified|decrypted|parsed)/i,
/referenced reasoning item .* (was not found|has expired)/i,
/item .* of type ['"]reasoning['"] was provided without its required following item/i,
]

export const isContextOverflow = (message: string) =>
!exclusions.some((pattern) => pattern.test(message)) &&
(patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message))

export const isStaleReasoning = (message: string) => staleReasoningPatterns.some((pattern) => pattern.test(message))

export const isContextOverflowFailure = (failure: unknown) =>
failure instanceof LLMError
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"

const failureText = (failure: unknown) => {
if (failure instanceof LLMError) return `${failure.message}\n${failure.reason.message}`
if (Schema.is(ProviderErrorEvent)(failure)) return failure.message
if (failure instanceof Error) return failure.message
return ""
}

export const isStaleReasoningFailure = (failure: unknown) => {
if (
failure instanceof LLMError &&
failure.reason._tag === "InvalidRequest" &&
failure.reason.classification === "stale-reasoning"
)
return true
if (Schema.is(ProviderErrorEvent)(failure) && failure.classification === "stale-reasoning") return true
return isStaleReasoning(failureText(failure))
}
Loading
Loading