返回源码地图

packages/core/session/src/types.ts

main snapshot · da00f7f5358f · 正文引用章节 05 / 05 / 05 / 16 / 16 / 21 / 26;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1import { brandNumber, brandString, type Branded, type BrandedNumber } from '@deepseek-ai/dsh-brand'
2import 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'
17import type { JsonValue } from '@deepseek-ai/dsh-util-values'
18
19/** Identifies one session in the store (and its persistence artifacts). */
20export type SessionId = Branded<'SessionId'>
21
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 */
27export function SessionId(id: string): SessionId {
28 return brandString<SessionId>(id)
29}
30
31/** Sequence number of one existing event in a Session log. */
32export type SessionSeq = BrandedNumber<'SessionSeq'>
33
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 */
39export 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}
45
46/** A Session log gap, prefix length, or read offset, which may equal the event count. */
47export type SessionLogOffset = BrandedNumber<'SessionLogOffset'>
48
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 */
54export 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}
60
61/** Inclusive Session event watermark, or `-1` before any event exists. */
62export type SessionSeqCursor = SessionSeq | -1
63
64/** One existing Session event position, or explicit absence. */
65export type OptionalSessionSeq = SessionSeq | null
66
67/**
68 * Current logical Session format version, stamped into every newly written
69 * {@link SessionHeader}. Current Session and persistence code accept only this
70 * value; header-only readers classify supported historical formats, while an
71 * event-body read composes the build-static adjacent chain and publishes only
72 * this final generation before constructing a Session.
73 *
74 * The version is a single monotonic integer with no major/minor split. Whether
75 * a bump is needed is decided by what the WRITER emits, never by what a newer
76 * reader can accept: bump exactly when an older runtime could no longer handle
77 * a new log with full semantic correctness ("parses without error" is not
78 * correctness — silently skipping content that shapes reconstruction is a
79 * wrong read). Only structural changes reach that bar: the header shape, the
80 * {@link SessionEvent} envelope, core event semantics, or the surface
81 * mechanism (the {@link SurfaceEventType} set and {@link SurfaceOp} variants).
82 * Adding an ordinary event type does not bump — the per-event
83 * {@link SessionEvent.ignorable} guard covers vocabulary growth instead. When
84 * in doubt, bump: a near-identity upgrade step is almost free, a missed bump
85 * makes older runtimes read new logs wrong silently. The released migration,
86 * immutable prior-generation, and current fast-path rules are recorded in
87 * `.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md`.
88 */
89export const SESSION_FORMAT_VERSION = 4
90
91/**
92 * Immutable validated storage metadata, kept outside the conversation event log.
93 */
94export 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_VERSION
100 /** The session's id (mirrors the {@link Session}'s id). */
101 readonly id: SessionId
102 /** Non-negative safe-integer Unix epoch milliseconds when the session was created. */
103 readonly createdAt: number
104 /** Absolute working directory the session was created in (if any). */
105 readonly cwd?: string
106 /** The session this one was forked from (seed lineage), if any. */
107 readonly parentSession?: SessionId
108 /**
109 * Whether this Session contains a fork-inherited event prefix. The exact prefix
110 * length is Session state rather than ordinary header metadata.
111 */
112 readonly isSeeded: boolean
113 /**
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 + 1
120 * for a subagent child. Persisted so a recursion budget survives restart and
121 * resume — a runtime-only depth would reset a resumed child to top-level.
122 */
123 readonly delegationDepth?: number
124 /**
125 * Id of the agent preset this session's agent was composed from, when the
126 * deployment composes per session. Durable because the preset decides the
127 * session's tools and prompt: a resume that restored a different composition
128 * would replay history the model can no longer act on.
129 */
130 readonly agentPreset?: string
131}
132
133/**
134 * Options for creating a {@link Session} via the store. `seed` replays/forks
135 * an existing event log; `meta` carries the caller-supplied storage fields the
136 * store folds into a {@link SessionHeader}.
137 */
138export 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. The
143 * constructor appends the child-owned tagged marker at the cut unless
144 * the seed already includes it followed by child-owned fork closers.
145 */
146 readonly inheritedEventCount?: SessionLogOffset
147 /**
148 * Storage metadata read once before publication. `isSeeded` marks fork
149 * lineage; supplying replay history alone does not make it inherited.
150 */
151 readonly meta?: {
152 readonly cwd?: string
153 readonly parentSession?: SessionId
154 readonly createdAt?: number
155 readonly isSeeded?: boolean
156 readonly origin?: 'subagent'
157 readonly delegationDepth?: number
158 readonly agentPreset?: string
159 }
160}
161
162/**
163 * Aliasing state of an adoptable Session seed. `shared-frozen` permits deeply
164 * frozen aliases plus independently owned unfrozen values in the same seed.
165 */
166export type SessionSeedEventState = 'detached' | 'shared-frozen'
167
168/**
169 * Adoptable storage values transferred to {@link SessionStore.prepare}
170 * without another copy or freeze pass.
171 */
172export 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: SessionHeader
177 /** Exact number of fork-inherited leading events decoded from storage. */
178 readonly inheritedEventCount: SessionLogOffset
179 /** Aliasing state carried from the operation that produced the seed. */
180 readonly eventState: SessionSeedEventState
181}
182
183/** Inputs accepted while constructing an unpublished Session. */
184export type PrepareSessionOptions =
185 | (CreateSessionOptions & { readonly eventState?: undefined })
186 | RestoredSessionOptions
187
188/** Why an active agent driver was cancelled. */
189export type AgentCancelCause =
190 | { readonly kind: 'user' }
191 | { readonly kind: 'parent' }
192 | { readonly kind: 'hook'; readonly reason: string }
193 | { readonly kind: 'disposed' }
194
195/** Durable cancellation cause, including imports whose original coarse record carried no cause. */
196export type TurnEndCancelCause = AgentCancelCause | { readonly kind: 'legacy' }
197
198/**
199 * Why a turn ended. Merge-extensible sum type.
200 */
201export interface TurnEndReasonMap {
202 completed: { kind: 'completed' }
203 /** A cancellation request interrupted the live turn. */
204 aborted: { kind: 'aborted'; reason: TurnEndCancelCause }
205
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 appends
217 * this closer for a stored log whose last turn never ended, and session-query
218 * synthesizes it on cold reads. The loop never emits this marker live, and
219 * 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 fork
224 * boundary in the source session. Only fork seeds carry this marker — the
225 * loop never emits it — and the source events before the boundary remain
226 * intact in the child.
227 */
228 forked: { kind: 'forked' }
229}
230
231/** The union over {@link TurnEndReasonMap} — why a turn ended; plugins extend it by merging variants into the map. */
232export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap]
233
234/**
235 * Logged request state outside derived history: call config and tools. The
236 * system prompt is derived history — surface node 0, a `system/message` event.
237 * The latest full `request/header` snapshot reconstructs the header; canonical
238 * empty optional fields are absent.
239 */
240export interface EpochHeader {
241 /** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */
242 config: LlmCallConfig
243 /** Effective config fields materialized from the exact adapter rather than proposed by a caller. */
244 adapterDefaults?: LlmCallConfigAdapterDefaults
245 /** Assembled tool schemas; absent for a tool-less request. */
246 tools?: ToolSchema[]
247 /** Retired request text; system prompts belong to system/message events.
248 * @persistenceReserved
249 */
250 system?: never
251}
252
253/** Registration-bound metadata for one resolved model route. */
254export interface RequestContext {
255 /** Registered provider route the metadata belongs to. */
256 provider: string
257 /** Provider-owned model id the metadata belongs to. */
258 model: string
259 /** Maximum combined request and response context in tokens, when advertised. */
260 contextWindow?: number
261 /** `'in-history'` when the route reads the latest `system` message at any position as the effective system prompt. */
262 systemPromptUpdate?: SystemPromptUpdate
263}
264
265/**
266 * Why a `request/header` snapshot was appended: `'initial'` — the log's first
267 * header (a new conversation); `'resume'` — a loop instance's first request
268 * over a log that already has header events (process restart, fork seed);
269 * `'change'` — a later request used a different header; `'series'` — an unchanged
270 * header began an explicitly distinct message series or followed a surface
271 * replacement. Other reasons carry `startsSeries` when a new series coincides.
272 */
273export type RequestHeaderReason = 'initial' | 'resume' | 'change' | 'series'
274
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 and
278 * sequence numbers stay contiguous. Assistant attempt events embed their exact
279 * compact raw streams so persistence stores one durable settlement per attempt.
280 */
281export 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 no
285 * step; otherwise the following identified `user/message` event or batch
286 * 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 turn
291 * with no entered step has no `step/start` or `step/end`. The loop does not await a
292 * flush at turn boundaries: `dsh-session-checkpoint-policy` owns the
293 * per-request durability checkpoint, and consumers that read storage after
294 * `whenIdle()` flush themselves. Success commits the turn; rejection is
295 * 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 prompt
304 * (the queued message claimed for this turn), a synthetic `agent.inject()`
305 * context (file-change notices, subdir AGENTS.md, skill content, cron
306 * notifications, …), or an entered goal continuation round. All three
307 * project their `content` verbatim; `source` tells them apart.
308 */
309 'user/message': UserMessage
310 /** An incremental agent session change admitted at the named turn and step. */
311 'developer/message': {
312 turn: number
313 step: number
314 message: DeveloperMessage
315 /** Earlier request/header defining every tool addition; required exactly when additions are present. */
316 headerSeq?: SessionSeq
317 }
318 /**
319 * The rendered system prompt on the model-visible surface. The loop appends
320 * 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 continuing
322 * series. An incapable route or new series normalizes text to the first system
323 * node. Normalization empties nonempty later nodes, then rewrites the head if
324 * needed, through logged per-node replacements. An empty rendering always
325 * 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 with
327 * no active later node records "no system prompt". Restored nonempty text follows
328 * 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, so
334 * the model output and its accounting travel together (there is no separate
335 * usage record). `usage` is absent when the adapter reported none. A turn
336 * cancelled mid-stream finalizes its delivered text/reasoning prefix as this
337 * event with `interrupted: true`; undispatched tool calls are absent. The
338 * marker distinguishes that prefix without re-deriving interruption from turn
339 * boundaries. An aborted turn with no such event streamed no visible content.
340 */
341 'assistant/message': {
342 turn: number
343 step: number
344 message: AssistantMessage
345 /** Exact timed model stream, compacted without joining delta boundaries. */
346 stream: AssistantStreamRecord[]
347 usage?: TokenUsage
348 interrupted?: true
349 }
350 /**
351 * One model attempt that committed no surface message. The embedded stream
352 * preserves a failed, retried, cancelled, or stream-error attempt that
353 * 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 the
359 * 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 failure
364 * identity and user-facing reason, and optional tool-private `meta`
365 * presentation payload. The reason remains outside the model-facing message.
366 * `meta` is
367 * opaque to the core (the producing tool owns its shape and reads it back in
368 * `presentResult`) but MUST be JSON-serializable: `Session.append`
369 * runtime-validates all event data with `isJsonValue`, so a non-serializable
370 * `meta` is rejected at the source, and the durable log reproduces the
371 * identical card on replay. Absent
372 * unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time
373 * contextual diff here).
374 */
375 'tool/result': {
376 turn: number
377 step: number
378 message: ToolResultMessage
379 /**
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?: JsonValue
385 }
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: EpochHeader
392 reason: RequestHeaderReason
393 /** This request begins a distinct model-message series, independently of the header reason. */
394 startsSeries?: true
395 }
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 request
399 * reconstruction or header equality. Prompt admission uses the bound prepared
400 * call's capability, not this snapshot from an earlier request.
401 */
402 'request/context': RequestContext
403 /**
404 * Separates inherited or restored history from later lifecycle-owned work.
405 * This log-only marker need not be at {@link Session.firstLiveSeq}: a fork
406 * seed can already contain its tagged marker and child-owned synthetic
407 * closers before construction.
408 *
409 * A fresh fork child owns one `{ inherited: true }` marker at its exact
410 * inherited-prefix cut, even when that prefix ends in an ancestor marker.
411 * `buildForkSeed` appends that marker before any synthetic closers; the
412 * `Session` constructor supplies it when given only the inherited prefix.
413 * The last tagged marker is the current Session's cut; untagged markers
414 * 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 one
418 * 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 otherwise
422 * byte-identical: an unmatched opening marker before this event belongs to
423 * an ended lifecycle, whatever ended it. NOT a liveness signal about other
424 * 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}
429
430/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
431export type SessionEventType = keyof SessionEventMap
432
433/**
434 * The subset of {@link SessionEventType} values whose events produce LLM
435 * messages and are eligible to appear on the ordered surface. Only these
436 * event types may carry {@link SurfaceOp}; system, user, and tool events may also cite
437 * earlier sources through {@link SessionEvent.sourceEventSeqs}.
438 */
439export type SurfaceEventType =
440 | 'system/message'
441 | 'developer/message'
442 | 'user/message'
443 | 'assistant/message'
444 | 'tool/result'
445
446/** A message-producing event carrying its required surface operation. */
447export type SurfaceEvent = SessionEvent<SurfaceEventType>
448
449/**
450 * How a session event entered the ordered surface. Only valid on
451 * {@link SurfaceEventType} events.
452 *
453 * - `'append'`: added to the tail — normal path for user/assistant/tool
454 * messages.
455 * - `{ op: 'replace', startSeq, endSeq }`: replaces surface nodes from `startSeq`
456 * (inclusive) through `endSeq` (inclusive) with this node. Both must exist as
457 * surface nodes in the current surface. `startSeq === endSeq` replaces a single
458 * node. The node's {@link SessionEvent.sourceEventSeqs} must include every
459 * shadowed surface node. Used by compaction; any surface-replacing producer
460 * may use it.
461 */
462export type SurfaceOp =
463 | 'append'
464 | { op: 'replace'; startSeq: SessionSeq; endSeq: SessionSeq }
465
466/**
467 * Surface placement and cited source-event seqs for {@link Session.append}. Required on
468 * message-producing events and forbidden on log-only events.
469 */
470export type SurfaceIntent<T extends SurfaceEventType = SurfaceEventType> = {
471 surfaceOp: SurfaceOp
472} & (T extends 'assistant/message' ? {
473 /** Assistant messages embed their provider stream instead of citing source events. */
474 sourceEventSeqs?: never
475} : {
476 /** Complete non-empty set of known earlier source-event seqs. */
477 sourceEventSeqs?: SessionSeq[]
478})
479
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 carry
490 * surface metadata — the compiler enforces this at `Session.append()`
491 * call sites.
492 */
493export type SessionEvent<T extends SessionEventType = SessionEventType> = {
494 [K in SessionEventType]: {
495 type: K
496 /** Monotonic sequence number within the session. */
497 seq: SessionSeq
498 /** Unix epoch milliseconds. */
499 time: number
500 data: SessionEventMap[K]
501 /**
502 * Marks an event a reader may safely skip when it does not recognize
503 * `type`. Absent means required: a reader meeting an unrecognized type
504 * without this marker MUST refuse to reconstruct the session instead of
505 * silently dropping the event, because an unrecognized required event may
506 * change how the rest of the log is interpreted. A writer sets `true` only
507 * on purely informational records whose loss cannot affect reconstruction;
508 * defaulting to required means a forgotten marker over-refuses (an
509 * inconvenience) rather than silently resuming a gutted session.
510 */
511 ignorable?: true
512 } & (K extends SurfaceEventType ? SurfaceIntent<K> : {
513 surfaceOp?: never
514 sourceEventSeqs?: never
515 })
516}[T]
517
518declare 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}