refactor(session): centralize surface provenance validation
This commit is contained in:
@@ -31,6 +31,7 @@ await ctx.plugin(Invariants, { freeze: false }) // assert contract, don't freez
|
||||
Session log (per session):
|
||||
|
||||
- **`seq` strictly increases** — the spine of replay equivalence.
|
||||
- **surface provenance is valid** — `sourceEventSeqs` uses the shared `dsh-session` checker for type eligibility, nonempty unique earlier references, and complete replacement coverage.
|
||||
- **turns pair and nest** — `turn/start` opens a turn, `turn/end` closes the matching one; no overlapping turns.
|
||||
- **steps nest in turns** — `step/start` opens a step in the open turn; `step/end` closes the matching step.
|
||||
- **chunks belong to an open step** — `step/start` precedes its `assistant/chunk`s.
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* `session/event`, and `agent/status`. It is **off in production**: enable it
|
||||
* in tests and the demos, where a contract violation should be a loud failure,
|
||||
* not a subtle one. It doubles as executable documentation of the event
|
||||
* taxonomy: the assertions below ARE the contract.
|
||||
* taxonomy: these assertions and the shared session validators they invoke
|
||||
* are the contract.
|
||||
*
|
||||
* Why runtime assertions instead of compile-time deep-readonly types? See
|
||||
* the dev-invariants RFC. Briefly: a `DeepReadonly<SessionEvent>` is high type-noise across
|
||||
@@ -23,7 +24,13 @@ import type { Context } from 'cordis'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { CallId, GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
Session,
|
||||
SessionId,
|
||||
foldRequestHeader,
|
||||
isSurfaceEligibleType,
|
||||
validateSurfaceProvenance,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
|
||||
|
||||
export const name = 'invariants'
|
||||
@@ -121,71 +128,50 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
|
||||
trace.lastSeq = event.seq
|
||||
|
||||
// --- Surface invariants ---
|
||||
// Surface metadata (sourceEventSeqs, surfaceOp) is only valid on
|
||||
// surface-eligible event types. The compiler enforces this at append()
|
||||
// call sites; this runtime check catches casts and persisted data.
|
||||
const SURFACE_TYPES = new Set<string>(['user/message', 'assistant/message', 'tool/result', 'context/message', 'steering/message'])
|
||||
// Cast to surface-eligible event type so we can access surfaceOp and
|
||||
// sourceEventSeqs (optional on SessionEvent, mandatory on SurfaceEvent).
|
||||
// SurfaceEvent's mandatory surfaceOp is too strict here — we need to
|
||||
// CHECK whether surface metadata is present, not assume it.
|
||||
const se = event as SessionEvent<SurfaceEventType>
|
||||
if (!SURFACE_TYPES.has(event.type)) {
|
||||
if (se.sourceEventSeqs !== undefined) {
|
||||
throw new InvariantError(`${event.type} cannot carry sourceEventSeqs (non-surface event)`)
|
||||
}
|
||||
if (se.surfaceOp !== undefined) {
|
||||
throw new InvariantError(`${event.type} cannot carry surfaceOp (non-surface event)`)
|
||||
}
|
||||
}
|
||||
if (se.sourceEventSeqs !== undefined) {
|
||||
if (se.sourceEventSeqs.length === 0) {
|
||||
throw new InvariantError('sourceEventSeqs must not be empty when present')
|
||||
}
|
||||
const unique = new Set(se.sourceEventSeqs)
|
||||
if (unique.size !== se.sourceEventSeqs.length) {
|
||||
throw new InvariantError('sourceEventSeqs must not contain duplicates')
|
||||
}
|
||||
for (const ref of se.sourceEventSeqs) {
|
||||
if (ref >= event.seq) {
|
||||
throw new InvariantError(`sourceEventSeqs must reference earlier events: ${ref} >= current seq ${event.seq}`)
|
||||
}
|
||||
if (!trace.knownSeqs.has(ref)) {
|
||||
throw new InvariantError(`sourceEventSeqs references unknown seq ${ref}`)
|
||||
}
|
||||
}
|
||||
if (!isSurfaceEligibleType(event.type) && se.surfaceOp !== undefined) {
|
||||
throw new InvariantError(`${event.type} cannot carry surfaceOp (non-surface event)`)
|
||||
}
|
||||
|
||||
// Fold this event into the tracked surface linked list, validating the
|
||||
// replace contract as we go. `append` adds a tail node; `replace` shadows a
|
||||
// positional range — every shadowed node must appear in sourceEventSeqs.
|
||||
if (se.surfaceOp !== undefined) {
|
||||
if (se.surfaceOp === 'append') {
|
||||
trace.surface.push(event.seq)
|
||||
} else {
|
||||
const { start, end } = se.surfaceOp
|
||||
const startIdx = trace.surface.indexOf(start)
|
||||
if (startIdx === -1) {
|
||||
throw new InvariantError(`surface replace: start seq ${start} is not on the surface`)
|
||||
}
|
||||
const endIdx = trace.surface.indexOf(end)
|
||||
if (endIdx === -1) {
|
||||
throw new InvariantError(`surface replace: end seq ${end} is not on the surface`)
|
||||
}
|
||||
if (startIdx > endIdx) {
|
||||
throw new InvariantError(`surface replace: start seq ${start} (pos ${startIdx}) is after end seq ${end} (pos ${endIdx}) on the surface`)
|
||||
}
|
||||
// Every node the replace shadows (surface positions [startIdx, endIdx]
|
||||
// inclusive) must appear in sourceEventSeqs — the provenance contract.
|
||||
const shadowed = trace.surface.slice(startIdx, endIdx + 1)
|
||||
const recorded = new Set(se.sourceEventSeqs ?? [])
|
||||
const missing = shadowed.filter(seq => !recorded.has(seq))
|
||||
if (missing.length > 0) {
|
||||
throw new InvariantError(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`)
|
||||
}
|
||||
// Apply the replace to the tracked surface: the new node takes the
|
||||
// range's position so order stays in sync for later replaces.
|
||||
trace.surface.splice(startIdx, shadowed.length, event.seq)
|
||||
let replacement: { startIdx: number; shadowed: number[] } | undefined
|
||||
if (se.surfaceOp !== undefined && se.surfaceOp !== 'append') {
|
||||
const { start, end } = se.surfaceOp
|
||||
const startIdx = trace.surface.indexOf(start)
|
||||
if (startIdx === -1) {
|
||||
throw new InvariantError(`surface replace: start seq ${start} is not on the surface`)
|
||||
}
|
||||
const endIdx = trace.surface.indexOf(end)
|
||||
if (endIdx === -1) {
|
||||
throw new InvariantError(`surface replace: end seq ${end} is not on the surface`)
|
||||
}
|
||||
if (startIdx > endIdx) {
|
||||
throw new InvariantError(`surface replace: start seq ${start} (pos ${startIdx}) is after end seq ${end} (pos ${endIdx}) on the surface`)
|
||||
}
|
||||
replacement = { startIdx, shadowed: trace.surface.slice(startIdx, endIdx + 1) }
|
||||
}
|
||||
|
||||
const provenanceViolation = validateSurfaceProvenance(
|
||||
event,
|
||||
trace.knownSeqs,
|
||||
replacement?.shadowed,
|
||||
)
|
||||
if (provenanceViolation !== undefined) {
|
||||
throw new InvariantError(provenanceViolation)
|
||||
}
|
||||
|
||||
if (se.surfaceOp === 'append') {
|
||||
trace.surface.push(event.seq)
|
||||
} else if (replacement !== undefined) {
|
||||
// The new node takes the replaced range's position so order stays in sync
|
||||
// for later replacements.
|
||||
trace.surface.splice(replacement.startIdx, replacement.shadowed.length, event.seq)
|
||||
}
|
||||
|
||||
// Boundary/step-scoped events have explicit cases; every OTHER event type —
|
||||
|
||||
Reference in New Issue
Block a user