Merge branch 'worktree/ci-native-windows-20260808' into worktree/ci-native-windows-coverage-20260808

This commit is contained in:
Tianyi Cui
2026-08-09 21:02:28 +08:00
267 changed files with 12096 additions and 4837 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm-retry/README.md
README.md: e6e56ec44032d714393c6fcc1c42d7271017a294
README.zh.md: b7ce8bee4acd2c4f7c88870745dff96ec5695435
README.md: 0a907b5505650f7c2cd5e9933750be3701f8e34b
README.zh.md: bb8c5b50efddf3def0047f411285b57c3c544f95

View File

@@ -8,9 +8,9 @@ Each provider adapter owns an optional nested `retryPolicy`, captured when its r
Both modes use bounded exponential backoff with symmetric jitter. A valid `providerRetryAfterMs` at or below `maxDelayMs` replaces local backoff without jitter. An over-cap provider delay makes normal mode delegate, while always mode uses its configured local backoff so it cannot terminate on that instruction.
Before waiting, the plugin appends a non-surface `llm/retry` event with the provider, mode, canonical resolved-policy key, failure, and scheduled delay. Its payload is available from the browser-safe `@deepseek-ai/dsh-llm-retry/types` subpath, so remote renderers can consume the durable status without loading the policy runtime. The key includes every behavior-affecting field and sorts normal-mode codes because eligibility uses set membership. Retry numbers continue only across events with the same provider and complete policy key, so a route replacement with different limits, code membership, or backoff starts its own history. Normal events include the finite maximum; always events omit it, and UIs render `∞`. After the wait, the listener returns `{ kind: 'retry' }`, and the loop closes the failed turn and opens a retry turn over the same durable history. Cancellation and plugin disposal abort active backoff, drain active delegated recovery before applying the abort, and make a callback captured before disposal fail closed.
Before waiting, the plugin appends a non-surface `llm/retry` event with the shared `retryId`, provider, mode, canonical resolved-policy key, failure, and scheduled delay. Its payload is available from the browser-safe `@deepseek-ai/dsh-llm-retry/types` subpath, so remote renderers can consume the durable status without loading the policy runtime. The key includes every behavior-affecting field and sorts normal-mode codes because eligibility uses set membership. Retry numbers continue only across events with the same provider and complete policy key, so a route replacement with different limits, code membership, or backoff starts its own history. Normal events include the finite maximum; always events omit it, and UIs render `∞`. When the wait completes, the plugin appends `llm/retry-started` with the same `retryId`, turn, step, and retry number immediately before returning `{ kind: 'retry' }`; cancellation during backoff writes no started event. The loop then closes the failed turn and opens a retry turn over the same durable history. Cancellation and plugin disposal abort active backoff, drain active delegated recovery before applying the abort, and make a callback captured before disposal fail closed.
The separately published `./invariant` companion checks that every retry record names the current open turn and latest closed step, matches the failed request's durable provider, carries non-empty provider and policy identities, has mode-specific bounds, a unique step record, the correct provider-policy retry number, and a bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary.
The separately published `./invariant` companion checks that every scheduled retry names the current open turn and latest closed step, matches the failed request's durable provider, carries non-empty provider and policy identities, has mode-specific bounds, a unique step record, the correct provider-policy retry number, and a bounded timer delay. It also requires each `llm/retry-started` event to name one prior scheduled attempt with the same `retryId`, turn, step, and retry number, and rejects repeated started events. Full jitter may schedule zero milliseconds at its lower boundary.
```yaml
- name: '@deepseek-ai/dsh-llm-deepseek'

View File

@@ -8,9 +8,9 @@
两种 mode 都使用带对称 jitter 的有界指数退避。有效 `providerRetryAfterMs` 不超过 `maxDelayMs` 时会替换本地退避,并且不加 jitter。超出上限的提供方延迟会使 normal mode 继续委托always mode 则改用已配置的本地退避,避免该指令终止重试。
等待前,插件会追加一条不进入表层的 `llm/retry` 事件其中包含提供方、mode、已解析策略的规范 key、失败和计划延迟。该载荷由可安全用于浏览器的 `@deepseek-ai/dsh-llm-retry/types` 子路径导出,因此远程渲染器无需加载策略运行时即可使用该持久状态。该 key 包含所有影响行为的字段,并对 normal mode 的 code 排序,因为合格性采用集合成员关系判断。只有提供方与完整策略 key 都相同的事件才会延续重试编号因此用限制、code 成员关系或退避不同的路由替换后会开始自己的历史。normal 事件包含有限上限always 事件省略该上限UI 会渲染 `∞`。等待结束后,监听器返回 `{ kind: 'retry' }`循环关闭失败轮次,并在同一持久历史上开启重试轮次。取消与插件 dispose 会中止活跃退避,在应用中止前排空活跃的委托恢复,并使 dispose 前捕获的 callback 只能以失败结束。
等待前,插件会追加一条不进入表层的 `llm/retry` 事件,其中包含共享 `retryId`提供方、mode、已解析策略的规范 key、失败和计划延迟。该载荷由可安全用于浏览器的 `@deepseek-ai/dsh-llm-retry/types` 子路径导出,因此远程渲染器无需加载策略运行时即可使用该持久状态。该 key 包含所有影响行为的字段,并对 normal mode 的 code 排序,因为合格性采用集合成员关系判断。只有提供方与完整策略 key 都相同的事件才会延续重试编号因此用限制、code 成员关系或退避不同的路由替换后会开始自己的历史。normal 事件包含有限上限always 事件省略该上限UI 会渲染 `∞`。等待完成时,插件会在返回 `{ kind: 'retry' }` 前立即追加 `llm/retry-started`,其中带有相同的 `retryId`、轮次、步骤与重试编号;退避期间取消则不会写入 started 事件。随后循环关闭失败轮次,并在同一持久历史上开启重试轮次。取消与插件 dispose 会中止活跃退避,在应用中止前排空活跃的委托恢复,并使 dispose 前捕获的 callback 只能以失败结束。
单独发布的 `./invariant` 配套模块会检查每个重试记录是否指向当前开启轮次及其最新已关闭步骤,是否与失败请求的持久提供方匹配,是否携带非空的提供方与策略标识,是否满足 mode 特定边界,是否拥有唯一步骤记录和正确的提供方策略重试编号,以及是否携带有界定时器延迟。完整 jitter 可以在下界调度为零毫秒。
单独发布的 `./invariant` 配套模块会检查每个已调度重试是否指向当前开启轮次及其最新已关闭步骤,是否与失败请求的持久提供方匹配,是否携带非空的提供方与策略标识,是否满足 mode 特定边界,是否拥有唯一步骤记录和正确的提供方策略重试编号,以及是否携带有界定时器延迟。它还要求每个 `llm/retry-started` 事件通过相同的 `retryId`、轮次、步骤与重试编号指向一个先前调度的尝试,并拒绝重复的 started 事件。完整 jitter 可以在下界调度为零毫秒。
```yaml
- name: '@deepseek-ai/dsh-llm-deepseek'

View File

@@ -29,6 +29,7 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
@@ -40,6 +41,7 @@
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",

View File

@@ -0,0 +1,13 @@
import type { Branded } from '@deepseek-ai/dsh-brand'
/** Stable identity shared by every attempt in one request-step retry chain. */
export type RetryId = Branded<'RetryId'>
/**
* Brand an implementation-minted retry-chain identity.
* @param id - opaque retry identity.
* @returns the same string, branded; no validation is performed.
*/
export function RetryId(id: string): RetryId {
return id as RetryId
}

View File

@@ -5,39 +5,17 @@
* @module @deepseek-ai/dsh-llm-retry
*/
import { randomUUID } from 'node:crypto'
import type { Context, Events } from 'cordis'
import z from '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'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */
'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 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']
@@ -139,13 +117,15 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
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 = policy.mode === 'normal'
const eventData: LlmRetryEventData = policy.mode === 'normal'
? {
retryId,
turn,
step,
provider,
@@ -157,6 +137,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
failure,
}
: {
retryId,
turn,
step,
provider,
@@ -168,6 +149,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
}
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' }
}
@@ -207,6 +189,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
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)
@@ -221,7 +204,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
delayMs = localDelay(policy, retry, random)
}
return backoff(agent, turn, step, failure, provider, policy, policyKey, retry, delayMs, signal)
return backoff(agent, turn, step, failure, provider, policy, policyKey, retry, retryId, delayMs, signal)
}
const disposeListener = ctx.on('agent/request-error', (

View File

@@ -47,7 +47,10 @@ function validateRetry(
event: SessionEvent<'llm/retry'>,
fail: InvariantFailure,
): void {
const { turn, step, provider, mode, policyKey, retry, delayMs } = event.data
const { retryId, turn, step, provider, mode, policyKey, retry, delayMs } = event.data
if (typeof retryId !== 'string' || retryId.length === 0) {
fail('llm/retry retryId must be a non-empty string')
}
const failure: unknown = event.data.failure
validateFailure(failure, fail)
if (!Number.isSafeInteger(retry) || retry < 1) {
@@ -110,12 +113,43 @@ function validateRetry(
if (retry !== expectedRetry) {
fail(`llm/retry retry ${retry} must equal provider policy retry ${expectedRetry}`)
}
if (priorPolicyRetry !== undefined && priorPolicyRetry.data.retryId !== retryId) {
fail('llm/retry must preserve retryId across one provider-policy chain')
}
if (priorPolicyRetry === undefined && history.some(prior =>
(prior.type === 'llm/retry' || prior.type === 'llm/retry-started')
&& prior.data.retryId === retryId)) {
fail(`llm/retry retryId ${JSON.stringify(retryId)} is already owned by another chain`)
}
}
/** Validate one wait-complete transition against its scheduled attempt. */
function validateStarted(
history: readonly SessionEvent[],
event: SessionEvent<'llm/retry-started'>,
fail: InvariantFailure,
): void {
const { retryId, turn, step, retry } = event.data
if (typeof retryId !== 'string' || retryId.length === 0) {
fail('llm/retry-started retryId must be a non-empty string')
}
const scheduled = history.findLast((prior): prior is SessionEvent<'llm/retry'> =>
prior.type === 'llm/retry' && prior.data.retryId === retryId && prior.data.retry === retry)
if (scheduled === undefined) fail('llm/retry-started pairs no prior scheduled attempt')
if (scheduled.data.turn !== turn || scheduled.data.step !== step) {
fail('llm/retry-started turn/step must match its scheduled attempt')
}
if (history.some(prior => prior.type === 'llm/retry-started'
&& prior.data.retryId === retryId && prior.data.retry === retry)) {
fail('llm/retry-started repeats one scheduled attempt')
}
}
/** Validate every retry record already present in one loaded session. */
function validateSession(session: Session, fail: InvariantFailure): void {
for (const [index, event] of session.events.entries()) {
if (event.type === 'llm/retry') validateRetry(session.events.slice(0, index), event, fail)
else if (event.type === 'llm/retry-started') validateStarted(session.events.slice(0, index), event, fail)
}
}
@@ -127,6 +161,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent]
if (event.type === 'llm/retry') validateRetry(session.events, event, fail)
else if (event.type === 'llm/retry-started') validateStarted(session.events, event, fail)
}, { global: true })
}, { inject: ['sessions'] })

View File

@@ -1,8 +1,21 @@
import type { LlmFailure } from '@deepseek-ai/dsh-llm/types'
import type { RetryId } from './brand.ts'
export type { RetryId }
declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
/** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */
'llm/retry': LlmRetryEventData
/** Durable transition written after a retry wait succeeds and before the next request attempt starts. */
'llm/retry-started': LlmRetryStartedEventData
}
}
/** Durable payload recorded before one provider-routed model-request retry wait. */
export type LlmRetryEventData =
| {
retryId: RetryId
turn: number
step: number
provider: string
@@ -13,7 +26,9 @@ export type LlmRetryEventData =
delayMs: number
failure: LlmFailure
}
| {
retryId: RetryId
turn: number
step: number
provider: string
@@ -23,3 +38,11 @@ export type LlmRetryEventData =
delayMs: number
failure: LlmFailure
}
/** Durable transition recorded after one retry delay completes. */
export interface LlmRetryStartedEventData {
retryId: RetryId
turn: number
step: number
retry: number
}

View File

@@ -5,6 +5,7 @@ import { createUserMessage, ProviderRequestId } from '@deepseek-ai/dsh-llm'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as RetryInvariant from '@deepseek-ai/dsh-llm-retry/invariant'
import { RetryId } from '@deepseek-ai/dsh-llm-retry'
import { providerForOpenStep } from '../src/history.ts'
async function setup(): Promise<Context> {
@@ -38,6 +39,7 @@ function appendRetryTurn(session: Session, turn: number) {
const failure = { message: 'provider busy', code: 'RATE_LIMIT', status: 429 }
const normal = {
retryId: RetryId('normal-retry-chain'),
provider: 'mock',
mode: 'normal' as const,
policyKey: 'normal-policy',
@@ -47,6 +49,7 @@ const normal = {
failure,
}
const always = {
retryId: RetryId('always-retry-chain'),
provider: 'mock',
mode: 'always' as const,
policyKey: 'always-policy',
@@ -130,6 +133,7 @@ describe('llm-retry invariants', () => {
})
it.each([
['empty-retry-id', { ...normal, retryId: RetryId('') }, /retryId must be a non-empty string/],
['retry-zero', { ...normal, retry: 0 }, /positive safe integer/],
['retry-fraction', { ...normal, retry: 1.5 }, /positive safe integer/],
['max-zero', { ...normal, maxRetries: 0 }, /positive safe maxRetries/],
@@ -212,10 +216,65 @@ describe('llm-retry invariants', () => {
reset.append('step/end', { turn: 1, step: 1 })
reset.append('step/start', { turn: 1, step: 2 })
expect(() => {
reset.append('llm/retry', { turn: 1, step: 2, ...normal })
reset.append('llm/retry', {
turn: 1,
step: 2,
...normal,
retryId: RetryId('reset-step-2-retry-chain'),
})
}).not.toThrow()
})
it('keeps one retry identity per provider-policy chain', async () => {
const ctx = await setup()
const changed = openStep(ctx, 'retry-invariant-changed-chain-id')
changed.append('llm/retry', { turn: 1, step: 1, ...normal })
expect(() => changed.append('llm/retry', {
turn: 1,
step: 1,
...normal,
retry: 2,
retryId: RetryId('changed-retry-chain'),
})).toThrow(/must preserve retryId/)
const reused = openStep(ctx, 'retry-invariant-reused-chain-id')
reused.append('llm/retry', { turn: 1, step: 1, ...normal })
expect(() => reused.append('llm/retry', {
turn: 1,
step: 1,
...always,
retryId: normal.retryId,
})).toThrow(/already owned by another chain/)
})
it('validates retry-started correlation and uniqueness', async () => {
const ctx = await setup()
const empty = openStep(ctx, 'retry-started-empty-id')
expect(() => empty.append('llm/retry-started', {
retryId: RetryId(''), turn: 1, step: 1, retry: 1,
})).toThrow(/retryId must be a non-empty string/)
const missing = openStep(ctx, 'retry-started-missing-schedule')
expect(() => missing.append('llm/retry-started', {
retryId: RetryId('missing-retry-chain'), turn: 1, step: 1, retry: 1,
})).toThrow(/pairs no prior scheduled attempt/)
const mismatch = openStep(ctx, 'retry-started-location-mismatch')
mismatch.append('llm/retry', { turn: 1, step: 1, ...normal })
expect(() => mismatch.append('llm/retry-started', {
retryId: normal.retryId, turn: 2, step: 1, retry: 1,
})).toThrow(/turn\/step must match/)
const repeated = openStep(ctx, 'retry-started-repeated')
repeated.append('llm/retry', { turn: 1, step: 1, ...normal })
repeated.append('llm/retry-started', {
retryId: normal.retryId, turn: 1, step: 1, retry: 1,
})
expect(() => repeated.append('llm/retry-started', {
retryId: normal.retryId, turn: 1, step: 1, retry: 1,
})).toThrow(/repeats one scheduled attempt/)
})
it('starts a fresh retry chain after incomplete predecessor boundaries', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -261,4 +320,16 @@ describe('llm-retry invariants', () => {
await ctx.plugin(InvariantService)
await expect(ctx.plugin(RetryInvariant)).rejects.toThrow(/inside an open turn/)
})
it('accepts a scheduled and started attempt on late registration', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = openStep(ctx, 'retry-invariant-late-started')
session.append('llm/retry', { turn: 1, step: 1, ...normal })
session.append('llm/retry-started', {
retryId: normal.retryId, turn: 1, step: 1, retry: 1,
})
await ctx.plugin(InvariantService)
await expect(ctx.plugin(RetryInvariant)).resolves.toBeDefined()
})
})

View File

@@ -6,6 +6,7 @@ import { Context } from 'cordis'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite'
import { RetryId } from '@deepseek-ai/dsh-llm-retry'
import type {} from '../src/index.ts'
const dirs: string[] = []
@@ -39,6 +40,7 @@ describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind)
reason: 'initial',
})
const event = session.append('llm/retry', {
retryId: RetryId(`retry-${kind}-chain`),
turn: 1,
step: 1,
provider: 'mock',

View File

@@ -191,7 +191,9 @@ describe('provider-routed retry policy', () => {
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
const event = await scheduled
expect(event.data.retryId).toEqual(expect.any(String))
expect(event.data).toEqual({
retryId: event.data.retryId,
turn: 1,
step: 1,
provider: 'mock',

View File

@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../../util/brand"
},
{
"path": "../../../vendor/cosmokit"
},

View File

@@ -10,6 +10,7 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import type { ContextBreakdownProjection } from '@deepseek-ai/dsh-token-meter/client'
import { CompactionId } from '@deepseek-ai/dsh-compact'
import { contextBreakdownProjectionDefinition } from '../src/breakdown-projection.ts'
import {
estimateContent,
@@ -59,6 +60,7 @@ function appendSummaryMeter(ctx: Context, session: Session, start: number, end:
const endIdx = nodes.findIndex(node => node.seq === end)
const shadowed = nodes.slice(startIdx, endIdx + 1)
session.append('compact/summary', {
compactionId: CompactionId('context-breakdown-summary'),
summary: [{ type: 'text', text: 'summary' }],
shadowedRange: { start, end },
shadowedSeqs: shadowed.map(node => node.seq),

View File

@@ -7,6 +7,7 @@ import type { Session } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
import { CompactionId } from '@deepseek-ai/dsh-compact'
const ZERO: TokenUsageProjection = {
uncachedInputTokens: 0,
@@ -81,6 +82,7 @@ function appendSummaryMeter(ctx: Context, session: Session, start: number, end:
const endIdx = nodes.findIndex(node => node.seq === end)
const shadowed = nodes.slice(startIdx, endIdx + 1)
session.append('compact/summary', {
compactionId: CompactionId('token-usage-summary'),
summary: [{ type: 'text', text: 'summary' }],
shadowedRange: { start, end },
shadowedSeqs: shadowed.map(node => node.seq),