mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
# Conflicts: # .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml # .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md # .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/cordis-catalog/events.md # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/llm-streaming.md # docs/core-data-structures/llm-streaming.zh.md # docs/event-producer-consumer.md # examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl # packages/compact/compact-basic/src/index.ts # packages/compact/compact-basic/tests/compact-basic.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/README.md # packages/core/agent-loop/README.zh.md # packages/core/agent-loop/src/loop.ts # packages/core/agent-loop/tests/request-recovery.spec.ts # packages/core/agent/src/types.ts # packages/core/scope/tests/invariant.spec.ts # packages/llm/llm-retry/README.i18n.yaml # packages/llm/llm-retry/README.md # packages/llm/llm-retry/README.zh.md # packages/llm/llm-retry/src/index.ts # packages/llm/llm-retry/src/invariant.ts # packages/llm/llm-retry/tests/invariant.spec.ts # packages/llm/llm-retry/tests/retry.spec.ts # packages/plan/plan-mode/src/index.ts # packages/plan/plan-mode/tests/integration.spec.ts # packages/plan/plan-mode/tests/plan-mode.spec.ts
266 lines
8.9 KiB
TypeScript
266 lines
8.9 KiB
TypeScript
/**
|
|
* Provider-routed model-request retry policy on the agent loop's closed-step
|
|
* recovery seam. Each scheduled retry is durable before its cancellable wait.
|
|
*
|
|
* @module @deepseek-ai/dsh-llm-retry
|
|
*/
|
|
|
|
import type { Context } from 'cordis'
|
|
import z from 'schemastery'
|
|
import type { Agent, RequestError, RequestErrorAction } from '@deepseek-ai/dsh-agent'
|
|
import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
|
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
import { providerForClosedStep } from './history.ts'
|
|
|
|
declare module '@deepseek-ai/dsh-session' {
|
|
interface SessionEventMap {
|
|
/** Durable, non-surface record of one provider-routed retry scheduled after a closed failed step. */
|
|
'llm/retry': {
|
|
turn: number
|
|
step: number
|
|
provider: string
|
|
mode: 'normal'
|
|
policyKey: string
|
|
retry: number
|
|
maxRetries: number
|
|
delayMs: number
|
|
failure: LlmFailure
|
|
} | {
|
|
turn: number
|
|
step: number
|
|
provider: string
|
|
mode: 'always'
|
|
policyKey: string
|
|
retry: number
|
|
delayMs: number
|
|
failure: LlmFailure
|
|
}
|
|
}
|
|
}
|
|
|
|
export const name = 'llm-retry'
|
|
export const inject = ['agents']
|
|
|
|
/** This policy executor has no config; providers own `retryPolicy`. */
|
|
export type Config = Readonly<Record<string, never>>
|
|
|
|
/** Runtime schema for {@link Config}. */
|
|
export const Config = z.object({}) as unknown as z<Config>
|
|
|
|
function validateConfig(config: Config): void {
|
|
const [key] = Object.keys(config)
|
|
if (key === undefined) return
|
|
if (key === 'retryPolicy') {
|
|
throw new Error('llm-retry: retryPolicy belongs under each provider configuration')
|
|
}
|
|
throw new Error(`llm-retry: unknown key "${key}"`)
|
|
}
|
|
|
|
/** Non-serializable seams used to make timing policy deterministic in tests. */
|
|
export interface RetryInternals {
|
|
/** Random sample in the inclusive zero-to-one range used for jitter. */
|
|
random?: () => number
|
|
}
|
|
|
|
type DownstreamOutcome =
|
|
| { readonly type: 'decision'; readonly decision: RequestErrorAction }
|
|
| { readonly type: 'error'; readonly error: unknown }
|
|
|
|
async function settleDownstream(
|
|
next: () => Promise<RequestErrorAction>,
|
|
): Promise<DownstreamOutcome> {
|
|
try {
|
|
return { type: 'decision', decision: await next() }
|
|
} catch (error: unknown) {
|
|
return { type: 'error', error }
|
|
}
|
|
}
|
|
|
|
function localDelay(config: ResolvedRetryPolicy, retry: number, random: () => number): number {
|
|
const exponent = Math.min(retry - 1, 1024)
|
|
const exponential = Math.min(config.initialDelayMs * 2 ** exponent, config.maxDelayMs)
|
|
const jitter = 1 - config.jitterRatio + 2 * config.jitterRatio * random()
|
|
return Math.min(exponential * jitter, config.maxDelayMs)
|
|
}
|
|
|
|
function retryPolicyKey(policy: ResolvedRetryPolicy): string {
|
|
return policy.mode === 'always'
|
|
? JSON.stringify([policy.mode, policy.initialDelayMs, policy.maxDelayMs, policy.jitterRatio])
|
|
: JSON.stringify([
|
|
policy.mode,
|
|
policy.maxRetries,
|
|
[...policy.retryableCodes].sort(),
|
|
policy.initialDelayMs,
|
|
policy.maxDelayMs,
|
|
policy.jitterRatio,
|
|
])
|
|
}
|
|
|
|
function cancellableDelay(delayMs: number, signal: AbortSignal): Promise<boolean> {
|
|
if (signal.aborted) return Promise.resolve(false)
|
|
return new Promise((resolve) => {
|
|
const timer = setTimeout(() => {
|
|
signal.removeEventListener('abort', onAbort)
|
|
resolve(true)
|
|
}, delayMs)
|
|
function onAbort(): void {
|
|
clearTimeout(timer)
|
|
resolve(false)
|
|
}
|
|
signal.addEventListener('abort', onAbort, { once: true })
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Install provider-routed normal or unbounded request recovery.
|
|
* @param ctx - plugin context that owns the listener and active waits.
|
|
* @param config - empty executor config; provider registrations own policy.
|
|
* @param internals - non-serializable deterministic seams for tests.
|
|
*/
|
|
export function apply(ctx: Context, config: Config = {}, internals: RetryInternals = {}): void {
|
|
validateConfig(config)
|
|
const random = internals.random ?? Math.random
|
|
const lifetime = new AbortController()
|
|
const active = new Set<Promise<RequestErrorAction>>()
|
|
|
|
function track(operation: Promise<RequestErrorAction>): Promise<RequestErrorAction> {
|
|
const tracked = operation.finally(() => active.delete(tracked))
|
|
active.add(tracked)
|
|
return tracked
|
|
}
|
|
|
|
async function backoff(
|
|
agent: Agent,
|
|
turn: number,
|
|
step: number,
|
|
failure: LlmFailure,
|
|
provider: string,
|
|
policy: ResolvedRetryPolicy,
|
|
policyKey: string,
|
|
retry: number,
|
|
delayMs: number,
|
|
signal: AbortSignal,
|
|
): Promise<RequestErrorAction> {
|
|
const fusedSignal = AbortSignal.any([signal, lifetime.signal])
|
|
if (fusedSignal.aborted) return
|
|
const eventData = policy.mode === 'normal'
|
|
? {
|
|
turn,
|
|
step,
|
|
provider,
|
|
mode: policy.mode,
|
|
policyKey,
|
|
retry,
|
|
maxRetries: policy.maxRetries,
|
|
delayMs,
|
|
failure,
|
|
}
|
|
: {
|
|
turn,
|
|
step,
|
|
provider,
|
|
mode: policy.mode,
|
|
policyKey,
|
|
retry,
|
|
delayMs,
|
|
failure,
|
|
}
|
|
agent.session.append('llm/retry', eventData)
|
|
if (!await cancellableDelay(delayMs, fusedSignal)) return
|
|
return { kind: 'retry' }
|
|
}
|
|
|
|
async function recover(
|
|
agent: Agent,
|
|
turn: number,
|
|
step: number,
|
|
_error: RequestError,
|
|
failure: LlmFailure,
|
|
priorFailures: readonly LlmFailure[],
|
|
policy: ResolvedRetryPolicy | undefined,
|
|
signal: AbortSignal,
|
|
next: () => Promise<RequestErrorAction>,
|
|
): Promise<RequestErrorAction> {
|
|
if (policy === undefined) return next()
|
|
// The call-local policy belongs to the registration that served this
|
|
// failure. Recover only the durable provider identity from the header;
|
|
// downstream recovery may append later state before an always fallback.
|
|
const provider = providerForClosedStep(agent.session.events, turn, step)
|
|
/* v8 ignore next 3 -- agent-loop closes only steps whose request header was recorded */
|
|
if (provider === undefined) {
|
|
throw new Error(`llm-retry: no request provider for closed turn ${turn}/step ${step}`)
|
|
}
|
|
if (policy.mode === 'always') {
|
|
if (signal.aborted || lifetime.signal.aborted) return
|
|
const fusedSignal = AbortSignal.any([signal, lifetime.signal])
|
|
// The loop and plugin lifetime stay open until delegated recovery settles.
|
|
// An abort then wins before the decision or fallback can mutate later state.
|
|
const downstream = await settleDownstream(next)
|
|
if (fusedSignal.aborted) return
|
|
if (downstream.type === 'error') {
|
|
ctx.logger.warn(
|
|
`llm-retry: provider "${provider}" always policy ignored a downstream recovery failure: %o`,
|
|
downstream.error,
|
|
)
|
|
}
|
|
if (downstream.type === 'decision' && downstream.decision?.kind === 'retry') {
|
|
return downstream.decision
|
|
}
|
|
} else if (!policy.retryableCodes.includes(failure.code)) {
|
|
return next()
|
|
}
|
|
|
|
const policyKey = retryPolicyKey(policy)
|
|
const firstPriorTurn = turn - priorFailures.length
|
|
const priorPolicyRetry = agent.session.events.findLast((event): event is SessionEvent<'llm/retry'> =>
|
|
event.type === 'llm/retry'
|
|
&& event.data.turn >= firstPriorTurn
|
|
&& event.data.turn < turn
|
|
&& event.data.provider === provider
|
|
&& event.data.policyKey === policyKey,
|
|
)
|
|
const previousRetry = priorPolicyRetry?.data.retry ?? 0
|
|
if (policy.mode === 'normal' && previousRetry >= policy.maxRetries) return next()
|
|
const retry = previousRetry + 1
|
|
let delayMs: number
|
|
if (failure.providerRetryAfterMs !== undefined
|
|
&& Number.isFinite(failure.providerRetryAfterMs)
|
|
&& failure.providerRetryAfterMs > 0) {
|
|
if (failure.providerRetryAfterMs > policy.maxDelayMs) {
|
|
if (policy.mode === 'normal') return next()
|
|
delayMs = localDelay(policy, retry, random)
|
|
} else {
|
|
delayMs = failure.providerRetryAfterMs
|
|
}
|
|
} else {
|
|
delayMs = localDelay(policy, retry, random)
|
|
}
|
|
|
|
return backoff(agent, turn, step, failure, provider, policy, policyKey, retry, delayMs, signal)
|
|
}
|
|
|
|
const disposeListener = ctx.on('agent/request-error', (
|
|
agent: Agent,
|
|
turn: number,
|
|
step: number,
|
|
error: RequestError,
|
|
failure: LlmFailure,
|
|
priorFailures: readonly LlmFailure[],
|
|
policy: ResolvedRetryPolicy | undefined,
|
|
signal: AbortSignal,
|
|
next: () => Promise<RequestErrorAction>,
|
|
) => {
|
|
// A waterfall may have captured this callback before its registration was
|
|
// removed. Lifetime cancellation must prevent that stale callback from
|
|
// entering a downstream policy after disposal.
|
|
if (lifetime.signal.aborted) return Promise.resolve<RequestErrorAction>(undefined)
|
|
return track(recover(agent, turn, step, error, failure, priorFailures, policy, signal, next))
|
|
})
|
|
|
|
ctx.effect(() => async () => {
|
|
disposeListener()
|
|
lifetime.abort(new Error('llm-retry plugin disposed'))
|
|
await Promise.allSettled([...active])
|
|
}, 'llm-retry: abort and drain active recovery')
|
|
}
|