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

@@ -63,7 +63,7 @@ async function executeCompact(
return { kind: 'error', text: USAGE } return { kind: 'error', text: USAGE }
} }
try { try {
const result = await ctx.compact.compactNow(invocation.agent, invocation.signal) const result = await ctx.compact.compactNow(invocation.agent, invocation.signal, invocation.commandId)
if (result === null) return { kind: 'success', text: 'No compactable history yet.' } if (result === null) return { kind: 'success', text: 'No compactable history yet.' }
return { return {
kind: 'success', kind: 'success',

View File

@@ -27,6 +27,7 @@
"peerDependencies": { "peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-compact": "^0.0.1", "@deepseek-ai/dsh-compact": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1",
@@ -49,6 +50,7 @@
"@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^",

View File

@@ -13,6 +13,7 @@ import type { Session } from '@deepseek-ai/dsh-session'
import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm' import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
// Type-only: makes the optional sibling service available to `ctx.get()`. // Type-only: makes the optional sibling service available to `ctx.get()`.
import type {} from '@deepseek-ai/dsh-compact-tool-result-prune' import type {} from '@deepseek-ai/dsh-compact-tool-result-prune'
import { import {
@@ -361,9 +362,14 @@ export class BasicCompactService extends CompactService {
* resolve only after its standalone marker pair is durably checkpointed. * resolve only after its standalone marker pair is durably checkpointed.
* @param agent - idle agent whose next-turn admission this call reserves. * @param agent - idle agent whose next-turn admission this call reserves.
* @param signal - cancellation scoped to this compaction request. * @param signal - cancellation scoped to this compaction request.
* @param sourceCommandId - initiating command identity for presentation correlation.
* @returns the committed result, or `null` when no safe useful range exists. * @returns the committed result, or `null` when no safe useful range exists.
*/ */
override compactNow(agent: Agent, signal: AbortSignal): Promise<CompactionResult | null> { override compactNow(
agent: Agent,
signal: AbortSignal,
sourceCommandId?: CommandId,
): Promise<CompactionResult | null> {
signal.throwIfAborted() signal.throwIfAborted()
try { try {
return agent.runMaintenance(async (agentSignal) => { return agent.runMaintenance(async (agentSignal) => {
@@ -385,6 +391,7 @@ export class BasicCompactService extends CompactService {
{ {
owner: null, owner: null,
stability: 'selected-span', stability: 'selected-span',
...sourceCommandId === undefined ? {} : { sourceCommandId },
flush: async () => { flush: async () => {
await this.ctx.sessions.flush(agent.session) await this.ctx.sessions.flush(agent.session)
}, },

View File

@@ -5,14 +5,17 @@
* @module @deepseek-ai/dsh-compact-basic/region * @module @deepseek-ai/dsh-compact-basic/region
*/ */
import { randomUUID } from 'node:crypto'
import { isDeepStrictEqual } from 'node:util' import { isDeepStrictEqual } from 'node:util'
import { import {
COMPACT_CHECKPOINT_SOURCE, CompactionId,
ManualCompactionError, ManualCompactionError,
compactCheckpointSource,
toolPairingBalancedAfter, toolPairingBalancedAfter,
toolPairingBalancedBefore, toolPairingBalancedBefore,
} from '@deepseek-ai/dsh-compact' } from '@deepseek-ai/dsh-compact'
import type { CompactionResult } from '@deepseek-ai/dsh-compact' import type { CompactionResult } from '@deepseek-ai/dsh-compact'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
import type { Message, UserMessage } from '@deepseek-ai/dsh-llm' import type { Message, UserMessage } from '@deepseek-ai/dsh-llm'
import type { TokenMeasurement, TokenMeterService } from '@deepseek-ai/dsh-token-meter' import type { TokenMeasurement, TokenMeterService } from '@deepseek-ai/dsh-token-meter'
@@ -54,6 +57,8 @@ interface CompactionTransactionOptions {
readonly stability: 'whole-surface' | 'selected-span' readonly stability: 'whole-surface' | 'selected-span'
/** Optional durability checkpoint after a successfully closed bracket. */ /** Optional durability checkpoint after a successfully closed bracket. */
readonly flush?: () => Promise<void> readonly flush?: () => Promise<void>
/** Manual command that initiated this transaction, when present. */
readonly sourceCommandId?: CommandId
} }
interface CompactionEntryState { interface CompactionEntryState {
@@ -175,7 +180,13 @@ export async function compactSurfaceRegion(
owner = entryState.openTurn owner = entryState.openTurn
} }
const startEvent = session.append('compact/start', { turn: owner }) const compactionId = CompactionId(randomUUID())
const lifecycle = {
compactionId,
...options.sourceCommandId === undefined ? {} : { sourceCommandId: options.sourceCommandId },
turn: owner,
}
const startEvent = session.append('compact/start', lifecycle)
const assertStable: StabilityCheck = options.stability === 'whole-surface' const assertStable: StabilityCheck = options.stability === 'whole-surface'
? assertWholeSurfaceUnchanged ? assertWholeSurfaceUnchanged
: assertSelectedSpanStable : assertSelectedSpanStable
@@ -188,13 +199,20 @@ export async function compactSurfaceRegion(
try { try {
const prepared = prepareCompaction(dependencies, session, selection) const prepared = prepareCompaction(dependencies, session, selection)
const summarized = await summarizeCompaction(dependencies, prepared, agent, signal) const summarized = await summarizeCompaction(
dependencies,
prepared,
agent,
compactionId,
options.sourceCommandId,
signal,
)
if (options.owner === null) signal?.throwIfAborted() if (options.owner === null) signal?.throwIfAborted()
assertStable(dependencies, session, summarized) assertStable(dependencies, session, summarized)
stage = 'commit' stage = 'commit'
const pending = commitCompactionBody(session, startEvent, summarized) const pending = commitCompactionBody(session, startEvent, summarized)
closing = true closing = true
const endEvent = session.append('compact/end', { turn: owner }) const endEvent = session.append('compact/end', lifecycle)
closed = true closed = true
result = completeCompaction(pending, endEvent) result = completeCompaction(pending, endEvent)
} catch (error: unknown) { } catch (error: unknown) {
@@ -202,7 +220,7 @@ export async function compactSurfaceRegion(
if (!closing) { if (!closing) {
closing = true closing = true
try { try {
session.append('compact/end', { turn: owner, error: errorChain(error) }) session.append('compact/end', { ...lifecycle, error: errorChain(error) })
closed = true closed = true
} catch (closeError: unknown) { } catch (closeError: unknown) {
failure = { error: closeError, stage: 'commit' } failure = { error: closeError, stage: 'commit' }
@@ -343,12 +361,14 @@ async function summarizeCompaction(
dependencies: RegionDependencies, dependencies: RegionDependencies,
prepared: PreparedCompaction, prepared: PreparedCompaction,
agent: Agent, agent: Agent,
compactionId: CompactionResult['compactionId'],
sourceCommandId: CommandId | undefined,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<SummarizedCompaction> { ): Promise<SummarizedCompaction> {
const summaryResult = await dependencies.summarize(prepared.input, agent, signal) const summaryResult = await dependencies.summarize(prepared.input, agent, signal)
const checkpointMessage = createUserMessage({ const checkpointMessage = createUserMessage({
content: frameSummary(summaryResult.summary), content: frameSummary(summaryResult.summary),
source: COMPACT_CHECKPOINT_SOURCE, source: compactCheckpointSource(compactionId, sourceCommandId),
}) })
const framedSummaryTokenCount = dependencies.meter.estimateMessage(checkpointMessage) const framedSummaryTokenCount = dependencies.meter.estimateMessage(checkpointMessage)
if (framedSummaryTokenCount >= prepared.shadowedTokenCount) { if (framedSummaryTokenCount >= prepared.shadowedTokenCount) {
@@ -425,6 +445,10 @@ function commitCompactionBody(
? { rawOutput: summarized.rawOutput, llmStreamCall: true as const } ? { rawOutput: summarized.rawOutput, llmStreamCall: true as const }
: summarized.rawOutput === undefined ? {} : { rawOutput: summarized.rawOutput } : summarized.rawOutput === undefined ? {} : { rawOutput: summarized.rawOutput }
const summaryEvent = session.append('compact/summary', { const summaryEvent = session.append('compact/summary', {
compactionId: startEvent.data.compactionId,
...startEvent.data.sourceCommandId === undefined
? {}
: { sourceCommandId: startEvent.data.sourceCommandId },
summary, summary,
...callProvenance, ...callProvenance,
shadowedRange: { start, end }, shadowedRange: { start, end },
@@ -440,6 +464,10 @@ function commitCompactionBody(
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs], sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
}) })
return { return {
compactionId: startEvent.data.compactionId,
...startEvent.data.sourceCommandId === undefined
? {}
: { sourceCommandId: startEvent.data.sourceCommandId },
startSeq: startEvent.seq, startSeq: startEvent.seq,
summarySeq: summaryEvent.seq, summarySeq: summaryEvent.seq,
summary, summary,

View File

@@ -27,6 +27,9 @@
{ {
"path": "../../core/agent" "path": "../../core/agent"
}, },
{
"path": "../../interaction/commands"
},
{ {
"path": "../compact" "path": "../compact"
}, },

View File

@@ -19,6 +19,10 @@
"types": "./lib/types/checkpoint.d.ts", "types": "./lib/types/checkpoint.d.ts",
"default": "./lib/types/checkpoint.js" "default": "./lib/types/checkpoint.js"
}, },
"./brand": {
"types": "./lib/types/brand.d.ts",
"default": "./lib/types/brand.js"
},
"./src/*": "./src/*", "./src/*": "./src/*",
"./package.json": "./package.json" "./package.json": "./package.json"
}, },
@@ -30,12 +34,16 @@
], ],
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"peerDependencies": { "peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7" "cordis": "^4.0.0-rc.7"
}, },
"devDependencies": { "devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^",

View File

@@ -0,0 +1,13 @@
import type { Branded } from '@deepseek-ai/dsh-brand'
/** Stable identity shared by one compact start/summary/checkpoint/end transaction. */
export type CompactionId = Branded<'CompactionId'>
/**
* Brand an implementation-minted compaction identity.
* @param id - opaque transaction identity.
* @returns the same string, branded; no validation is performed.
*/
export function CompactionId(id: string): CompactionId {
return id as CompactionId
}

View File

@@ -13,10 +13,35 @@
*/ */
import type { MessageSource } from '@deepseek-ai/dsh-llm/message' import type { MessageSource } from '@deepseek-ai/dsh-llm/message'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { CompactionId } from './brand.ts'
/** Canonical source for the replacement user message produced by every compaction backend. */ /** Canonical source for the replacement user message produced by every compaction backend. */
export const COMPACT_CHECKPOINT_SOURCE = Object.freeze({ kind: 'plugin', plugin: 'compact' } as const) export const COMPACT_CHECKPOINT_SOURCE = Object.freeze({ kind: 'plugin', plugin: 'compact' } as const)
/** Message provenance carried by a concrete compaction checkpoint. */
export type CompactCheckpointSource = typeof COMPACT_CHECKPOINT_SOURCE & {
readonly compactionId: CompactionId
readonly sourceCommandId?: CommandId
}
/**
* Create checkpoint provenance correlated with one compaction transaction.
* @param compactionId - owning compaction identity.
* @param sourceCommandId - initiating manual command, when present.
* @returns immutable checkpoint source.
*/
export function compactCheckpointSource(
compactionId: CompactionId,
sourceCommandId?: CommandId,
): CompactCheckpointSource {
return Object.freeze({
...COMPACT_CHECKPOINT_SOURCE,
compactionId,
...sourceCommandId === undefined ? {} : { sourceCommandId },
})
}
/** /**
* Test whether a persisted message source identifies a compaction checkpoint. * Test whether a persisted message source identifies a compaction checkpoint.
* @param source - source restored from a surface user message. * @param source - source restored from a surface user message.

View File

@@ -9,14 +9,17 @@
import { Context, Service } from 'cordis' import { Context, Service } from 'cordis'
import type { Session } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { CompactionResult } from './types.ts' import type { CompactionResult } from './types.ts'
export type { CompactionResult } from './types.ts' export type { CompactionResult } from './types.ts'
export { CompactionId } from './brand.ts'
export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts' export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts'
// The checkpoint source and its predicate are declared on the cordis-free // The checkpoint source and its predicate are declared on the cordis-free
// `./checkpoint` leaf so client and wire programs can name them without this // `./checkpoint` leaf so client and wire programs can name them without this
// root's Context merge; the root stays the host-side entry point for both. // root's Context merge; the root stays the host-side entry point for both.
export { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from './checkpoint.ts' export { COMPACT_CHECKPOINT_SOURCE, compactCheckpointSource, isCompactCheckpointSource } from './checkpoint.ts'
export type { CompactCheckpointSource } from './checkpoint.ts'
/** Why automatic policy is asking a backend to consider compaction. */ /** Why automatic policy is asking a backend to consider compaction. */
export type CompactionTrigger = 'pressure' | 'context-overflow' export type CompactionTrigger = 'pressure' | 'context-overflow'
@@ -126,6 +129,7 @@ export abstract class CompactService extends Service {
* *
* @param agent - idle agent whose durable history should be compacted. * @param agent - idle agent whose durable history should be compacted.
* @param signal - cancellation scoped to this compaction request. * @param signal - cancellation scoped to this compaction request.
* @param sourceCommandId - initiating command identity for a manual compaction.
* @returns the compaction result, or `null` when no safe useful range exists. * @returns the compaction result, or `null` when no safe useful range exists.
* @throws {@link ManualCompactionError} for expected busy, agent-cancellation, * @throws {@link ManualCompactionError} for expected busy, agent-cancellation,
* changed-span, summarization/shrink, commit-stage, or persistence failures; * changed-span, summarization/shrink, commit-stage, or persistence failures;
@@ -135,6 +139,7 @@ export abstract class CompactService extends Service {
abstract compactNow( abstract compactNow(
agent: ManualCompactAgentContext, agent: ManualCompactAgentContext,
signal: AbortSignal, signal: AbortSignal,
sourceCommandId?: CommandId,
): Promise<CompactionResult | null> ): Promise<CompactionResult | null>
/** /**

View File

@@ -1,8 +1,12 @@
/** Package-owned compaction log-stream invariants. @module @deepseek-ai/dsh-compact/invariant */ /** Package-owned compaction log-stream invariants. @module @deepseek-ai/dsh-compact/invariant */
import type { Context } from 'cordis' import type { Context } from 'cordis'
import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { CompactionId } from './brand.ts'
import { isCompactCheckpointSource } from './checkpoint.ts'
import type { CompactCheckpointSource } from './checkpoint.ts'
import type {} from './types.ts' import type {} from './types.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-compact' const PACKAGE_NAME = '@deepseek-ai/dsh-compact'
@@ -13,6 +17,8 @@ export const name = 'compact-invariant'
export const inject = ['invariants'] export const inject = ['invariants']
interface CompactionTrace { interface CompactionTrace {
compactionId: CompactionId
sourceCommandId: string | undefined
startSeq: number startSeq: number
turn: number | null turn: number | null
summarized: boolean summarized: boolean
@@ -24,11 +30,48 @@ interface SessionTrace {
} }
type CompactionTransition = type CompactionTransition =
| { kind: 'start'; startSeq: number; turn: number | null } | { kind: 'start'; compactionId: CompactionId; sourceCommandId: string | undefined; startSeq: number; turn: number | null }
| { kind: 'summary'; startSeq: number; turn: number | null } | { kind: 'summary'; compactionId: CompactionId; sourceCommandId: string | undefined; startSeq: number; turn: number | null }
| { kind: 'end' } | { kind: 'end' }
| { kind: 'end-seed' } | { kind: 'end-seed' }
/** Require a durable opaque identity to be a non-empty string. */
function validateId(value: unknown, label: string, fail: InvariantFailure): asserts value is string {
if (typeof value !== 'string' || value.length === 0) fail(`${label} must be a non-empty string`)
}
/** Keep the optional initiating command identity stable across one transaction. */
function validateSourceCommandId(
eventType: string,
value: unknown,
expected: string | undefined,
fail: InvariantFailure,
): void {
if (value !== undefined) validateId(value, `${eventType} sourceCommandId`, fail)
if (value !== expected) {
fail(`${eventType} sourceCommandId ${String(value)} does not match compact/start sourceCommandId ${String(expected)}`)
}
}
/** Validate one replacement checkpoint against its open compaction transaction. */
function validateCheckpoint(
trace: SessionTrace,
event: SessionEvent<'user/message'>,
fail: InvariantFailure,
): void {
const source = event.data.source as typeof event.data.source & Partial<CompactCheckpointSource>
validateId(source.compactionId, 'compaction checkpoint compactionId', fail)
if (source.sourceCommandId !== undefined) {
validateId(source.sourceCommandId, 'compaction checkpoint sourceCommandId', fail)
}
const open = trace.compaction
if (open === undefined) fail('compaction checkpoint has no matching compact/start')
if (source.compactionId !== open.compactionId) {
fail(`compaction checkpoint id ${source.compactionId} does not match compact/start id ${open.compactionId}`)
}
validateSourceCommandId('compaction checkpoint', source.sourceCommandId, open.sourceCommandId, fail)
}
/** Compaction starts still unmatched when a later seed boundary made them stale. */ /** Compaction starts still unmatched when a later seed boundary made them stale. */
function inheritedOrphanStartSeqs( function inheritedOrphanStartSeqs(
events: readonly SessionEvent[], events: readonly SessionEvent[],
@@ -99,20 +142,44 @@ function validateCompactionEvent(
fail: InvariantFailure, fail: InvariantFailure,
): CompactionTransition | undefined { ): CompactionTransition | undefined {
if (event.type === 'session/end-seed') return { kind: 'end-seed' } if (event.type === 'session/end-seed') return { kind: 'end-seed' }
if (event.type === 'user/message'
&& isReplacementSurfaceEvent(event)
&& isCompactCheckpointSource(event.data.source)) {
validateCheckpoint(trace, event, fail)
return undefined
}
if (event.type !== 'compact/start' && event.type !== 'compact/summary' && event.type !== 'compact/end') { if (event.type !== 'compact/start' && event.type !== 'compact/summary' && event.type !== 'compact/end') {
return undefined return undefined
} }
const open = trace.compaction const open = trace.compaction
if (event.type === 'compact/start') { if (event.type === 'compact/start') {
validateId(event.data.compactionId, 'compact/start compactionId', fail)
if (event.data.sourceCommandId !== undefined) {
validateId(event.data.sourceCommandId, 'compact/start sourceCommandId', fail)
}
if (open !== undefined) { if (open !== undefined) {
const owner = open.turn === null ? 'standalone compaction' : `turn ${open.turn}` const owner = open.turn === null ? 'standalone compaction' : `turn ${open.turn}`
fail(`compact/start while ${owner} is still compacting`) fail(`compact/start while ${owner} is still compacting`)
} }
validateOwner(event.data.turn, trace.openTurn, event.type, fail) validateOwner(event.data.turn, trace.openTurn, event.type, fail)
return { kind: 'start', startSeq: event.seq, turn: event.data.turn } return {
kind: 'start',
compactionId: event.data.compactionId,
sourceCommandId: event.data.sourceCommandId,
startSeq: event.seq,
turn: event.data.turn,
}
} }
if (event.type === 'compact/summary') { if (event.type === 'compact/summary') {
validateId(event.data.compactionId, 'compact/summary compactionId', fail)
if (event.data.sourceCommandId !== undefined) {
validateId(event.data.sourceCommandId, 'compact/summary sourceCommandId', fail)
}
if (open === undefined) fail('compact/summary has no matching compact/start') if (open === undefined) fail('compact/summary has no matching compact/start')
if (event.data.compactionId !== open.compactionId) {
fail(`compact/summary id ${event.data.compactionId} does not match compact/start id ${open.compactionId}`)
}
validateSourceCommandId('compact/summary', event.data.sourceCommandId, open.sourceCommandId, fail)
validateOwner(open.turn, trace.openTurn, event.type, fail) validateOwner(open.turn, trace.openTurn, event.type, fail)
if (open.summarized) fail('compact/summary repeated within one compaction') if (open.summarized) fail('compact/summary repeated within one compaction')
const seqs = event.data.shadowedSeqs const seqs = event.data.shadowedSeqs
@@ -123,9 +190,23 @@ function validateCompactionEvent(
if (!Number.isSafeInteger(event.data.shadowedTokenCount) || event.data.shadowedTokenCount < 0) { if (!Number.isSafeInteger(event.data.shadowedTokenCount) || event.data.shadowedTokenCount < 0) {
fail('compact/summary shadowedTokenCount must be a non-negative safe integer') fail('compact/summary shadowedTokenCount must be a non-negative safe integer')
} }
return { kind: 'summary', startSeq: open.startSeq, turn: open.turn } return {
kind: 'summary',
compactionId: open.compactionId,
sourceCommandId: open.sourceCommandId,
startSeq: open.startSeq,
turn: open.turn,
}
}
validateId(event.data.compactionId, 'compact/end compactionId', fail)
if (event.data.sourceCommandId !== undefined) {
validateId(event.data.sourceCommandId, 'compact/end sourceCommandId', fail)
} }
if (open === undefined) fail('compact/end has no matching compact/start') if (open === undefined) fail('compact/end has no matching compact/start')
if (event.data.compactionId !== open.compactionId) {
fail(`compact/end id ${event.data.compactionId} does not match compact/start id ${open.compactionId}`)
}
validateSourceCommandId('compact/end', event.data.sourceCommandId, open.sourceCommandId, fail)
if (event.data.turn !== open.turn) { if (event.data.turn !== open.turn) {
fail(`compact/end owner ${String(event.data.turn)} does not match compact/start owner ${String(open.turn)}`) fail(`compact/end owner ${String(event.data.turn)} does not match compact/start owner ${String(open.turn)}`)
} }
@@ -142,6 +223,8 @@ function applyCompactionTransition(
): CompactionTrace | undefined { ): CompactionTrace | undefined {
if (transition.kind === 'start') { if (transition.kind === 'start') {
return { return {
compactionId: transition.compactionId,
sourceCommandId: transition.sourceCommandId,
startSeq: transition.startSeq, startSeq: transition.startSeq,
turn: transition.turn, turn: transition.turn,
summarized: false, summarized: false,
@@ -149,6 +232,8 @@ function applyCompactionTransition(
} }
if (transition.kind === 'summary') { if (transition.kind === 'summary') {
return { return {
compactionId: transition.compactionId,
sourceCommandId: transition.sourceCommandId,
startSeq: transition.startSeq, startSeq: transition.startSeq,
turn: transition.turn, turn: transition.turn,
summarized: true, summarized: true,

View File

@@ -8,6 +8,8 @@
*/ */
import type { ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { CompactionId } from './brand.ts'
declare module '@deepseek-ai/dsh-session' { declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap { interface SessionEventMap {
@@ -16,7 +18,7 @@ declare module '@deepseek-ai/dsh-session' {
* `compact/end`. A numbered owner is strictly enclosed by that open turn; * `compact/end`. A numbered owner is strictly enclosed by that open turn;
* `null` identifies a standalone manual transaction between turns. * `null` identifies a standalone manual transaction between turns.
*/ */
'compact/start': { turn: number | null } 'compact/start': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null }
/** /**
* Completed summary, its inputs, and its model call facts — log-only, no surfaceOp. * Completed summary, its inputs, and its model call facts — log-only, no surfaceOp.
* The summary content is in `data.summary`; the actual surface replacement * The summary content is in `data.summary`; the actual surface replacement
@@ -27,6 +29,8 @@ declare module '@deepseek-ai/dsh-session' {
* before it (`compact/prune` documents the shared protocol). * before it (`compact/prune` documents the shared protocol).
*/ */
'compact/summary': { 'compact/summary': {
compactionId: CompactionId
sourceCommandId?: CommandId
summary: ContentBlock[] summary: ContentBlock[]
shadowedRange: { start: number; end: number } shadowedRange: { start: number; end: number }
shadowedSeqs: number[] shadowedSeqs: number[]
@@ -62,7 +66,7 @@ declare module '@deepseek-ai/dsh-session' {
* Marks the end of a compaction — log-only, releases the lock. Its owner * Marks the end of a compaction — log-only, releases the lock. Its owner
* matches `compact/start`; `error` records an unsuccessful attempt. * matches `compact/start`; `error` records an unsuccessful attempt.
*/ */
'compact/end': { turn: number | null; error?: string } 'compact/end': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null; error?: string }
/** /**
* Shadow price of one model-free prune replacement — log-only, no * Shadow price of one model-free prune replacement — log-only, no
* surfaceOp. The shared shadow-price protocol: a surface `replace` event * surfaceOp. The shared shadow-price protocol: a surface `replace` event
@@ -85,6 +89,10 @@ declare module '@deepseek-ai/dsh-session' {
/** Result of a successful compaction operation. */ /** Result of a successful compaction operation. */
export interface CompactionResult { export interface CompactionResult {
/** Stable identity shared by this compaction's complete durable lifecycle. */
compactionId: CompactionId
/** Human command that initiated this compaction, when it was manual. */
sourceCommandId?: CommandId
/** The seq of the appended `compact/start` event. */ /** The seq of the appended `compact/start` event. */
startSeq: number startSeq: number
/** The seq of the appended `compact/summary` event. */ /** The seq of the appended `compact/summary` event. */

View File

@@ -8,6 +8,9 @@
"src" "src"
], ],
"references": [ "references": [
{
"path": "../../util/brand"
},
{ {
"path": "../../../vendor/cosmokit" "path": "../../../vendor/cosmokit"
}, },
@@ -17,6 +20,9 @@
{ {
"path": "../../llm/llm" "path": "../../llm/llm"
}, },
{
"path": "../../interaction/commands"
},
{ {
"path": "../../core/session" "path": "../../core/session"
}, },

View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'tsdown'
/** Builds each published entry as a self-contained file admitted by the package whitelist. */
export default defineConfig([
{
entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
},
{
entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
},
])

View File

@@ -30,7 +30,7 @@ declare module '@deepseek-ai/dsh-session' {
* with `tool/code-dispatch` by `subCallId` (timing = the two events' * with `tool/code-dispatch` by `subCallId` (timing = the two events'
* `time` fields). * `time` fields).
*/ */
'tool/code-dispatch-start': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown } 'tool/code-dispatch-start': { rootCallId: CallId; parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown }
/** /**
* One bridged sub-dispatch SETTLING: the pairing ids (matching the * One bridged sub-dispatch SETTLING: the pairing ids (matching the
* `tool/code-dispatch-start` with the same `subCallId`), the tool `name` * `tool/code-dispatch-start` with the same `subCallId`), the tool `name`
@@ -46,7 +46,7 @@ declare module '@deepseek-ai/dsh-session' {
* before returning), so its execution-enclosure relation holds by * before returning), so its execution-enclosure relation holds by
* construction. * construction.
*/ */
'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] } 'tool/code-dispatch': { rootCallId: CallId; parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] }
} }
} }
@@ -502,6 +502,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge
const subCallId = CallId(`${String(exec.callId)}:code:${n}`) const subCallId = CallId(`${String(exec.callId)}:code:${n}`)
const input = { const input = {
callId: subCallId, callId: subCallId,
rootCallId: exec.rootCallId,
name, name,
arguments: normalized.dispatched, arguments: normalized.dispatched,
...exec.agent ? { agent: exec.agent } : {}, ...exec.agent ? { agent: exec.agent } : {},
@@ -539,6 +540,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge
content: result.content, content: result.content,
}) })
agent.session.append('tool/code-dispatch', { agent.session.append('tool/code-dispatch', {
rootCallId: exec.rootCallId,
parentCallId: exec.callId, parentCallId: exec.callId,
subCallId, subCallId,
name, name,
@@ -563,6 +565,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge
}, },
async start(): Promise<void> { async start(): Promise<void> {
exec.agent?.session.append('tool/code-dispatch-start', { exec.agent?.session.append('tool/code-dispatch-start', {
rootCallId: exec.rootCallId,
parentCallId: exec.callId, parentCallId: exec.callId,
subCallId, subCallId,
name, name,

View File

@@ -297,6 +297,11 @@ export type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]:
*/ */
export interface ToolExecutionInput { export interface ToolExecutionInput {
readonly callId: CallId readonly callId: CallId
/**
* Root model-requested call owning this execution tree. Callers omit it for
* a root execution; nested dispatchers propagate the enclosing value.
*/
readonly rootCallId?: CallId
readonly name: string readonly name: string
/** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */ /** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */
readonly arguments: unknown readonly arguments: unknown
@@ -352,6 +357,8 @@ export interface CodeDispatchLog {
* observers run. * observers run.
*/ */
export interface ToolExecution extends ToolExecutionInput { export interface ToolExecution extends ToolExecutionInput {
/** Root model-requested call, resolved for every root and nested execution. */
readonly rootCallId: CallId
/** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */
readonly token: ToolExecutionToken readonly token: ToolExecutionToken
} }
@@ -1123,6 +1130,7 @@ export class ToolRegistry extends Service {
const deferredContexts: UserMessage[] = [] const deferredContexts: UserMessage[] = []
const token = createExecutionToken() const token = createExecutionToken()
const callId = exec.callId const callId = exec.callId
const rootCallId = exec.rootCallId ?? callId
const name = exec.name const name = exec.name
const agent = exec.agent const agent = exec.agent
const parent = exec.parent const parent = exec.parent
@@ -1133,6 +1141,7 @@ export class ToolRegistry extends Service {
const base = { const base = {
token, token,
callId, callId,
rootCallId,
name, name,
signal, signal,
...agent !== undefined ? { agent } : {}, ...agent !== undefined ? { agent } : {},

View File

@@ -33,9 +33,34 @@ function validateResult(
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
const stages = new WeakMap<object, ToolStage>() const stages = new WeakMap<object, ToolStage>()
const openTurns = new WeakMap<Session, number | null>() const openTurns = new WeakMap<Session, number | null>()
const dispatchRoots = new WeakMap<Session, Map<string, string>>()
const validateDispatch = (session: Session, event: SessionEvent): void => {
if (event.type !== 'tool/code-dispatch-start' && event.type !== 'tool/code-dispatch') return
const root = String(event.data.rootCallId)
const parent = String(event.data.parentCallId)
const child = String(event.data.subCallId)
if (root.length === 0 || parent.length === 0 || child.length === 0) {
fail(`${event.type} must carry non-empty rootCallId, parentCallId, and subCallId`)
return
}
const roots = dispatchRoots.get(session)
const known = roots?.get(child)
if (known !== undefined && known !== root) fail(`${event.type} changed rootCallId for subCallId ${child}`)
if (parent !== root && roots?.get(parent) !== root) {
fail(`${event.type} parentCallId ${parent} does not belong to rootCallId ${root}`)
}
}
const commitDispatch = (session: Session, event: SessionEvent): void => {
if (event.type !== 'tool/code-dispatch-start' && event.type !== 'tool/code-dispatch') return
const roots = dispatchRoots.get(session) as Map<string, string>
roots.set(String(event.data.subCallId), String(event.data.rootCallId))
}
const seed = (session: Session): number | null => { const seed = (session: Session): number | null => {
let openTurn: number | null = null let openTurn: number | null = null
dispatchRoots.set(session, new Map())
for (const event of session.events) { for (const event of session.events) {
validateDispatch(session, event)
commitDispatch(session, event)
if (event.type === 'turn/start') openTurn = event.data.turn if (event.type === 'turn/start') openTurn = event.data.turn
else if (event.type === 'turn/end') openTurn = null else if (event.type === 'turn/end') openTurn = null
else if ((event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') else if ((event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch')
@@ -51,12 +76,15 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
for (const session of ctx.sessions.list()) seed(session) for (const session of ctx.sessions.list()) seed(session)
ctx.on('session/created', (session) => { seed(session) }, { global: true }) ctx.on('session/created', (session) => { seed(session) }, { global: true })
ctx.on('session/event', (session, event) => { ctx.on('session/event', (session, event) => {
validateDispatch(session, event)
commitDispatch(session, event)
if (event.type === 'turn/start') openTurns.set(session, event.data.turn) if (event.type === 'turn/start') openTurns.set(session, event.data.turn)
else if (event.type === 'turn/end') openTurns.set(session, null) else if (event.type === 'turn/end') openTurns.set(session, null)
}, { global: true }) }, { global: true })
ctx.on('internal/dispatch', (_mode, eventName, args) => { ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName === 'session/event') { if (eventName === 'session/event') {
const [session, event] = args as [Session, SessionEvent] const [session, event] = args as [Session, SessionEvent]
validateDispatch(session, event)
if ((event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') if ((event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch')
&& openTurnFor(session) === null) { && openTurnFor(session) === null) {
fail(`${event.type} appended outside any open turn`) fail(`${event.type} appended outside any open turn`)

View File

@@ -37,6 +37,8 @@ export interface CommandInputDescriptor {
/** Invocation passed to one registered command handler. */ /** Invocation passed to one registered command handler. */
export interface CommandInvocation { export interface CommandInvocation {
/** Pairing id already written to this invocation's `command/run` event. */
readonly commandId: CommandId
/** Exact agent whose human-facing surface received the command. */ /** Exact agent whose human-facing surface received the command. */
readonly agent: Agent readonly agent: Agent
/** Exact text following the registered command name, including separator whitespace. */ /** Exact text following the registered command name, including separator whitespace. */
@@ -389,7 +391,7 @@ export class CommandService extends Service {
...command.definition.recordInput === false ? {} : { args: parsed.rawInput }, ...command.definition.recordInput === false ? {} : { args: parsed.rawInput },
source: { kind: 'user' }, source: { kind: 'user' },
}) })
const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal }) const invocation = Object.freeze({ commandId, agent, rawInput: parsed.rawInput, signal })
let result: CommandResult let result: CommandResult
try { try {
const output = command.definition.handler(invocation) const output = command.definition.handler(invocation)

View File

@@ -19,6 +19,10 @@
"types": "./lib/types/types.d.ts", "types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js" "default": "./lib/types/types.js"
}, },
"./brand": {
"types": "./lib/types/brand.d.ts",
"default": "./lib/types/brand.js"
},
"./package.json": "./package.json" "./package.json": "./package.json"
}, },
"files": [ "files": [
@@ -29,6 +33,7 @@
], ],
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"peerDependencies": { "peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1",
@@ -40,6 +45,7 @@
"schemastery": "^3.18.0" "schemastery": "^3.18.0"
}, },
"devDependencies": { "devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^", "@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "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 * @module @deepseek-ai/dsh-llm-retry
*/ */
import { randomUUID } from 'node:crypto'
import type { Context, Events } from 'cordis' import type { Context, Events } from 'cordis'
import z from 'schemastery' import z from 'schemastery'
import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent' import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent'
import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session' 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' { declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap { interface SessionEventMap {
/** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */ /** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */
'llm/retry': { 'llm/retry': LlmRetryEventData
turn: number /** Durable transition written after a retry wait succeeds and before the next request attempt starts. */
step: number 'llm/retry-started': LlmRetryStartedEventData
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 name = 'llm-retry'
export const inject = ['agents'] export const inject = ['agents']
@@ -139,6 +126,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
policy: ResolvedRetryPolicy, policy: ResolvedRetryPolicy,
policyKey: string, policyKey: string,
retry: number, retry: number,
retryId: RetryId,
delayMs: number, delayMs: number,
signal: AbortSignal, signal: AbortSignal,
): Promise<RequestErrorAction> { ): Promise<RequestErrorAction> {
@@ -146,6 +134,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
if (fusedSignal.aborted) return if (fusedSignal.aborted) return
const eventData = policy.mode === 'normal' const eventData = policy.mode === 'normal'
? { ? {
retryId,
turn, turn,
step, step,
provider, provider,
@@ -157,6 +146,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
failure, failure,
} }
: { : {
retryId,
turn, turn,
step, step,
provider, provider,
@@ -168,6 +158,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
} }
agent.session.append('llm/retry', eventData) agent.session.append('llm/retry', eventData)
if (!await cancellableDelay(delayMs, fusedSignal)) return if (!await cancellableDelay(delayMs, fusedSignal)) return
agent.session.append('llm/retry-started', { retryId, turn, step, retry })
return { kind: 'retry' } return { kind: 'retry' }
} }
@@ -207,6 +198,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
const previousRetry = priorPolicyRetry?.data.retry ?? 0 const previousRetry = priorPolicyRetry?.data.retry ?? 0
if (policy.mode === 'normal' && previousRetry >= policy.maxRetries) return next() if (policy.mode === 'normal' && previousRetry >= policy.maxRetries) return next()
const retry = previousRetry + 1 const retry = previousRetry + 1
const retryId = priorPolicyRetry?.data.retryId ?? RetryId(randomUUID())
let delayMs: number let delayMs: number
if (failure.providerRetryAfterMs !== undefined if (failure.providerRetryAfterMs !== undefined
&& Number.isFinite(failure.providerRetryAfterMs) && Number.isFinite(failure.providerRetryAfterMs)
@@ -221,7 +213,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
delayMs = localDelay(policy, retry, random) 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', ( const disposeListener = ctx.on('agent/request-error', (

View File

@@ -47,7 +47,10 @@ function validateRetry(
event: SessionEvent<'llm/retry'>, event: SessionEvent<'llm/retry'>,
fail: InvariantFailure, fail: InvariantFailure,
): void { ): 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 const failure: unknown = event.data.failure
validateFailure(failure, fail) validateFailure(failure, fail)
if (!Number.isSafeInteger(retry) || retry < 1) { if (!Number.isSafeInteger(retry) || retry < 1) {
@@ -110,12 +113,43 @@ function validateRetry(
if (retry !== expectedRetry) { if (retry !== expectedRetry) {
fail(`llm/retry retry ${retry} must equal provider policy 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. */ /** Validate every retry record already present in one loaded session. */
function validateSession(session: Session, fail: InvariantFailure): void { function validateSession(session: Session, fail: InvariantFailure): void {
for (const [index, event] of session.events.entries()) { for (const [index, event] of session.events.entries()) {
if (event.type === 'llm/retry') validateRetry(session.events.slice(0, index), event, fail) 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 if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent] const [session, event] = args as [Session, SessionEvent]
if (event.type === 'llm/retry') validateRetry(session.events, event, fail) if (event.type === 'llm/retry') validateRetry(session.events, event, fail)
else if (event.type === 'llm/retry-started') validateStarted(session.events, event, fail)
}, { global: true }) }, { global: true })
}, { inject: ['sessions'] }) }, { inject: ['sessions'] })

View File

@@ -1,8 +1,10 @@
import type { LlmFailure } from '@deepseek-ai/dsh-llm/types' 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. */ /** Durable payload recorded before one provider-routed model-request retry wait. */
export type LlmRetryEventData = export type LlmRetryEventData =
| { | {
retryId: RetryId
turn: number turn: number
step: number step: number
provider: string provider: string
@@ -13,7 +15,9 @@ export type LlmRetryEventData =
delayMs: number delayMs: number
failure: LlmFailure failure: LlmFailure
} }
| { | {
retryId: RetryId
turn: number turn: number
step: number step: number
provider: string provider: string
@@ -23,3 +27,11 @@ export type LlmRetryEventData =
delayMs: number delayMs: number
failure: LlmFailure 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" "src"
], ],
"references": [ "references": [
{
"path": "../../util/brand"
},
{ {
"path": "../../../vendor/cosmokit" "path": "../../../vendor/cosmokit"
}, },