mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
A well-formed provider stream that ends with finish_reason stop and zero
content blocks previously became a successful empty assistant message: the
turn completed silently, and drivers like goal-session counted the no-op
round. Both adapters now map that degenerate completion to a finish
{kind:'error'} with the new canonical EMPTY_RESPONSE code from dsh-llm, and
dsh-llm-retry adds the code to its default retryable set, so the existing
closed-step recovery path retries it and fails loud once the budget is
exhausted.
Covered by adapter unit tests, an llm-retry default-policy test, and a new
authored keyless ACP snapshot (empty-response-retry) with a deterministic
1 ms zero-jitter retry overlay.
214 lines
8.0 KiB
TypeScript
214 lines
8.0 KiB
TypeScript
/**
|
|
* Bounded transient 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, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
|
|
import type { LlmFailure } from '@deepseek-ai/dsh-llm'
|
|
import type {} from '@deepseek-ai/dsh-session'
|
|
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
|
|
|
declare module '@deepseek-ai/dsh-session' {
|
|
interface SessionEventMap {
|
|
/** Durable, non-surface record of one transient retry scheduled after a closed failed step. */
|
|
'llm/retry': {
|
|
turn: number
|
|
step: number
|
|
retry: number
|
|
maxRetries: number
|
|
delayMs: number
|
|
failure: LlmFailure
|
|
}
|
|
}
|
|
}
|
|
|
|
export const name = 'llm-retry'
|
|
export const inject = ['agents']
|
|
|
|
const DEFAULT_MAX_TRANSIENT_RETRIES = 2
|
|
const DEFAULT_INITIAL_DELAY_MS = 500
|
|
const DEFAULT_MAX_DELAY_MS = 10_000
|
|
const DEFAULT_JITTER_RATIO = 0.1
|
|
const DEFAULT_RETRYABLE_CODES = Object.freeze(['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT'])
|
|
|
|
/** Deployment-owned limits and classification for transient request recovery. */
|
|
export interface Config {
|
|
/** Maximum transient retries after the first request (default 2). */
|
|
maxTransientRetries?: number
|
|
/** Initial local exponential-backoff delay in milliseconds (default 500). */
|
|
initialDelayMs?: number
|
|
/** Maximum accepted or locally scheduled delay in milliseconds (default 10000). */
|
|
maxDelayMs?: number
|
|
/** Symmetric random multiplier range around one (default 0.1). */
|
|
jitterRatio?: number
|
|
/** Stable failure codes eligible for this policy. */
|
|
retryableCodes?: string[]
|
|
}
|
|
|
|
/** Runtime schema for {@link Config}. */
|
|
export const Config: z<Config> = z.object({
|
|
maxTransientRetries: z.number().step(1).min(0).default(DEFAULT_MAX_TRANSIENT_RETRIES),
|
|
initialDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS),
|
|
maxDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS),
|
|
jitterRatio: z.number().min(0).max(1).default(DEFAULT_JITTER_RATIO),
|
|
retryableCodes: z.array(z.string()).default([...DEFAULT_RETRYABLE_CODES]),
|
|
})
|
|
|
|
interface ResolvedConfig {
|
|
readonly maxTransientRetries: number
|
|
readonly initialDelayMs: number
|
|
readonly maxDelayMs: number
|
|
readonly jitterRatio: number
|
|
readonly retryableCodes: ReadonlySet<string>
|
|
}
|
|
|
|
function resolveConfig(config: Config): ResolvedConfig {
|
|
const maxTransientRetries = config.maxTransientRetries ?? DEFAULT_MAX_TRANSIENT_RETRIES
|
|
const initialDelayMs = config.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS
|
|
const maxDelayMs = config.maxDelayMs ?? DEFAULT_MAX_DELAY_MS
|
|
const jitterRatio = config.jitterRatio ?? DEFAULT_JITTER_RATIO
|
|
const codes = config.retryableCodes ?? [...DEFAULT_RETRYABLE_CODES]
|
|
|
|
if (!Number.isInteger(maxTransientRetries) || maxTransientRetries < 0) {
|
|
throw new Error('llm-retry: maxTransientRetries must be a non-negative integer')
|
|
}
|
|
if (!Number.isFinite(initialDelayMs) || initialDelayMs <= 0 || initialDelayMs > MAX_TIMER_DELAY_MS) {
|
|
throw new Error(`llm-retry: initialDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
|
|
}
|
|
if (!Number.isFinite(maxDelayMs) || maxDelayMs <= 0 || maxDelayMs > MAX_TIMER_DELAY_MS) {
|
|
throw new Error(`llm-retry: maxDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
|
|
}
|
|
if (initialDelayMs > maxDelayMs) {
|
|
throw new Error('llm-retry: initialDelayMs must be less than or equal to maxDelayMs')
|
|
}
|
|
if (!Number.isFinite(jitterRatio) || jitterRatio < 0 || jitterRatio > 1) {
|
|
throw new Error('llm-retry: jitterRatio must be between 0 and 1')
|
|
}
|
|
if (codes.length === 0) {
|
|
throw new Error('llm-retry: retryableCodes must not be empty')
|
|
}
|
|
if (codes.some(code => code.length === 0)) {
|
|
throw new Error('llm-retry: retryableCodes must contain only non-empty strings')
|
|
}
|
|
if (new Set(codes).size !== codes.length) {
|
|
throw new Error('llm-retry: retryableCodes must not contain duplicates')
|
|
}
|
|
|
|
return Object.freeze({
|
|
maxTransientRetries,
|
|
initialDelayMs,
|
|
maxDelayMs,
|
|
jitterRatio,
|
|
retryableCodes: new Set(codes),
|
|
})
|
|
}
|
|
|
|
/** 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
|
|
}
|
|
|
|
function localDelay(config: ResolvedConfig, 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 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 bounded transient request recovery.
|
|
* @param ctx - plugin context that owns the listener and active waits.
|
|
* @param config - retry budget, delay bounds, jitter, and eligible codes.
|
|
* @param internals - non-serializable deterministic seams for tests.
|
|
*/
|
|
export function apply(ctx: Context, config: Config = {}, internals: RetryInternals = {}): void {
|
|
const resolved = resolveConfig(config)
|
|
const random = internals.random ?? Math.random
|
|
const lifetime = new AbortController()
|
|
const active = new Set<Promise<RequestErrorDecision>>()
|
|
|
|
async function backoff(
|
|
agent: Agent,
|
|
turn: number,
|
|
step: number,
|
|
failure: LlmFailure,
|
|
retry: number,
|
|
delayMs: number,
|
|
signal: AbortSignal,
|
|
): Promise<RequestErrorDecision> {
|
|
const fusedSignal = AbortSignal.any([signal, lifetime.signal])
|
|
if (fusedSignal.aborted) return { action: 'fail' }
|
|
agent.session.append('llm/retry', {
|
|
turn,
|
|
step,
|
|
retry,
|
|
maxRetries: resolved.maxTransientRetries,
|
|
delayMs,
|
|
failure,
|
|
})
|
|
if (!await cancellableDelay(delayMs, fusedSignal)) return { action: 'fail' }
|
|
return { action: 'retry' }
|
|
}
|
|
|
|
const disposeListener = ctx.on('agent/request-error', (
|
|
agent: Agent,
|
|
turn: number,
|
|
step: number,
|
|
_error: RequestError,
|
|
failure: LlmFailure,
|
|
priorFailures: readonly LlmFailure[],
|
|
signal: AbortSignal,
|
|
next: () => Promise<RequestErrorDecision>,
|
|
) => {
|
|
// 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<RequestErrorDecision>({ action: 'fail' })
|
|
if (!resolved.retryableCodes.has(failure.code)) return next()
|
|
const priorTransientFailures = priorFailures.filter(item => resolved.retryableCodes.has(item.code)).length
|
|
if (priorTransientFailures >= resolved.maxTransientRetries) return next()
|
|
|
|
const retry = priorTransientFailures + 1
|
|
let delayMs: number
|
|
if (failure.providerRetryAfterMs !== undefined
|
|
&& Number.isFinite(failure.providerRetryAfterMs)
|
|
&& failure.providerRetryAfterMs > 0) {
|
|
if (failure.providerRetryAfterMs > resolved.maxDelayMs) return next()
|
|
delayMs = failure.providerRetryAfterMs
|
|
} else {
|
|
delayMs = localDelay(resolved, retry, random)
|
|
}
|
|
|
|
const tracked = backoff(agent, turn, step, failure, retry, delayMs, signal)
|
|
.finally(() => active.delete(tracked))
|
|
active.add(tracked)
|
|
return tracked
|
|
})
|
|
|
|
ctx.effect(() => async () => {
|
|
disposeListener()
|
|
lifetime.abort(new Error('llm-retry plugin disposed'))
|
|
await Promise.allSettled([...active])
|
|
}, 'llm-retry: abort and drain backoffs')
|
|
}
|