From d837ffe70fe2ba03811ad6167e1f9669549ee512 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:16:19 -0500 Subject: [PATCH] feat(plugin): add session retry hook (#45999) --- packages/core/src/session/model-request.ts | 3 + packages/core/src/session/runner/llm.ts | 34 ++++--- packages/core/src/session/runner/retry.ts | 92 ++++++++++++++----- packages/core/src/session/runner/step.ts | 36 +++++--- packages/core/test/session-runner.test.ts | 83 ++++++++++++++++- packages/core/test/session-step.test.ts | 3 + packages/plugin/src/README.md | 4 + packages/plugin/src/effect/README.md | 7 ++ packages/plugin/src/effect/session.ts | 13 +++ packages/plugin/src/promise/session.ts | 13 +++ .../src/docs/content/build/plugins/effect.mdx | 41 +++++++++ .../src/docs/content/build/plugins/index.mdx | 36 ++++++++ 12 files changed, 318 insertions(+), 47 deletions(-) diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 28b72403561..41793290917 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -48,6 +48,7 @@ const declineDefect = (cause: Cause.Cause) => { export interface Prepared { readonly request: LLMRequest readonly options: StreamOptions + readonly retry: (event: PluginHooks.Domains["session"]["retry"]) => Effect.Effect /** * One request-scoped execution operation. Unknown and hook-removed calls * fail individually through the same seam. @@ -364,9 +365,11 @@ export const layer = Layer.effect( tools .execute({ ...input, definitions: hooked }) .pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline))) + const retry: Prepared["retry"] = (event) => hooks.trigger("session", "retry", event).pipe(Effect.asVoid) return { request, options, + retry, executeTool, } }) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 2d10ab75464..b35308626c9 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -1,7 +1,7 @@ export * as SessionRunnerLLM from "./llm.js" import { Message } from "@opencode-ai/ai" -import { Cause, Effect, Exit, FiberMap, Layer, Pull, Schedule } from "effect" +import { Cause, Effect, Exit, FiberMap, Layer } from "effect" import { Database } from "../../database/database.js" import { Bus } from "../../bus.js" import { InstructionState } from "../instruction-state.js" @@ -171,7 +171,7 @@ const layer = Layer.effect( const runStep = Effect.fn("SessionRunner.runStep")(function* (first: SessionContext.Loaded, step: number) { const sessionID = first.session.id let assistantMessageID = SessionMessage.ID.create() - const retry = yield* Schedule.toStepWithSleep(SessionRunnerRetry.schedule(bus, sessionID)) + const retry = yield* SessionRunnerRetry.make(bus, sessionID) let initial: SessionContext.Loaded | undefined = first let recoverOverflow = true let recoverContinuation = true @@ -217,6 +217,15 @@ const layer = Layer.effect( agent: loaded.agent.id, model: loaded.model, prepared, + retry: (cause, error, proposed) => + retry.decide({ + cause, + error, + agent: loaded.agent.id, + model: loaded.model.ref, + hook: prepared.retry, + retry: proposed, + }), recoverContinuation, recoverOverflow: Effect.suspend(() => recoverOverflow && compaction.enabled() @@ -227,18 +236,17 @@ const layer = Layer.effect( const completed = yield* SessionStep.Outcome.$match(outcome, { Completed: (outcome) => Effect.succeed(outcome.needsContinuation), Retry: (outcome) => - retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe( - Pull.catchDone(() => - bus - .publish(SessionEvent.Step.Failed, { sessionID, assistantMessageID, error: outcome.error }) - .pipe(Effect.andThen(outcome.cause)), - ), - Effect.asVoid, - ), + retry.wait({ + decision: outcome.decision, + error: outcome.error, + assistantMessageID, + }), Continue: Effect.fnUntraced(function* (outcome) { - yield* retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe( - Pull.catchDone(() => outcome.cause), - ) + yield* retry.wait({ + decision: outcome.decision, + error: outcome.error, + assistantMessageID, + }) yield* bus.publish(SessionEvent.Synthetic, { sessionID, text: CONTINUE_AFTER_INCOMPLETE_STREAM }) assistantMessageID = SessionMessage.ID.create() }), diff --git a/packages/core/src/session/runner/retry.ts b/packages/core/src/session/runner/retry.ts index 4a39f5f8905..88002291936 100644 --- a/packages/core/src/session/runner/retry.ts +++ b/packages/core/src/session/runner/retry.ts @@ -1,17 +1,29 @@ export * as SessionRunnerRetry from "./retry.js" import { AIError } from "@opencode-ai/ai" +import { Agent } from "@opencode-ai/schema/agent" +import { Model } from "@opencode-ai/schema/model" import { SessionError } from "@opencode-ai/schema/session-error" -import { Duration, Effect, Schedule } from "effect" +import { Clock, Duration, Effect, Pull, Schedule } from "effect" import { Bus } from "../../bus.js" +import type { PluginHooks } from "../../plugin/hooks.js" import { SessionEvent } from "../event.js" import { SessionMessage } from "../message.js" import { SessionSchema } from "../schema.js" -export interface Input { +interface Input { readonly cause: AIError readonly error: SessionError.Error - readonly assistantMessageID: SessionMessage.ID + readonly agent: Agent.ID + readonly model: Model.Ref + readonly hook: (event: PluginHooks.Domains["session"]["retry"]) => Effect.Effect + readonly retry: boolean +} + +export interface Decision { + readonly retry: true + readonly attempt: number + readonly delay: number } export function isRetryable(error: AIError) { @@ -55,22 +67,58 @@ const retryAfter = (input: Input) => { return undefined } -export const schedule = (bus: Bus.Interface, sessionID: SessionSchema.ID) => - Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe( - Schedule.jittered, - Schedule.setInputType(), - Schedule.modifyDelay(({ input, duration: delay }) => { - const minimum = retryAfter(input) - const duration = minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum)) - return Effect.succeed(Duration.millis(Math.ceil(Duration.toMillis(duration)))) - }), - Schedule.tap((metadata) => - bus.publish(SessionEvent.RetryScheduled, { - sessionID, - assistantMessageID: metadata.input.assistantMessageID, - attempt: metadata.attempt + 1, - at: metadata.now + Duration.toMillis(metadata.duration), - error: metadata.input.error, - }), - ), - ) +const schedule = Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe( + Schedule.jittered, + Schedule.setInputType(), + Schedule.modifyDelay(({ input, duration: delay }) => { + const minimum = retryAfter(input) + const duration = minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum)) + return Effect.succeed(Duration.millis(Math.ceil(Duration.toMillis(duration)))) + }), +) + +export const make = (bus: Bus.Interface, sessionID: SessionSchema.ID) => + Effect.gen(function* () { + const step = yield* Schedule.toStep(schedule) + let attempt = 1 + const decide = (input: Input) => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis + const next = yield* step(now, input).pipe(Pull.catchDone(() => Effect.succeed(undefined))) + if (!next) return { retry: false as const } + const [, duration] = next + attempt++ + const delay = Math.ceil(Duration.toMillis(duration)) + const event: PluginHooks.Domains["session"]["retry"] = { + sessionID, + agent: input.agent, + model: input.model, + error: input.error, + attempt, + decision: input.retry ? { retry: true, delay } : { retry: false }, + } + yield* input.hook(event) + if (!event.decision.retry) return event.decision + const normalized = + Number.isFinite(event.decision.delay) && event.decision.delay >= 0 ? Math.ceil(event.decision.delay) : delay + return { retry: true as const, attempt, delay: normalized } + }) + const wait = (input: { + readonly decision: Decision + readonly assistantMessageID: SessionMessage.ID + readonly error: SessionError.Error + }) => + Effect.gen(function* () { + const scheduled = yield* Clock.currentTimeMillis + yield* bus.publish(SessionEvent.RetryScheduled, { + sessionID, + assistantMessageID: input.assistantMessageID, + attempt: input.decision.attempt, + at: scheduled + input.decision.delay, + error: input.error, + }) + const remaining = Math.max(0, scheduled + input.decision.delay - (yield* Clock.currentTimeMillis)) + yield* Effect.sleep(Duration.millis(remaining)) + }) + return { decide, wait } + }) diff --git a/packages/core/src/session/runner/step.ts b/packages/core/src/session/runner/step.ts index b715cba9d6c..df6a2431cc9 100644 --- a/packages/core/src/session/runner/step.ts +++ b/packages/core/src/session/runner/step.ts @@ -31,8 +31,11 @@ import { SessionRunnerRetry } from "./retry.js" export type Outcome = Data.TaggedEnum<{ Completed: { readonly needsContinuation: boolean } - Retry: { readonly cause: AIError; readonly error: SessionError.Error } - Continue: { readonly cause: AIError; readonly error: SessionError.Error } + Retry: { readonly error: SessionError.Error; readonly decision: SessionRunnerRetry.Decision } + Continue: { + readonly error: SessionError.Error + readonly decision: SessionRunnerRetry.Decision + } RecoverFull: {} Compacted: {} }> @@ -44,6 +47,11 @@ interface Input { readonly agent: Agent.ID readonly model: SessionRunnerModel.Resolved readonly prepared: SessionModelRequest.Prepared + readonly retry: ( + cause: AIError, + error: SessionError.Error, + retry: boolean, + ) => Effect.Effect<{ readonly retry: false } | SessionRunnerRetry.Decision> readonly recoverContinuation: boolean /** The runner owns compaction policy; the attempt invokes it only before durable output. */ readonly recoverOverflow: Effect.Effect @@ -161,10 +169,21 @@ export const make = Effect.gen(function* () { !recorded.outputStarted ) return Outcome.RecoverFull() - if (llmFailure && llmError && SessionRunnerRetry.isRetryable(llmFailure) && !recorded.outputStarted) { + const retry = + llmFailure && llmError && !isContextOverflowFailure(llmFailure) + ? yield* restore( + input.retry( + llmFailure, + llmError, + SessionRunnerRetry.isRetryable(llmFailure) || + (recorded.outputStarted && isInterruptedStream(llmFailure)), + ), + ) + : undefined + if (llmFailure && llmError && retry?.retry && !recorded.outputStarted) { // Retry state projects onto the existing assistant, even before it has produced output. yield* publisher.startAssistant() - return Outcome.Retry({ cause: llmFailure, error: llmError }) + return Outcome.Retry({ error: llmError, decision: retry }) } if (llmError) yield* publisher.failAssistant(llmError) @@ -221,20 +240,15 @@ export const make = Effect.gen(function* () { }) } - // After durable output, recovery continues instead of replaying: the - // partial assistant message is already persisted history. Any failure - // the pre-output gate would retry is continued here, plus interrupted - // streams, whose read failures may carry delivery states the retry - // policy rejects for full resends. if ( llmFailure && llmError && - (isInterruptedStream(llmFailure) || SessionRunnerRetry.isRetryable(llmFailure)) && + retry?.retry && record.outputStarted && tools.declines.length === 0 && !tools.interrupted ) - return Outcome.Continue({ cause: llmFailure, error: llmError }) + return Outcome.Continue({ error: llmError, decision: retry }) if (Exit.isFailure(stream)) return yield* Effect.failCause(stream.cause) if (tools.declines.length > 0) return yield* Effect.interrupt diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index d2ba5fd8e64..359b01ac06a 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -552,6 +552,7 @@ const setup = Effect.gen(function* () { admit, resume, context: session.context(sessionID), + hooks, messages: session.messages({ sessionID }), inbox: session.inbox(sessionID), runPrompt: Effect.fnUntraced(function* (text: string) { @@ -4356,9 +4357,14 @@ describe("SessionRunnerLLM", () => { yield* s.admit("Retry transport") yield* s.llm.push(Stream.fail(providerUnavailable())) yield* s.llm.push(TestLLM.text("Recovered", "retry-success")) + const scheduled = yield* s.bus.subscribe(SessionEvent.RetryScheduled).pipe( + Stream.filter((event) => event.data.sessionID === sessionID), + Stream.runHead, + Effect.forkScoped({ startImmediately: true }), + ) const run = yield* s.resume.pipe(Effect.forkChild) - yield* s.llm.wait(1) + yield* Fiber.join(scheduled) yield* TestClock.adjust("1599 millis") expect(s.requests).toHaveLength(1) yield* TestClock.adjust("801 millis") @@ -4376,6 +4382,81 @@ describe("SessionRunnerLLM", () => { expect((yield* s.context).filter((message) => message.type === "assistant")).toHaveLength(1) }) + scenario("allows session retry hooks to veto a proposed retry", function* (s) { + const failure = providerUnavailable() + let observed: PluginHooks.Domains["session"]["retry"] | undefined + yield* s.hooks.register("session", "retry", (event) => + Effect.sync(() => { + observed = event + event.decision = { retry: false } + }), + ) + yield* s.llm.push(Stream.fail(failure)) + + expect(yield* s.runPrompt("Do not retry transport").pipe(Effect.flip)).toBe(failure) + expect(s.requests).toHaveLength(1) + expect(observed).toMatchObject({ + sessionID, + agent: "build", + model: { providerID: "fake", id: "fake-model" }, + error: { type: "provider.transport", message: "Provider unavailable" }, + attempt: 2, + decision: { retry: false }, + }) + expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1") + }) + + scenario("allows session retry hooks to retry a terminal provider failure", function* (s) { + yield* s.hooks.register("session", "retry", (event) => + Effect.sync(() => { + expect(event.decision).toEqual({ retry: false }) + event.decision = { retry: true, delay: 0 } + }), + ) + yield* s.admit("Retry invalid request") + yield* s.llm.push(Stream.fail(invalidRequest()), TestLLM.text("Recovered", "forced-retry-success")) + + yield* s.resume + + expect(s.requests).toHaveLength(2) + expect(yield* s.context).toMatchObject([ + Expected.user("Retry invalid request"), + Expected.assistant({ finish: "stop" }, [Expected.text("Recovered")]), + ]) + }) + + scenario("uses the final session retry hook delay", function* (s) { + yield* s.hooks.register("session", "retry", (event) => + Effect.sync(() => { + event.decision = { retry: true, delay: 10_000 } + }), + ) + yield* s.hooks.register("session", "retry", (event) => + Effect.sync(() => { + expect(event.decision).toEqual({ retry: true, delay: 10_000 }) + event.decision = { retry: true, delay: 5_000 } + }), + ) + yield* s.admit("Use custom retry delay") + yield* s.llm.push(Stream.fail(providerUnavailable()), TestLLM.text("Recovered", "hook-delay-success")) + const scheduled = yield* s.bus.subscribe(SessionEvent.RetryScheduled).pipe( + Stream.filter((event) => event.data.sessionID === sessionID), + Stream.runHead, + Effect.forkScoped({ startImmediately: true }), + ) + + const run = yield* s.resume.pipe(Effect.forkChild) + yield* Fiber.join(scheduled) + yield* TestClock.adjust("4999 millis") + expect(s.requests).toHaveLength(1) + yield* TestClock.adjust("1 millis") + yield* Fiber.join(run) + + expect(s.requests).toHaveLength(2) + const assistant = requireAssistant(yield* s.context) + expect(assistant.retry).toBeUndefined() + }) + scenario("does not start another physical attempt after interruption during retry backoff", function* (s) { yield* s.admit("Interrupt retry backoff") yield* s.llm.push(Stream.fail(providerUnavailable()), TestLLM.text("Must not run", "unused-retry")) diff --git a/packages/core/test/session-step.test.ts b/packages/core/test/session-step.test.ts index 2873e4b5275..e993f66fb17 100644 --- a/packages/core/test/session-step.test.ts +++ b/packages/core/test/session-step.test.ts @@ -105,6 +105,7 @@ for (const fixture of [ agent: Agent.defaultID, model, prepared: { + retry: () => Effect.void, request: LLM.request({ model: model.model, prompt: "Run one tool", toolChoice: fixture.toolChoice }), options: {}, executeTool: () => @@ -113,6 +114,8 @@ for (const fixture of [ return { content: "Completed tool" } }), }, + retry: (_cause, _error, retry) => + Effect.succeed(retry ? { retry: true, attempt: 2, delay: 0 } : { retry: false }), recoverContinuation: true, recoverOverflow: Effect.succeed(false), }) diff --git a/packages/plugin/src/README.md b/packages/plugin/src/README.md index d3de4797907..c4632788c13 100644 --- a/packages/plugin/src/README.md +++ b/packages/plugin/src/README.md @@ -101,6 +101,10 @@ await ctx.session.hook("context", (event) => { event.tools.read.description = "Read a file using narrow line ranges." delete event.tools.write }) + +await ctx.session.hook("retry", (event) => { + if (event.attempt >= 3) event.decision = { retry: false } +}) ``` Promise tools use complete executable tool values with async executors: diff --git a/packages/plugin/src/effect/README.md b/packages/plugin/src/effect/README.md index d1bd5c5d4c0..134e3ca8878 100644 --- a/packages/plugin/src/effect/README.md +++ b/packages/plugin/src/effect/README.md @@ -98,6 +98,13 @@ yield * delete event.tools.write }), ) + +yield * + ctx.session.hook("retry", (event) => + Effect.sync(() => { + if (event.attempt >= 3) event.decision = { retry: false } + }), + ) ``` ## Reloading A Domain diff --git a/packages/plugin/src/effect/session.ts b/packages/plugin/src/effect/session.ts index 27f1c8de820..a56c890b695 100644 --- a/packages/plugin/src/effect/session.ts +++ b/packages/plugin/src/effect/session.ts @@ -5,6 +5,7 @@ import type { Model } from "@opencode-ai/schema/model" import type { PromptInput } from "@opencode-ai/schema/prompt-input" import type { Session } from "@opencode-ai/schema/session" import type { SessionInbox } from "@opencode-ai/schema/session-inbox" +import type { SessionError } from "@opencode-ai/schema/session-error" import type { SessionMessage } from "@opencode-ai/schema/session-message" import type { JsonSchema, Types } from "effect" import type { ModelHooks } from "./registration.js" @@ -52,12 +53,24 @@ export interface SessionHttpResponse { response: Response } +export type SessionRetryDecision = { retry: false } | { retry: true; delay: number } + +export interface SessionRetry { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly model: Model.Ref + readonly error: SessionError.Error + readonly attempt: number + decision: SessionRetryDecision +} + export interface SessionHooks { readonly prompt: SessionPrompt readonly context: SessionContext readonly "model.request": SessionModelRequest readonly "http.request": SessionHttpRequest readonly "http.response": SessionHttpResponse + readonly retry: SessionRetry } export type SessionDomain = Pick< diff --git a/packages/plugin/src/promise/session.ts b/packages/plugin/src/promise/session.ts index 894cc0daaa0..11b2ee3703d 100644 --- a/packages/plugin/src/promise/session.ts +++ b/packages/plugin/src/promise/session.ts @@ -5,6 +5,7 @@ import type { Model } from "@opencode-ai/schema/model" import type { PromptInput } from "@opencode-ai/schema/prompt-input" import type { Session } from "@opencode-ai/schema/session" import type { SessionInbox } from "@opencode-ai/schema/session-inbox" +import type { SessionError } from "@opencode-ai/schema/session-error" import type { SessionMessage } from "@opencode-ai/schema/session-message" import type { JsonSchema, Types } from "effect" import type { ModelHooks } from "./registration.js" @@ -52,12 +53,24 @@ export interface SessionHttpResponse { response: Response } +export type SessionRetryDecision = { retry: false } | { retry: true; delay: number } + +export interface SessionRetry { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly model: Model.Ref + readonly error: SessionError.Error + readonly attempt: number + decision: SessionRetryDecision +} + export interface SessionHooks { readonly prompt: SessionPrompt readonly context: SessionContext readonly "model.request": SessionModelRequest readonly "http.request": SessionHttpRequest readonly "http.response": SessionHttpResponse + readonly retry: SessionRetry } export type SessionDomain = Pick< diff --git a/packages/www/src/docs/content/build/plugins/effect.mdx b/packages/www/src/docs/content/build/plugins/effect.mdx index 9e3f75771be..17201b444bb 100644 --- a/packages/www/src/docs/content/build/plugins/effect.mdx +++ b/packages/www/src/docs/content/build/plugins/effect.mdx @@ -1128,6 +1128,35 @@ effect: (ctx) => }), ``` +Override the retry decision for a provider failure or replace its delay in milliseconds. The hook runs after OpenCode +classifies the failure and proposes its policy, but before any retry is scheduled. It does not expose how OpenCode +internally performs the next attempt. + +```ts +effect: (ctx) => + Effect.gen(function* () { + yield* ctx.session.hook("retry", (event) => + Effect.sync(() => { + if (event.error.status === 429) { + event.decision = { retry: true, delay: 10_000 } + return + } + if (event.error.type === "provider.invalid-request" && event.attempt === 2) { + event.decision = { retry: true, delay: 0 } + return + } + if (event.attempt >= 3) event.decision = { retry: false } + }), + ) + }), +``` + +The initial `decision` is OpenCode's policy, so hooks may make a normally terminal provider failure retryable or veto a +proposed retry. Multiple hooks run in registration order and later hooks see the current decision. The built-in maximum +attempt count remains a hard limit. `attempt` is the physical attempt being proposed; the initial request is attempt `1`, +so the first retry is attempt `2`. Invalid delays (`NaN`, infinity, or negative values) fall back to the computed delay. +Context-overflow recovery remains separate because it compacts the conversation instead of retrying the same request. + #### Reference ```ts @@ -1136,6 +1165,18 @@ interface SessionHooks { readonly "model.request": SessionModelRequest readonly "http.request": SessionHttpRequest readonly "http.response": SessionHttpResponse + readonly retry: SessionRetry +} + +type RetryDecision = { retry: false } | { retry: true; delay: number } + +interface SessionRetry { + readonly sessionID: string + readonly agent: string + readonly model: { providerID: string; id: string; variant?: string } + readonly error: { type: string; message: string; status?: number } + readonly attempt: number + decision: RetryDecision } interface SessionHookDomain { diff --git a/packages/www/src/docs/content/build/plugins/index.mdx b/packages/www/src/docs/content/build/plugins/index.mdx index 989b2ce55fd..a56394c2f55 100644 --- a/packages/www/src/docs/content/build/plugins/index.mdx +++ b/packages/www/src/docs/content/build/plugins/index.mdx @@ -1104,6 +1104,30 @@ await ctx.session.hook("http.response", (event) => { }) ``` +Override the retry decision for a provider failure or replace its delay in milliseconds. The hook runs after OpenCode +classifies the failure and proposes its policy, but before any retry is scheduled. It does not expose how OpenCode +internally performs the next attempt. + +```ts +await ctx.session.hook("retry", (event) => { + if (event.error.status === 429) { + event.decision = { retry: true, delay: 10_000 } + return + } + if (event.error.type === "provider.invalid-request" && event.attempt === 2) { + event.decision = { retry: true, delay: 0 } + return + } + if (event.attempt >= 3) event.decision = { retry: false } +}) +``` + +The initial `decision` is OpenCode's policy, so hooks may make a normally terminal provider failure retryable or veto a +proposed retry. Multiple hooks run in registration order and later hooks see the current decision. The built-in maximum +attempt count remains a hard limit. `attempt` is the physical attempt being proposed; the initial request is attempt `1`, +so the first retry is attempt `2`. Invalid delays (`NaN`, infinity, or negative values) fall back to the computed delay. +Context-overflow recovery remains separate because it compacts the conversation instead of retrying the same request. + #### Reference ```ts @@ -1115,6 +1139,18 @@ interface SessionHooks { "model.request": SessionModelRequestHook "http.request": SessionHttpRequestHook "http.response": SessionHttpResponseHook + retry: SessionRetryHook +} + +type RetryDecision = { retry: false } | { retry: true; delay: number } + +interface SessionRetryHook { + readonly sessionID: string + readonly agent: string + readonly model: { providerID: string; id: string; variant?: string } + readonly error: { type: string; message: string; status?: number } + readonly attempt: number + decision: RetryDecision } interface SessionContextHook {