Files
deepseek-harness/packages/llm/llm-retry/src/index.ts
Tianyi Cui 29619eda77 Merge remote-tracking branch 'origin/master' into worktree/pr628-merge-20260727
# 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
2026-07-27 23:29:26 +08:00

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')
}