Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton f8e5b12f41 fix(core): preserve provider session failures 2026-06-24 22:43:18 -04:00
13 changed files with 359 additions and 22 deletions
+27 -3
View File
@@ -6,6 +6,7 @@ import {
Message,
SystemPart,
isContextOverflowFailure,
type LLMErrorReason,
type ProviderErrorEvent,
} from "@opencode-ai/llm"
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
@@ -32,7 +33,7 @@ import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
import { type RunError, Service } from "./index"
import { SessionRunnerModel } from "./model"
import { createLLMEventPublisher } from "./publish-llm-event"
import { createLLMEventPublisher, providerError } from "./publish-llm-event"
import { toLLMMessages } from "./to-llm-message"
import { MAX_STEPS_PROMPT } from "./max-steps"
import { Snapshot } from "../../snapshot"
@@ -87,6 +88,29 @@ import { Snapshot } from "../../snapshot"
* explicit loop starts the next provider turn after local settlement. Configured agent step limits bound the loop.
*/
const providerCategories = {
InvalidRequest: "invalid-request",
NoRoute: "no-route",
Authentication: "authentication",
RateLimit: "rate-limit",
QuotaExceeded: "quota-exceeded",
ContentPolicy: "content-policy",
ProviderInternal: "provider-internal",
Transport: "transport",
InvalidProviderOutput: "invalid-provider-output",
UnknownProvider: "unknown",
} as const satisfies Record<LLMErrorReason["_tag"], Parameters<typeof providerError>[0]["category"]>
const toSessionProviderError = (reason: LLMErrorReason) =>
providerError({
category: providerCategories[reason._tag],
status:
("http" in reason ? reason.http?.response?.status : undefined) ??
("status" in reason ? reason.status : undefined),
retryable: reason.retryable,
retryAfterMs: "retryAfterMs" in reason ? reason.retryAfterMs : undefined,
})
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -283,7 +307,7 @@ export const layer = Layer.effect(
const llmFailure = failure instanceof LLMError ? failure : undefined
if (llmFailure && !publisher.hasProviderError()) {
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
yield* withPublication(publisher.failAssistant(llmFailure.reason.message))
yield* withPublication(publisher.failAssistant(toSessionProviderError(llmFailure.reason)))
}
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
@@ -299,7 +323,7 @@ export const layer = Layer.effect(
yield* FiberSet.clear(toolFibers)
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
if (publisher.hasActiveAssistant())
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
yield* withPublication(publisher.failAssistant({ type: "unknown", message: "Provider turn interrupted" }))
}
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
const failure = Cause.squash(settled.cause)
@@ -50,6 +50,33 @@ const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue):
return { structured: record(settled.structured), content: settled.content }
}
const providerMessages: Record<SessionMessage.ProviderErrorCategory, string> = {
"invalid-request": "Provider rejected the request",
"no-route": "No provider route is available",
authentication: "Provider authentication failed",
"rate-limit": "Provider rate limit exceeded",
"quota-exceeded": "Provider quota exceeded",
"content-policy": "Provider content policy rejected the request",
"provider-internal": "Provider service error",
transport: "Provider connection failed",
"invalid-provider-output": "Provider returned an invalid response",
unknown: "Provider request failed",
}
export const providerError = (input: {
readonly category: SessionMessage.ProviderErrorCategory
readonly status?: number
readonly retryable?: boolean
readonly retryAfterMs?: number
}): SessionMessage.ProviderError => ({
type: "provider",
category: input.category,
message: providerMessages[input.category],
status: input.status,
retryable: input.retryable ?? (input.category === "rate-limit" || input.category === "provider-internal"),
retryAfterMs: input.retryAfterMs,
})
/** Persist one provider turn without executing tools or starting a continuation turn. */
export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) => {
const tools = new Map<
@@ -196,7 +223,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* flushFragments()
})
const failAssistant = Effect.fnUntraced(function* (message: string) {
const failAssistant = Effect.fnUntraced(function* (error: SessionMessage.Error) {
if (assistantFailed) return
yield* flush()
const assistantMessageID = yield* startAssistant()
@@ -206,7 +233,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID,
error: { type: "unknown", message },
error,
})
})
@@ -403,7 +430,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return
case "provider-error":
providerFailed = true
yield* failAssistant(event.message)
yield* failAssistant(
event.category === undefined
? { type: "unknown", message: event.message }
: providerError({ category: event.category, status: event.status, retryable: event.retryable }),
)
return
}
})
@@ -70,16 +70,20 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
const assistant = (message: SessionMessage.Assistant, model: Model) => {
const sameModel =
String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id)
const preserveProviderMetadata = sameModel && message.finish !== "error"
const content = message.content.flatMap((item): ContentPart[] => {
if (item.type === "text") return [{ type: "text", text: item.text }]
if (item.type === "reasoning")
return sameModel
return preserveProviderMetadata
? [{ type: "reasoning", text: item.text, providerMetadata: item.providerMetadata }]
: item.text.length > 0
? [{ type: "text", text: item.text }]
: []
const call = toolCall(item, sameModel ? item.provider?.metadata : undefined)
const result = toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined)
const call = toolCall(item, preserveProviderMetadata ? item.provider?.metadata : undefined)
const result = toolResult(
item,
preserveProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined,
)
return item.provider?.executed === true && result ? [call, result] : [call]
})
const meaningful = content.filter((part) => {
@@ -89,7 +93,12 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
})
const results = message.content
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true)
.map((item) => toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined))
.map((item) =>
toolResult(
item,
preserveProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined,
),
)
.filter((message) => message !== undefined)
.map(Message.tool)
if (meaningful.length === 0) return results
@@ -327,6 +327,83 @@ Recent work
])
})
test("drops same-model provider metadata from errored turns without dropping useful output", () => {
const messages = toLLMMessages(
[
SessionMessage.Assistant.make({
id: id("assistant-error"),
type: "assistant",
agent: "build",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
finish: "error",
error: { type: "unknown", message: "Interrupted" },
content: [
SessionMessage.AssistantText.make({ type: "text", id: "text-error", text: "Partial answer" }),
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning-error",
text: "Visible thought",
providerMetadata: { openai: { reasoningEncryptedContent: "stale-secret" } },
}),
SessionMessage.AssistantTool.make({
type: "tool",
id: "tool-error",
name: "read",
provider: {
executed: false,
metadata: { openai: { itemId: "stale-call" } },
resultMetadata: { openai: { itemId: "stale-result" } },
},
state: SessionMessage.ToolStateError.make({
status: "error",
input: { path: "README.md" },
content: [{ type: "text", text: "Permission denied" }],
structured: {},
error: { type: "unknown", message: "Permission denied" },
}),
time: { created, completed: created },
}),
],
time: { created, completed: created },
}),
],
model,
)
expect(messages).toHaveLength(2)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Partial answer" },
{ type: "text", text: "Visible thought" },
{
type: "tool-call",
id: "tool-error",
name: "read",
input: { path: "README.md" },
providerExecuted: false,
providerMetadata: undefined,
},
])
expect(messages[1]?.content).toEqual([
{
type: "tool-result",
id: "tool-error",
name: "read",
result: {
type: "error",
value: {
error: { type: "unknown", message: "Permission denied" },
content: [{ type: "text", text: "Permission denied" }],
structured: {},
},
},
providerExecuted: false,
cache: undefined,
metadata: undefined,
providerMetadata: undefined,
},
])
})
test("drops provider-native continuation metadata after a model switch", () => {
const messages = toLLMMessages(
[
+108 -3
View File
@@ -4,6 +4,11 @@ import {
LLMError,
LLMEvent,
Model,
HttpContext,
HttpRateLimitDetails,
HttpRequestDetails,
HttpResponseDetails,
RateLimitReason,
TransportReason,
InvalidRequestReason,
type LLMClientShape,
@@ -519,7 +524,7 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) =>
{
type: "assistant",
finish: "error",
error: { type: "unknown", message: "Provider unavailable" },
error: { type: "provider", category: "transport", message: "Provider connection failed", retryable: false },
content: [fixture.expectedContent],
},
])
@@ -2984,11 +2989,106 @@ describe("SessionRunnerLLM", () => {
yield* replaySessionProjection(sessionID)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Fail raw stream durably" },
{ type: "assistant", finish: "error", error: { type: "unknown", message: "Provider unavailable" } },
{
type: "assistant",
finish: "error",
error: { type: "provider", category: "transport", message: "Provider connection failed", retryable: false },
},
])
}),
)
it.effect("preserves sanitized HTTP rate-limit details in the durable event and projection", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const db = yield* Database.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail with rate limit" }), resume: false })
const failure = new LLMError({
module: "RequestExecutor",
method: "execute",
reason: new RateLimitReason({
message: "secret provider response message",
retryAfterMs: 12_000,
rateLimit: new HttpRateLimitDetails({ retryAfterMs: 12_000, limit: { requests: "secret-limit" } }),
http: new HttpContext({
request: new HttpRequestDetails({
method: "POST",
url: "https://secret.example/v1/responses?api_key=credential",
headers: { authorization: "Bearer credential" },
}),
response: new HttpResponseDetails({ status: 429, headers: { "x-secret": "secret-header" } }),
body: '{"secret":"provider body"}',
requestId: "secret-request-id",
}),
}),
})
responseStream = Stream.fail(failure)
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
const event = yield* db.db
.select({ data: EventTable.data })
.from(EventTable)
.where(eq(EventTable.type, EventV2.versionedType(SessionEvent.Step.Failed.type, 2)))
.get()
.pipe(Effect.orDie)
const expected = {
type: "provider",
category: "rate-limit",
message: "Provider rate limit exceeded",
status: 429,
retryable: true,
retryAfterMs: 12_000,
}
expect(event?.data.error).toEqual(expected)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Fail with rate limit" },
{ type: "assistant", finish: "error", error: expected },
])
expect(JSON.stringify(event)).not.toMatch(
/secret provider|secret\.example|credential|secret-header|provider body|secret-request-id|secret-limit/,
)
expect(JSON.stringify(yield* session.context(sessionID))).not.toMatch(
/secret provider|secret\.example|credential|secret-header|provider body|secret-request-id|secret-limit/,
)
}),
)
it.effect("projects in-band rate limits without fabricating an HTTP status", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail in band" }), resume: false })
response = [
LLMEvent.providerError({
message: "rate_limit_exceeded: secret provider message",
category: "rate-limit",
retryable: true,
}),
]
yield* session.resume(sessionID)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Fail in band" },
{
type: "assistant",
finish: "error",
error: {
type: "provider",
category: "rate-limit",
message: "Provider rate limit exceeded",
retryable: true,
},
},
])
const assistant = (yield* session.context(sessionID))[1]
expect(assistant?.type === "assistant" ? assistant.error : undefined).not.toHaveProperty("status")
expect(JSON.stringify(assistant)).not.toContain("secret provider message")
}),
)
it.effect("does not continue automatically after a provider error follows a local tool call", () =>
Effect.gen(function* () {
yield* setup
@@ -3100,7 +3200,12 @@ describe("SessionRunnerLLM", () => {
{
type: "assistant",
finish: "error",
error: { type: "unknown", message: "Provider unavailable" },
error: {
type: "provider",
category: "transport",
message: "Provider connection failed",
retryable: false,
},
content: [{ type: "tool", id: "call-hosted-raw-failure", state: { status: "error" } }],
},
])
+16
View File
@@ -122,6 +122,8 @@ test("Core reuses the canonical shared schemas", async () => {
[coreSessionInput.Admitted, SessionInput.Admitted],
[coreSessionMessage.ID, SessionMessage.ID],
[coreSessionMessage.UnknownError, SessionMessage.UnknownError],
[coreSessionMessage.ProviderError, SessionMessage.ProviderError],
[coreSessionMessage.Error, SessionMessage.Error],
[coreSessionMessage.AgentSwitched, SessionMessage.AgentSwitched],
[coreSessionMessage.ModelSwitched, SessionMessage.ModelSwitched],
[coreSessionMessage.User, SessionMessage.User],
@@ -178,3 +180,17 @@ test("shared record schemas construct and decode plain objects", () => {
expect(Prompt.fromUserMessage({ text: "hello" })).toEqual(made)
expect(Workspace.ID.ascending("")).toStartWith("wrk_")
})
test("assistant errors retain legacy unknown decode compatibility", () => {
const assistant = Schema.decodeUnknownSync(SessionMessage.Assistant)({
id: "msg_legacy_error",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
error: { type: "unknown", message: "Legacy failure" },
time: { created: 0 },
})
expect(assistant.error).toEqual({ type: "unknown", message: "Legacy failure" })
})
@@ -891,6 +891,8 @@ const providerError = (event: OpenAIResponsesEvent, fallback: string) => {
return LLMEvent.providerError({
message,
classification: code === "context_length_exceeded" || isContextOverflow(message) ? "context-overflow" : undefined,
category: code === "rate_limit_exceeded" ? "rate-limit" : undefined,
retryable: code === "rate_limit_exceeded" ? true : undefined,
})
}
+14
View File
@@ -4,6 +4,20 @@ import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
export const ProviderFailureClassification = Schema.Literal("context-overflow")
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
export const ProviderFailureCategory = Schema.Literals([
"invalid-request",
"no-route",
"authentication",
"rate-limit",
"quota-exceeded",
"content-policy",
"provider-internal",
"transport",
"invalid-provider-output",
"unknown",
])
export type ProviderFailureCategory = typeof ProviderFailureCategory.Type
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
method: Schema.String,
url: Schema.String,
+3 -1
View File
@@ -2,7 +2,7 @@ import { Schema } from "effect"
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids"
import { ModelSchema } from "./options"
import { ToolOutput, ToolResultValue } from "./messages"
import { ProviderFailureClassification } from "./errors"
import { ProviderFailureCategory, ProviderFailureClassification } from "./errors"
/**
* Token usage reported by an LLM provider.
@@ -201,6 +201,8 @@ export const ProviderErrorEvent = Schema.Struct({
type: Schema.tag("provider-error"),
message: Schema.String,
classification: Schema.optional(ProviderFailureClassification),
category: Schema.optional(ProviderFailureCategory),
status: Schema.optional(Schema.Number),
retryable: Schema.optional(Schema.Boolean),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ProviderError" })
@@ -1326,7 +1326,14 @@ describe("OpenAI Responses route", () => {
// sometimes-generic provider message. The bare message alone meant
// production errors like rate limits were indistinguishable from
// unrelated stream failures.
expect(response.events).toEqual([{ type: "provider-error", message: "rate_limit_exceeded: Slow down" }])
expect(response.events).toEqual([
{
type: "provider-error",
message: "rate_limit_exceeded: Slow down",
category: "rate-limit",
retryable: true,
},
])
}),
)
+5 -1
View File
@@ -50,6 +50,10 @@ const stepSettlementOptions = {
export const UnknownError = SessionMessage.UnknownError
export type UnknownError = SessionMessage.UnknownError
export const ProviderError = SessionMessage.ProviderError
export type ProviderError = SessionMessage.ProviderError
export const Error = SessionMessage.Error
export type Error = SessionMessage.Error
export const AgentSwitched = Event.define({
type: "session.next.agent.switched",
@@ -188,7 +192,7 @@ export namespace Step {
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
error: UnknownError,
error: Error,
},
})
export type Failed = typeof Failed.Type
+28 -1
View File
@@ -17,6 +17,33 @@ export const UnknownError = Schema.Struct({
message: Schema.String,
}).annotate({ identifier: "Session.Error.Unknown" })
export const ProviderErrorCategory = Schema.Literals([
"invalid-request",
"no-route",
"authentication",
"rate-limit",
"quota-exceeded",
"content-policy",
"provider-internal",
"transport",
"invalid-provider-output",
"unknown",
])
export type ProviderErrorCategory = typeof ProviderErrorCategory.Type
export interface ProviderError extends Schema.Schema.Type<typeof ProviderError> {}
export const ProviderError = Schema.Struct({
type: Schema.Literal("provider"),
category: ProviderErrorCategory,
message: Schema.String,
status: Schema.Finite.pipe(Schema.optional),
retryable: Schema.Boolean,
retryAfterMs: Schema.Finite.pipe(Schema.optional),
}).annotate({ identifier: "Session.Error.Provider" })
export const Error = Schema.Union([UnknownError, ProviderError]).pipe(Schema.toTaggedUnion("type"))
export type Error = UnknownError | ProviderError
const Base = {
id: ID,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
@@ -173,7 +200,7 @@ export const Assistant = Schema.Struct({
reasoning: Schema.Finite,
cache: Schema.Struct({ read: Schema.Finite, write: Schema.Finite }),
}).pipe(Schema.optional),
error: UnknownError.pipe(Schema.optional),
error: Error.pipe(Schema.optional),
time: Schema.Struct({
created: DateTimeUtcFromMillis,
completed: DateTimeUtcFromMillis.pipe(Schema.optional),
+24 -5
View File
@@ -960,7 +960,7 @@ export type GlobalEvent = {
timestamp: number
sessionID: string
assistantMessageID: string
error: SessionErrorUnknown
error: SessionErrorUnknown | SessionErrorProvider
}
}
| {
@@ -3005,6 +3005,25 @@ export type SessionErrorUnknown = {
message: string
}
export type SessionErrorProvider = {
type: "provider"
category:
| "invalid-request"
| "no-route"
| "authentication"
| "rate-limit"
| "quota-exceeded"
| "content-policy"
| "provider-internal"
| "transport"
| "invalid-provider-output"
| "unknown"
message: string
status?: number
retryable: boolean
retryAfterMs?: number
}
export type ToolTextContent = {
type: "text"
text: string
@@ -3420,7 +3439,7 @@ export type SyncEventSessionNextStepFailed = {
timestamp: number
sessionID: string
assistantMessageID: string
error: SessionErrorUnknown
error: SessionErrorUnknown | SessionErrorProvider
}
}
}
@@ -4076,7 +4095,7 @@ export type SessionMessageAssistant = {
write: number
}
}
error?: SessionErrorUnknown
error?: SessionErrorUnknown | SessionErrorProvider
}
export type SessionMessageCompaction = {
@@ -4780,7 +4799,7 @@ export type V2EventSessionNextStepFailed = {
timestamp: number
sessionID: string
assistantMessageID: string
error: SessionErrorUnknown
error: SessionErrorUnknown | SessionErrorProvider
}
}
@@ -6412,7 +6431,7 @@ export type EventSessionNextStepFailed = {
timestamp: number
sessionID: string
assistantMessageID: string
error: SessionErrorUnknown
error: SessionErrorUnknown | SessionErrorProvider
}
}