refactor(events): add stable conversation correlation ids

This commit is contained in:
imccyu
2026-08-09 15:48:07 +08:00
parent dc825be8d8
commit aa623b6e7a
23 changed files with 347 additions and 41 deletions

View File

@@ -19,6 +19,10 @@
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./brand": {
"types": "./lib/types/brand.d.ts",
"default": "./lib/types/brand.js"
},
"./package.json": "./package.json"
},
"files": [
@@ -29,6 +33,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 +45,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,26 @@
* @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, LlmRetryStartedEventData } 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
}
'llm/retry': LlmRetryEventData
/** Durable transition written after a retry wait succeeds and before the next request attempt starts. */
'llm/retry-started': LlmRetryStartedEventData
}
}
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,6 +126,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
policy: ResolvedRetryPolicy,
policyKey: string,
retry: number,
retryId: RetryId,
delayMs: number,
signal: AbortSignal,
): Promise<RequestErrorAction> {
@@ -146,6 +134,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
if (fusedSignal.aborted) return
const eventData = policy.mode === 'normal'
? {
retryId,
turn,
step,
provider,
@@ -157,6 +146,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
failure,
}
: {
retryId,
turn,
step,
provider,
@@ -168,6 +158,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 +198,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 +213,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,10 @@
import type { LlmFailure } from '@deepseek-ai/dsh-llm/types'
import type { RetryId } from './brand.ts'
/** Durable payload recorded before one provider-routed model-request retry wait. */
export type LlmRetryEventData =
| {
retryId: RetryId
turn: number
step: number
provider: string
@@ -13,7 +15,9 @@ export type LlmRetryEventData =
delayMs: number
failure: LlmFailure
}
| {
retryId: RetryId
turn: number
step: number
provider: string
@@ -23,3 +27,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

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