1
import { brandNumber, brandString, type Branded, type BrandedNumber } from '@deepseek-ai/dsh-brand'2
import type {3
AssistantMessage,4
DeveloperMessage,5
AssistantStreamRecord,6
ToolCallId,7
LlmCallConfig,8
LlmCallConfigAdapterDefaults,9
LlmFailure,10
SystemMessage,11
SystemPromptUpdate,12
TokenUsage,13
ToolResultMessage,14
ToolSchema,15
UserMessage,16
} from '@deepseek-ai/dsh-llm'17
import type { JsonValue } from '@deepseek-ai/dsh-util-values'19
/** Identifies one session in the store (and its persistence artifacts). */20
export type SessionId = Branded<'SessionId'>22
/**23
* Brand a string as a {@link SessionId}.24
* @param id - the raw session id string.25
* @returns the same string with the session-id brand.26
*/27
export function SessionId(id: string): SessionId {28
return brandString<SessionId>(id)29
}31
/** Sequence number of one existing event in a Session log. */32
export type SessionSeq = BrandedNumber<'SessionSeq'>34
/**35
* Admit a numeric value as an existing Session event position.36
* @param value - non-negative safe integer admitted by the owning log operation.37
* @returns the same number with the Session-sequence brand.38
*/39
export function SessionSeq(value: number): SessionSeq {40
if (!Number.isSafeInteger(value) || value < 0 || Object.is(value, -0)) {41
throw new TypeError(`SessionSeq must be a non-negative safe integer, got ${String(value)}`)42
}43
return brandNumber<SessionSeq>(value)44
}46
/** A Session log gap, prefix length, or read offset, which may equal the event count. */47
export type SessionLogOffset = BrandedNumber<'SessionLogOffset'>49
/**50
* Admit a numeric value as a Session log offset.51
* @param value - non-negative safe integer used as a gap or prefix length.52
* @returns the same number with the Session-log-offset brand.53
*/54
export function SessionLogOffset(value: number): SessionLogOffset {55
if (!Number.isSafeInteger(value) || value < 0 || Object.is(value, -0)) {56
throw new TypeError(`SessionLogOffset must be a non-negative safe integer, got ${String(value)}`)57
}58
return brandNumber<SessionLogOffset>(value)59
}61
/** Inclusive Session event watermark, or `-1` before any event exists. */62
export type SessionSeqCursor = SessionSeq | -164
/** One existing Session event position, or explicit absence. */65
export type OptionalSessionSeq = SessionSeq | null67
/**68
* Current logical Session format version, stamped into every newly written69
* {@link SessionHeader}. Current Session and persistence code accept only this70
* value; header-only readers classify supported historical formats, while an71
* event-body read composes the build-static adjacent chain and publishes only72
* this final generation before constructing a Session.73
*74
* The version is a single monotonic integer with no major/minor split. Whether75
* a bump is needed is decided by what the WRITER emits, never by what a newer76
* reader can accept: bump exactly when an older runtime could no longer handle77
* a new log with full semantic correctness ("parses without error" is not78
* correctness — silently skipping content that shapes reconstruction is a79
* wrong read). Only structural changes reach that bar: the header shape, the80
* {@link SessionEvent} envelope, core event semantics, or the surface81
* mechanism (the {@link SurfaceEventType} set and {@link SurfaceOp} variants).82
* Adding an ordinary event type does not bump — the per-event83
* {@link SessionEvent.ignorable} guard covers vocabulary growth instead. When84
* in doubt, bump: a near-identity upgrade step is almost free, a missed bump85
* makes older runtimes read new logs wrong silently. The released migration,86
* immutable prior-generation, and current fast-path rules are recorded in87
* `.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md`.88
*/89
export const SESSION_FORMAT_VERSION = 491
/**92
* Immutable validated storage metadata, kept outside the conversation event log.93
*/94
export interface SessionHeader {95
/**96
* Current logical format version, stamped from {@link SESSION_FORMAT_VERSION}.97
* Historical physical headers are translated before entering this interface.98
*/99
readonly version: typeof SESSION_FORMAT_VERSION100
/** The session's id (mirrors the {@link Session}'s id). */101
readonly id: SessionId102
/** Non-negative safe-integer Unix epoch milliseconds when the session was created. */103
readonly createdAt: number104
/** Absolute working directory the session was created in (if any). */105
readonly cwd?: string106
/** The session this one was forked from (seed lineage), if any. */107
readonly parentSession?: SessionId108
/**109
* Whether this Session contains a fork-inherited event prefix. The exact prefix110
* length is Session state rather than ordinary header metadata.111
*/112
readonly isSeeded: boolean113
/**114
* Coarse product classification for a session created as a subagent child.115
* This is presentation metadata, not proof that the child is continuable.116
*/117
readonly origin?: 'subagent'118
/**119
* Delegation depth: absent (zero) for a top-level session, parent depth + 1120
* for a subagent child. Persisted so a recursion budget survives restart and121
* resume — a runtime-only depth would reset a resumed child to top-level.122
*/123
readonly delegationDepth?: number124
/**125
* Id of the agent preset this session's agent was composed from, when the126
* deployment composes per session. Durable because the preset decides the127
* session's tools and prompt: a resume that restored a different composition128
* would replay history the model can no longer act on.129
*/130
readonly agentPreset?: string131
}133
/**134
* Options for creating a {@link Session} via the store. `seed` replays/forks135
* an existing event log; `meta` carries the caller-supplied storage fields the136
* store folds into a {@link SessionHeader}.137
*/138
export interface CreateSessionOptions {139
/** Initial replay or fork history supplied at construction. */140
readonly seed?: readonly SessionEvent[]141
/**142
* Exact fork-inherited prefix length when `meta.isSeeded` is true. The143
* constructor appends the child-owned tagged marker at the cut unless144
* the seed already includes it followed by child-owned fork closers.145
*/146
readonly inheritedEventCount?: SessionLogOffset147
/**148
* Storage metadata read once before publication. `isSeeded` marks fork149
* lineage; supplying replay history alone does not make it inherited.150
*/151
readonly meta?: {152
readonly cwd?: string153
readonly parentSession?: SessionId154
readonly createdAt?: number155
readonly isSeeded?: boolean156
readonly origin?: 'subagent'157
readonly delegationDepth?: number158
readonly agentPreset?: string159
}160
}162
/**163
* Aliasing state of an adoptable Session seed. `shared-frozen` permits deeply164
* frozen aliases plus independently owned unfrozen values in the same seed.165
*/166
export type SessionSeedEventState = 'detached' | 'shared-frozen'168
/**169
* Adoptable storage values transferred to {@link SessionStore.prepare}170
* without another copy or freeze pass.171
*/172
export interface RestoredSessionOptions {173
/** Events that are independently owned or already deeply frozen. */174
readonly seed: SessionEvent[]175
/** Independently owned storage metadata to validate and freeze in place. */176
readonly meta: SessionHeader177
/** Exact number of fork-inherited leading events decoded from storage. */178
readonly inheritedEventCount: SessionLogOffset179
/** Aliasing state carried from the operation that produced the seed. */180
readonly eventState: SessionSeedEventState181
}183
/** Inputs accepted while constructing an unpublished Session. */184
export type PrepareSessionOptions =185
| (CreateSessionOptions & { readonly eventState?: undefined })186
| RestoredSessionOptions188
/** Why an active agent driver was cancelled. */189
export type AgentCancelCause =190
| { readonly kind: 'user' }191
| { readonly kind: 'parent' }192
| { readonly kind: 'hook'; readonly reason: string }193
| { readonly kind: 'disposed' }195
/** Durable cancellation cause, including imports whose original coarse record carried no cause. */196
export type TurnEndCancelCause = AgentCancelCause | { readonly kind: 'legacy' }198
/**199
* Why a turn ended. Merge-extensible sum type.200
*/201
export interface TurnEndReasonMap {202
completed: { kind: 'completed' }203
/** A cancellation request interrupted the live turn. */204
aborted: { kind: 'aborted'; reason: TurnEndCancelCause }206
blocked: { kind: 'blocked' }207
/**208
* The turn failed. `error` is always a structured failure: the `LlmError`209
* facts verbatim, or `{ message: errorChain(error), code: 'UNKNOWN' }`210
* flattened from any other error.211
*/212
error: { kind: 'error'; error: LlmFailure }213
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */214
'max-tokens': { kind: 'max-tokens' }215
/**216
* A crash-orphaned turn was closed after the fact: agent-loop resume appends217
* this closer for a stored log whose last turn never ended, and session-query218
* synthesizes it on cold reads. The loop never emits this marker live, and219
* the events recorded before the crash remain intact.220
*/221
interrupted: { kind: 'interrupted' }222
/**223
* Fork-seed construction closed a turn that was still open at the fork224
* boundary in the source session. Only fork seeds carry this marker — the225
* loop never emits it — and the source events before the boundary remain226
* intact in the child.227
*/228
forked: { kind: 'forked' }229
}231
/** The union over {@link TurnEndReasonMap} — why a turn ended; plugins extend it by merging variants into the map. */232
export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap]234
/**235
* Logged request state outside derived history: call config and tools. The236
* system prompt is derived history — surface node 0, a `system/message` event.237
* The latest full `request/header` snapshot reconstructs the header; canonical238
* empty optional fields are absent.239
*/240
export interface EpochHeader {241
/** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */242
config: LlmCallConfig243
/** Effective config fields materialized from the exact adapter rather than proposed by a caller. */244
adapterDefaults?: LlmCallConfigAdapterDefaults245
/** Assembled tool schemas; absent for a tool-less request. */246
tools?: ToolSchema[]247
/** Retired request text; system prompts belong to system/message events.248
* @persistenceReserved249
*/250
system?: never251
}253
/** Registration-bound metadata for one resolved model route. */254
export interface RequestContext {255
/** Registered provider route the metadata belongs to. */256
provider: string257
/** Provider-owned model id the metadata belongs to. */258
model: string259
/** Maximum combined request and response context in tokens, when advertised. */260
contextWindow?: number261
/** `'in-history'` when the route reads the latest `system` message at any position as the effective system prompt. */262
systemPromptUpdate?: SystemPromptUpdate263
}265
/**266
* Why a `request/header` snapshot was appended: `'initial'` — the log's first267
* header (a new conversation); `'resume'` — a loop instance's first request268
* over a log that already has header events (process restart, fork seed);269
* `'change'` — a later request used a different header; `'series'` — an unchanged270
* header began an explicitly distinct message series or followed a surface271
* replacement. Other reasons carry `startsSeries` when a new series coincides.272
*/273
export type RequestHeaderReason = 'initial' | 'resume' | 'change' | 'series'275
/**276
* The merge-extensible, append-only source of truth for an agent interaction.277
* Message history is derived from this log. Every event is lossless JSON and278
* sequence numbers stay contiguous. Assistant attempt events embed their exact279
* compact raw streams so persistence stores one durable settlement per attempt.280
*/281
export interface SessionEventMap {282
/**283
* Opens turn `turn` before the loop claims queued input or runs pre-step.284
* Rejection, empty input, cancellation, or failure may close it with no285
* step; otherwise the following identified `user/message` event or batch286
* records the messages entering the step.287
*/288
'turn/start': { turn: number }289
/**290
* Closes turn `turn` with the {@link TurnEndReason} that ended it. A turn291
* with no entered step has no `step/start` or `step/end`. The loop does not await a292
* flush at turn boundaries: `dsh-session-checkpoint-policy` owns the293
* per-request durability checkpoint, and consumers that read storage after294
* `whenIdle()` flush themselves. Success commits the turn; rejection is295
* reported live and does not prevent later work.296
*/297
'turn/end': { turn: number; reason: TurnEndReason }298
/** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */299
'step/start': { turn: number; step: number }300
/** Closes step `step` of turn `turn`. */301
'step/end': { turn: number; step: number }302
/**303
* A user-role message on the model-visible surface: a direct human prompt304
* (the queued message claimed for this turn), a synthetic `agent.inject()`305
* context (file-change notices, subdir AGENTS.md, skill content, cron306
* notifications, …), or an entered goal continuation round. All three307
* project their `content` verbatim; `source` tells them apart.308
*/309
'user/message': UserMessage310
/** An incremental agent session change admitted at the named turn and step. */311
'developer/message': {312
turn: number313
step: number314
message: DeveloperMessage315
/** Earlier request/header defining every tool addition; required exactly when additions are present. */316
headerSeq?: SessionSeq317
}318
/**319
* The rendered system prompt on the model-visible surface. The loop appends320
* the first one as surface node 0 before the step's first `user/message`.321
* A prepared in-history route can append nonempty changes in a continuing322
* series. An incapable route or new series normalizes text to the first system323
* node. Normalization empties nonempty later nodes, then rewrites the head if324
* needed, through logged per-node replacements. An empty rendering always325
* clears all active system nodes, leaving no older instructions model-visible.326
* Empty later nodes are dormant and project to no message; an empty head with327
* no active later node records "no system prompt". Restored nonempty text follows328
* the same route and series rule; empty nodes never restore older text.329
*/330
'system/message': { turn: number; step: number; message: SystemMessage }331
/**332
* Assembled assistant message for one step (derived history uses this).333
* Carries the step's `usage` when the adapter reported token accounting, so334
* the model output and its accounting travel together (there is no separate335
* usage record). `usage` is absent when the adapter reported none. A turn336
* cancelled mid-stream finalizes its delivered text/reasoning prefix as this337
* event with `interrupted: true`; undispatched tool calls are absent. The338
* marker distinguishes that prefix without re-deriving interruption from turn339
* boundaries. An aborted turn with no such event streamed no visible content.340
*/341
'assistant/message': {342
turn: number343
step: number344
message: AssistantMessage345
/** Exact timed model stream, compacted without joining delta boundaries. */346
stream: AssistantStreamRecord[]347
usage?: TokenUsage348
interrupted?: true349
}350
/**351
* One model attempt that committed no surface message. The embedded stream352
* preserves a failed, retried, cancelled, or stream-error attempt that353
* reached settlement without fabricating model-visible history.354
*/355
'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] }356
/**357
* The model requested one tool invocation: `name` with the raw `arguments`358
* JSON string exactly as the model produced it (unparsed). `callId` pairs the359
* call with its `tool/result`.360
*/361
'tool/call': { turn: number; step: number; callId: ToolCallId; name: string; arguments: string }362
/**363
* A completed tool call's model-facing result, optional internal failure364
* identity and user-facing reason, and optional tool-private `meta`365
* presentation payload. The reason remains outside the model-facing message.366
* `meta` is367
* opaque to the core (the producing tool owns its shape and reads it back in368
* `presentResult`) but MUST be JSON-serializable: `Session.append`369
* runtime-validates all event data with `isJsonValue`, so a non-serializable370
* `meta` is rejected at the source, and the durable log reproduces the371
* identical card on replay. Absent372
* unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time373
* contextual diff here).374
*/375
'tool/result': {376
turn: number377
step: number378
message: ToolResultMessage379
/**380
* Optional failure identity and raw user-facing reason, outside model content;381
* allowed only when the message has `isError: true`.382
*/383
error?: { name: string; code: string; reason?: string }384
meta?: JsonValue385
}386
/**387
* Full header for the next request, appended inside its step before dispatch.388
* It is log-only; the latest snapshot reconstructs the request header.389
*/390
'request/header': {391
header: EpochHeader392
reason: RequestHeaderReason393
/** This request begins a distinct model-message series, independently of the header reason. */394
startsSeries?: true395
}396
/**397
* Route metadata for the next request, logged only when the route, capacity,398
* or system prompt update mode changes. It does not participate in request399
* reconstruction or header equality. Prompt admission uses the bound prepared400
* call's capability, not this snapshot from an earlier request.401
*/402
'request/context': RequestContext403
/**404
* Separates inherited or restored history from later lifecycle-owned work.405
* This log-only marker need not be at {@link Session.firstLiveSeq}: a fork406
* seed can already contain its tagged marker and child-owned synthetic407
* closers before construction.408
*409
* A fresh fork child owns one `{ inherited: true }` marker at its exact410
* inherited-prefix cut, even when that prefix ends in an ancestor marker.411
* `buildForkSeed` appends that marker before any synthetic closers; the412
* `Session` constructor supplies it when given only the inherited prefix.413
* The last tagged marker is the current Session's cut; untagged markers414
* keep ordinary restore and replay lifecycle boundaries.415
*416
* Only the `Session` constructor and `buildForkSeed` may create this marker.417
* Session append does not reject other writers, so a plugin appending one418
* would silently classify every live bracket before it as seed history.419
*420
* An owner of a standalone open/close bracket (`compaction/start` …421
* `compaction/end`) reads it because seed history and live work are otherwise422
* byte-identical: an unmatched opening marker before this event belongs to423
* an ended lifecycle, whatever ended it. NOT a liveness signal about other424
* writers — a concurrently live session holds its own boundary elsewhere,425
* so tolerating concurrent writers needs a signal beyond the log.426
*/427
'session/end-seed': { inherited?: true }428
}430
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */431
export type SessionEventType = keyof SessionEventMap433
/**434
* The subset of {@link SessionEventType} values whose events produce LLM435
* messages and are eligible to appear on the ordered surface. Only these436
* event types may carry {@link SurfaceOp}; system, user, and tool events may also cite437
* earlier sources through {@link SessionEvent.sourceEventSeqs}.438
*/439
export type SurfaceEventType =440
| 'system/message'441
| 'developer/message'442
| 'user/message'443
| 'assistant/message'444
| 'tool/result'446
/** A message-producing event carrying its required surface operation. */447
export type SurfaceEvent = SessionEvent<SurfaceEventType>449
/**450
* How a session event entered the ordered surface. Only valid on451
* {@link SurfaceEventType} events.452
*453
* - `'append'`: added to the tail — normal path for user/assistant/tool454
* messages.455
* - `{ op: 'replace', startSeq, endSeq }`: replaces surface nodes from `startSeq`456
* (inclusive) through `endSeq` (inclusive) with this node. Both must exist as457
* surface nodes in the current surface. `startSeq === endSeq` replaces a single458
* node. The node's {@link SessionEvent.sourceEventSeqs} must include every459
* shadowed surface node. Used by compaction; any surface-replacing producer460
* may use it.461
*/462
export type SurfaceOp =463
| 'append'464
| { op: 'replace'; startSeq: SessionSeq; endSeq: SessionSeq }466
/**467
* Surface placement and cited source-event seqs for {@link Session.append}. Required on468
* message-producing events and forbidden on log-only events.469
*/470
export type SurfaceIntent<T extends SurfaceEventType = SurfaceEventType> = {471
surfaceOp: SurfaceOp472
} & (T extends 'assistant/message' ? {473
/** Assistant messages embed their provider stream instead of citing source events. */474
sourceEventSeqs?: never475
} : {476
/** Complete non-empty set of known earlier source-event seqs. */477
sourceEventSeqs?: SessionSeq[]478
})480
/**481
* One immutable entry in the session log.482
*483
* A proper discriminated union over `type` (not independent `type`/`data`484
* unions), so `switch (event.type)` narrows `event.data` without casts.485
*486
* The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:487
* they only exist on {@link SurfaceEventType} variants (`system/message`, `user/message`,488
* `assistant/message`, `tool/result`).489
* Non-surface events (boundary markers, attempts, errors) never carry490
* surface metadata — the compiler enforces this at `Session.append()`491
* call sites.492
*/493
export type SessionEvent<T extends SessionEventType = SessionEventType> = {494
[K in SessionEventType]: {495
type: K496
/** Monotonic sequence number within the session. */497
seq: SessionSeq498
/** Unix epoch milliseconds. */499
time: number500
data: SessionEventMap[K]501
/**502
* Marks an event a reader may safely skip when it does not recognize503
* `type`. Absent means required: a reader meeting an unrecognized type504
* without this marker MUST refuse to reconstruct the session instead of505
* silently dropping the event, because an unrecognized required event may506
* change how the rest of the log is interpreted. A writer sets `true` only507
* on purely informational records whose loss cannot affect reconstruction;508
* defaulting to required means a forgotten marker over-refuses (an509
* inconvenience) rather than silently resuming a gutted session.510
*/511
ignorable?: true512
} & (K extends SurfaceEventType ? SurfaceIntent<K> : {513
surfaceOp?: never514
sourceEventSeqs?: never515
})516
}[T]518
declare module '@deepseek-ai/dsh-typert-protocol' {519
interface RemoteErrorDetailsMap {520
/** The named Session does not exist; produced by every layer that resolves a SessionId. */521
'session/not-found': { readonly sessionId: SessionId }522
}523
}