Machine-produced by `pnpm run rescope-vendor --apply` plus the regeneration it prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`, `verify-translation-pairing --write` for the touched bilingual pairs, `gen-doc-graphs`, and one typert snapshot whose ids embed character offsets. `pnpm run rescope-vendor --check` verifies the result. Renames nine vendored packages (cordis, cosmokit, schemastery and the six @cordisjs plugins) and every reference that resolves them: manifest names and dependency keys, module specifiers including declare-module merges, cordis.yml plugin names, tsconfig paths, every Markdown fence, and `docs/` prose. Directory names, upstream versions, and dependency ranges are unchanged, so vendor/README.md still reads as an upstream snapshot; its manifest table gains an upstream-name column so THIRD_PARTY_NOTICES keeps MIT attribution pointed at each fork's origin. The tutorial tier follows the rename end to end: its yaml fences named plugins the Loader can no longer resolve, its `ts ignore-check` fences disagreed with the compiled fences beside them, and its prose quoted both. The contracts that told readers to keep upstream names — the root convention and the vendoring cookbook's tree comment and manifest invariant — now say to rescope instead. Two rules read `@deepseek-ai/` as "another workspace plugin": the client bundle purity gate now names the vendored libraries a browser bundle inlines, and the files where a bare `cordis` is an agent-preset id keep that product data.
227 lines
7.9 KiB
TypeScript
227 lines
7.9 KiB
TypeScript
/**
|
|
* Provider-routed model-request retry policy on the agent loop's request
|
|
* recovery extension point. Each scheduled retry is durable before its cancellable wait.
|
|
*
|
|
* @module @deepseek-ai/dsh-llm-retry
|
|
*/
|
|
|
|
import { randomUUID } from 'node:crypto'
|
|
import type { Context, Events } from '@deepseek-ai/cordis'
|
|
import z from '@deepseek-ai/schemastery'
|
|
import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent'
|
|
import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
|
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
import { RetryId } from './brand.ts'
|
|
import type { LlmRetryEventData } from './types.ts'
|
|
|
|
export type { LlmRetryEventData, LlmRetryStartedEventData } from './types.ts'
|
|
export { RetryId } from './brand.ts'
|
|
|
|
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 hooks 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 hooks 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,
|
|
retryId: RetryId,
|
|
delayMs: number,
|
|
signal: AbortSignal,
|
|
): Promise<RequestErrorAction> {
|
|
const fusedSignal = AbortSignal.any([signal, lifetime.signal])
|
|
if (fusedSignal.aborted) return
|
|
const eventData: LlmRetryEventData = policy.mode === 'normal'
|
|
? {
|
|
retryId,
|
|
turn,
|
|
step,
|
|
provider,
|
|
mode: policy.mode,
|
|
policyKey,
|
|
retry,
|
|
maxRetries: policy.maxRetries,
|
|
delayMs,
|
|
failure,
|
|
}
|
|
: {
|
|
retryId,
|
|
turn,
|
|
step,
|
|
provider,
|
|
mode: policy.mode,
|
|
policyKey,
|
|
retry,
|
|
delayMs,
|
|
failure,
|
|
}
|
|
agent.session.append('llm/retry', eventData)
|
|
if (!await cancellableDelay(delayMs, fusedSignal)) return
|
|
agent.session.append('llm/retry-started', { retryId, turn, step, retry })
|
|
return { kind: 'retry' }
|
|
}
|
|
|
|
async function recover(
|
|
{ agent, turn, step, provider, failure, retryPolicy: policy, signal }: Parameters<Events['agent/request-error']>[0],
|
|
next: () => Promise<RequestErrorAction>,
|
|
): Promise<RequestErrorAction> {
|
|
if (policy === undefined) return next()
|
|
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 priorPolicyRetry = agent.session.events.findLast((event): event is SessionEvent<'llm/retry'> =>
|
|
event.type === 'llm/retry'
|
|
&& event.data.turn === turn
|
|
&& event.data.step === step
|
|
&& 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
|
|
const retryId = priorPolicyRetry?.data.retryId ?? RetryId(randomUUID())
|
|
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, retryId, delayMs, signal)
|
|
}
|
|
|
|
const disposeListener = ctx.on('agent/request-error', (
|
|
payload,
|
|
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(payload, next))
|
|
})
|
|
|
|
ctx.effect(() => async () => {
|
|
disposeListener()
|
|
lifetime.abort(new Error('llm-retry plugin disposed'))
|
|
await Promise.allSettled([...active])
|
|
}, 'llm-retry: abort and drain active recovery')
|
|
}
|