Skip to content
Merged
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
41 changes: 41 additions & 0 deletions packages/opencode/src/provider/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,47 @@ export const ProviderFailureKind = z.enum([
])
export type ProviderFailureKind = z.infer<typeof ProviderFailureKind>

// The provider-API-rejection kinds: a provider returned an API-level error (an
// HTTP response was received, or a typed provider error body arrived) rather
// than the connection dropping. transport_disconnect and decompression are
// stream/transport failures and are deliberately left out. run-incident routes
// these to its provider_api_error terminal cause, and the renderer passes their
// real message through instead of a generic connection-lost interruption string.
//
// This tuple is the single source of truth: the type is derived from it and the
// runtime check reads it directly, so adding a ProviderFailureKind forces an
// explicit decision here and the two can never drift. `satisfies` keeps every
// entry a valid ProviderFailureKind.
const PROVIDER_API_ERROR_KINDS = [
"auth",
"rate_limit",
"quota_exhausted",
"server_overload",
"invalid_request",
"unknown",
] as const satisfies readonly ProviderFailureKind[]
export type ProviderApiErrorKind = (typeof PROVIDER_API_ERROR_KINDS)[number]

function isProviderApiErrorKind(kind: ProviderFailureKind | undefined): kind is ProviderApiErrorKind {
return kind !== undefined && (PROVIDER_API_ERROR_KINDS as readonly ProviderFailureKind[]).includes(kind)
}

// Whether a parsed APIError is a provider API rejection (an HTTP response was
// received, or a typed provider error body arrived) rather than a wrapped
// connection failure. The explicit kinds are only ever assigned from a status
// code or a typed error body, so they always qualify. "unknown" is the
// catch-all and must be gated on API evidence (a status code or a response
// body): without it, a wrapped network error — e.g. an APICallError with no
// HTTP response, or a DNS failure the stream classifier did not recognize —
// would be misclassified as a provider API error instead of a transport drop.
export function isProviderApiError<
T extends { kind?: ProviderFailureKind; statusCode?: number; hasResponseBody?: boolean },
>(input: T): input is T & { kind: ProviderApiErrorKind } {
if (!isProviderApiErrorKind(input.kind)) return false
if (input.kind === "unknown") return input.statusCode !== undefined || input.hasResponseBody === true
return true
}

function apiCallErrorKind(statusCode: number | undefined, code: string | undefined): ProviderFailureKind {
if (code === "insufficient_quota" || code === "usage_not_included") return "quota_exhausted"
if (code === "invalid_prompt") return "invalid_request"
Expand Down
92 changes: 85 additions & 7 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { SessionDiagnostics } from "./diagnostics"
import { classifyToolFailure } from "./tool-failure"
import type { Provider } from "@/provider"
import { ProviderTransform } from "@/provider"
import { isProviderApiError, type ProviderFailureKind } from "@/provider/error"
import { ExternalResult } from "@/tool/external-result"
import { errorMessage } from "@/util/error"
import { Log } from "@opencode-ai/core/util/log"
Expand Down Expand Up @@ -212,6 +213,12 @@ function retryTimeoutPolicyFor(
})
}

// Appended whenever a side effect may already have run, so the user verifies
// external state before resending. Kept separate from the "Connection lost."
// framing so a provider rejection (billing / auth) can reuse the bare hint
// without being mislabelled as a dropped connection.
const SIDE_EFFECT_SAFETY_HINT = "Please check whether the last operation completed before resending."

function recoveryInterruptionMessage(recovery: NonNullable<RunObservability.Summary["incident"]>["recovery"] | undefined) {
switch (recovery?.reason) {
case "no_visible_output_or_tool_execution":
Expand All @@ -224,7 +231,7 @@ function recoveryInterruptionMessage(recovery: NonNullable<RunObservability.Summ
case "tool_execution_started":
case "unsafe_side_effect_started":
case "side_effect_facts_incomplete":
return "Connection lost. Please check whether the last operation completed before resending."
return `Connection lost. ${SIDE_EFFECT_SAFETY_HINT}`
case "local_lifecycle_close":
return LOCAL_LIFECYCLE_CLOSE_INTERRUPTION_MESSAGE
case "user_cancel":
Expand All @@ -234,6 +241,47 @@ function recoveryInterruptionMessage(recovery: NonNullable<RunObservability.Summ
}
}

// Chooses the interruption message for a halted attempt.
//
// A provider API rejection (a 402 "Insufficient Balance", auth failure,
// invalid_request, rate_limit, …) carries its own actionable message. How that
// message is treated depends on the recovery reason the policy derived:
//
// - reason "provider_api_error" — a *terminal* rejection with no side-effect
// risk. Return undefined so halt() leaves the provider's own message intact;
// overwriting it with a generic recovery string was the bug where a billing
// failure surfaced as "Connection lost".
// - a side-effect safety reason (tool_execution_started / unsafe_side_effect_started
// / side_effect_facts_incomplete) — a tool already ran or a side effect may
// have started (be it a terminal rejection after a tool ran, or a retryable
// rate_limit / server_overload that exhausted its retries). Keep BOTH: the
// provider's real reason AND the safety hint, so a user who fixes the
// provider problem still checks external state before resending. The bare
// hint is used (not the "Connection lost." string) so the rejection is not
// mislabelled.
//
// Everything else (real transport drops, lifecycle close, user cancel) uses the
// recovery message as-is.
export function haltInterruptionMessage(
providerApiRejection: boolean,
recovery: NonNullable<RunObservability.Summary["incident"]>["recovery"] | undefined,
providerMessage?: string,
): string | undefined {
if (providerApiRejection) {
const reason = recovery?.reason
if (reason === "provider_api_error") return undefined
if (
providerMessage &&
(reason === "tool_execution_started" ||
reason === "unsafe_side_effect_started" ||
reason === "side_effect_facts_incomplete")
) {
return `${providerMessage} ${SIDE_EFFECT_SAFETY_HINT}`
}
}
return recoveryInterruptionMessage(recovery)
}

type PendingLoopAction = {
loopAction: "block" | "stop"
tool: string
Expand Down Expand Up @@ -1728,18 +1776,40 @@ export const layer: Layer.Layer<
if (phase) {
return {
retryable: true,
message: "Connection timed out",
watchdog: { phase },
message: "Connection timed out" as string | undefined,
watchdog: { phase } as { phase: "connect" | "silent_stream" | "unknown" } | undefined,
providerFailure: undefined as
| { kind: ProviderFailureKind; code?: string; statusCode?: number; hasResponseBody?: boolean }
| undefined,
providerMessage: undefined as string | undefined,
}
}
// Parse once for the retry decision; surface providerFailure plus the
// HTTP evidence so the recorder routes a provider API rejection to
// provider_api_error instead of re-parsing or defaulting it to a
// transport disconnect. The provider's own message rides along too so
// the halt path renders it without a second parse that could drift
// from this classification.
const parsed = parse(error)
const apiError = MessageV2.APIError.isInstance(parsed) ? parsed : undefined
const providerFailure = apiError?.data.providerFailure
? {
kind: apiError.data.providerFailure.kind,
code: apiError.data.providerFailure.code,
statusCode: apiError.data.statusCode,
hasResponseBody:
typeof apiError.data.responseBody === "string" && apiError.data.responseBody.length > 0,
}
: undefined
const providerMessage = apiError?.data.message
const classification = SessionRetry.classifyRetry(parsed)
if (!classification) return { retryable: false }
if (!classification)
return { retryable: false, message: undefined, watchdog: undefined, providerFailure, providerMessage }
if (SessionRetry.retryAction(classification) === "stop") {
ctx.terminalClassification = classification
return { retryable: false }
return { retryable: false, message: undefined, watchdog: undefined, providerFailure, providerMessage }
}
return { retryable: true, message: classification.raw }
return { retryable: true, message: classification.raw, watchdog: undefined, providerFailure, providerMessage }
}

const removeReasoningForAttempt = Effect.fn("SessionProcessor.removeReasoningForAttempt")(function* (
Expand Down Expand Up @@ -1883,6 +1953,7 @@ export const layer: Layer.Layer<
evidence: retrySignal.watchdog ? ["watchdog_fired", "iterator_error"] : ["iterator_error"],
watchdog: retrySignal.watchdog,
retryable: retrySignal.retryable,
providerFailure: retrySignal.providerFailure,
})
const retryDecision = buildModelRetryDecision({
technicalRetryability: retrySignal.retryable
Expand Down Expand Up @@ -1997,9 +2068,16 @@ export const layer: Layer.Layer<
break
}

// Pick the halt message (see haltInterruptionMessage): a terminal
// provider API rejection with no side-effect risk keeps its own
// actionable text; a provider rejection after a side effect keeps both
// that text and the safety hint; transport drops keep the recovery
// string. Lifecycle-close and user-cancel halts above keep their own
// authoritative interruption messages.
const providerApiRejection = !!retrySignal.providerFailure && isProviderApiError(retrySignal.providerFailure)
yield* halt(result.error, attemptID, {
recordFailure: false,
interruptionMessage: recoveryInterruptionMessage(decision),
interruptionMessage: haltInterruptionMessage(providerApiRejection, decision, retrySignal.providerMessage),
})
break
}
Expand Down
14 changes: 14 additions & 0 deletions packages/opencode/src/session/run-incident/derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,20 @@ function phaseFor(input: {
}
}

export function providerApiCause(input: {
kind: Extract<TerminalCause, { category: "provider_api_error" }>["subcategory"]
retryable?: boolean
error?: SafeErrorFingerprint
}): Extract<TerminalCause, { category: "provider_api_error" }> {
return {
category: "provider_api_error",
subcategory: input.kind,
retryable: input.retryable,
error: input.error,
confidence: "high",
}
}

export function transportCause(input: {
error?: SafeErrorFingerprint
providerProgressSeen: boolean
Expand Down
7 changes: 6 additions & 1 deletion packages/opencode/src/session/run-incident/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { deriveIncident as deriveRunIncident, transportCause as providerTransportCause } from "./derive"
import {
deriveIncident as deriveRunIncident,
providerApiCause as deriveProviderApiCause,
transportCause as providerTransportCause,
} from "./derive"
import { recoveryFor as deriveRecovery } from "./policy"
import { plainSummary as derivePlainSummary, userSummary as deriveUserSummary } from "./presentation"
import { sanitizeIncident as sanitizeRunIncident, sanitizeLifecycleRequest } from "./sanitize"
Expand Down Expand Up @@ -31,6 +35,7 @@ export namespace RunIncident {
export const SCHEMA_VERSION = VERSION
export const derive = deriveRunIncident
export const transportCause = providerTransportCause
export const providerApiCause = deriveProviderApiCause
export const recoveryFor = deriveRecovery
export const evaluateReplaySafety = deriveReplaySafety
export const userSummary = deriveUserSummary
Expand Down
46 changes: 38 additions & 8 deletions packages/opencode/src/session/run-incident/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,16 @@ export function recoveryFor(input: {
const terminalFacts = input.terminalFacts ?? input.facts
const noToolActivity =
!terminalFacts.tool_input_started && !terminalFacts.tool_call_materialized && !terminalFacts.tool_execution_started
const retryableTransport =
// A failure whose retryability says auto-retry is worth attempting, subject to
// the side-effect-safety gates below. Includes a retryable provider API error
// (rate_limit / server_overload) alongside transport drops and watchdog
// timeouts — they all replay the same way when no visible output or tool side
// effect has happened yet.
const retryableProviderFailure =
input.retryable === true &&
(input.cause.category === "provider_transport_disconnect" || input.cause.category === "watchdog_timeout")
(input.cause.category === "provider_transport_disconnect" ||
input.cause.category === "watchdog_timeout" ||
input.cause.category === "provider_api_error")
if (input.cause.category === "user_cancel") {
return { ...base, recommendation: "do_not_retry", confidence: "high", reason: "user_cancel" }
}
Expand All @@ -31,12 +38,35 @@ export function recoveryFor(input: {
reason: "local_lifecycle_close",
}
}
if (input.cause.category === "provider_api_error" && input.retryable !== true) {
// A terminal provider API rejection (auth / quota_exhausted / invalid_request,
// or unknown retryability) cannot be fixed by retrying and is not a connection
// drop, so always stop (do_not_retry). Retryable provider API errors
// (rate_limit / server_overload) fall through to the auto-retry tree via
// retryableProviderFailure.
//
// With no side-effect risk we keep the reason out of the connection-lost set
// ("provider_api_error"), so the renderer shows the real provider message
// verbatim. But if a tool already ran, an unsafe side effect started, or
// side-effect facts are incomplete, we surface that side-effect reason instead
// (still do_not_retry): the renderer then keeps the "check external state"
// safety hint alongside the provider message, so a user who fixes their
// balance/auth and resends does not silently re-run a side-effecting operation.
const reason = terminalFacts.unsafe_side_effect_started
? "unsafe_side_effect_started"
: terminalFacts.tool_execution_started
? "tool_execution_started"
: !terminalFacts.side_effect_facts_complete
? "side_effect_facts_incomplete"
: "provider_api_error"
return { ...base, recommendation: "do_not_retry", confidence: "high", reason }
}
if (
canAutoRetryBeforeFirstProviderProgress({
cause: input.cause,
facts: input.facts,
terminalFacts,
retryableTransport,
retryableProviderFailure,
})
) {
return {
Expand All @@ -56,7 +86,7 @@ export function recoveryFor(input: {
}
}
if (
retryableTransport &&
retryableProviderFailure &&
noToolActivity &&
!isBeforeFirstProviderProgressCause(input.cause) &&
terminalFacts.reasoning_output_started &&
Expand All @@ -81,7 +111,7 @@ export function recoveryFor(input: {
}
}
if (
retryableTransport &&
retryableProviderFailure &&
noToolActivity &&
terminalFacts.reasoning_output_started &&
!terminalFacts.text_output_started
Expand Down Expand Up @@ -150,7 +180,7 @@ export function recoveryFor(input: {
if (input.facts.lifecycle_close_seen) {
return { ...base, recommendation: "do_not_retry", confidence: "high", reason: "local_lifecycle_close" }
}
if (retryableTransport) {
if (retryableProviderFailure) {
return {
...base,
recommendation: "auto_retry",
Expand All @@ -166,9 +196,9 @@ function canAutoRetryBeforeFirstProviderProgress(input: {
cause: TerminalCause
facts: IncidentFacts
terminalFacts: IncidentFacts
retryableTransport: boolean
retryableProviderFailure: boolean
}) {
if (!input.retryableTransport) return false
if (!input.retryableProviderFailure) return false
if (input.facts.user_cancel_seen || input.facts.lifecycle_close_seen) return false
if (!isBeforeFirstProviderProgressCause(input.cause)) return false
if (input.terminalFacts.provider_progress_seen) return false
Expand Down
8 changes: 7 additions & 1 deletion packages/opencode/src/session/run-incident/presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export function plainSummary(input: { cause: TerminalCause; facts: IncidentFacts
if (input.cause.category === "local_lifecycle_close") {
return "The active run was interrupted by a local lifecycle close."
}
if (input.cause.category === "provider_api_error")
return "The provider rejected the request before the response completed."
if (input.cause.category === "user_cancel") return "The run was cancelled by the user."
if (input.cause.category === "watchdog_timeout")
return "The run stopped after PawWork waited too long for provider progress."
Expand All @@ -39,7 +41,11 @@ function actionKey(recovery: RecoveryDecision) {

function severity(cause: TerminalCause) {
if (cause.category === "user_cancel") return "info" as const
if (cause.category === "unknown_interruption" || cause.category === "crash_or_restart_incomplete")
if (
cause.category === "provider_api_error" ||
cause.category === "unknown_interruption" ||
cause.category === "crash_or_restart_incomplete"
)
return "error" as const
return "warning" as const
}
13 changes: 12 additions & 1 deletion packages/opencode/src/session/run-incident/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ import type {
ToolEffectKind,
} from "../run-observability/types"
import type { LifecycleRequest } from "../lifecycle-provenance"
import type { ProviderApiErrorKind } from "@/provider/error"

export const RUN_INCIDENT_SCHEMA_VERSION = 1
// v2: added the provider_api_error terminal cause (a provider returned an
// API-level rejection rather than the connection dropping).
export const RUN_INCIDENT_SCHEMA_VERSION = 2

export type Confidence = "low" | "medium" | "high"

Expand Down Expand Up @@ -63,6 +66,13 @@ export type TerminalCause =
error?: SafeErrorFingerprint
confidence: Confidence
}
| {
category: "provider_api_error"
subcategory: ProviderApiErrorKind
retryable?: boolean
error?: SafeErrorFingerprint
confidence: Confidence
}
| {
category: "local_lifecycle_close"
subcategory: LifecycleKind | "unknown_lifecycle_close"
Expand Down Expand Up @@ -187,6 +197,7 @@ export type RecoveryDecision = {
| "side_effect_facts_incomplete"
| "local_lifecycle_close"
| "user_cancel"
| "provider_api_error"
| "unknown"
auto_retry?: { max_attempts: number; backoff_ms: number; attempted_at?: number }
user_action?: { kind: "continue" | "resume" | "retry" | "confirm_continue" | "dismiss"; idempotency_key: string }
Expand Down
Loading