index.ts 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930
  1. /**
  2. * Event-sourced session service: append-only session log, in-memory store, and
  3. * the derived LLM message history. Persistence is a plugin concern (subscribe
  4. * to `session/event`, drain on `session/flush`).
  5. *
  6. * @module @deepseek-ai/dsh-session
  7. */
  8. import { Context, Service } from 'cordis'
  9. import { isAbsolute } from 'node:path'
  10. import { deepFreeze } from '@deepseek-ai/dsh-llm'
  11. import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
  12. import type { Scoped } from '@deepseek-ai/dsh-scope'
  13. import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
  14. import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
  15. import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
  16. import { snapshotJsonValue } from './json.ts'
  17. import { SurfaceManager, isSurfaceEligibleType } from './surface.ts'
  18. import { foldRequestHeader } from './request-header.ts'
  19. export * from './types.ts'
  20. export { isJsonValue, snapshotJsonValue } from './json.ts'
  21. export type { JsonValue } from './json.ts'
  22. export { interruptedTurnClosers } from './repair.ts'
  23. export type { SurfaceNode } from './surface.ts'
  24. export { isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
  25. export { isToolPairingBalanced } from './tool-pairing.ts'
  26. export { applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from './request-header.ts'
  27. declare module 'cordis' {
  28. interface Context {
  29. sessions: SessionStore
  30. }
  31. interface Events {
  32. /**
  33. * A session was created in the store. A synchronous listener throw vetoes
  34. * publication and rollback emits the matching `session/disposed` edge;
  35. * returned-promise rejection is observed and logged but cannot retroactively
  36. * veto this synchronous boundary. A synchronous listener that requests the
  37. * advanced detach does not remove the entry immediately: removal and the
  38. * paired `session/disposed` edge wait until the creation dispatch unwinds.
  39. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
  40. * session's owner scope, captured when the session was ENTERED (an agent's
  41. * session is entered through `agent.ctx`, so its events dispatch in that
  42. * agent's scope; a bare `sessions.create()` from a plain plugin dispatches
  43. * subject-less). A listener registered through `agent.ctx` hears only that
  44. * agent's sessions; a plain plugin listener hears every session.
  45. * @param session - the session just entered and announced.
  46. * @mode emit
  47. */
  48. 'session/created'(this: Scoped<Session>, session: Session): void
  49. /**
  50. * A previously announced session left the store. Emitted exactly once on
  51. * normal detach or publication rollback, and never for a prepared/entered
  52. * session whose `session/created` announcement did not begin. Listener
  53. * failures (including returned-promise rejections) are logged and contained
  54. * per listener so teardown always reaches quiescence.
  55. * Scope-filtered dispatch uses the same owner carrier captured at entry;
  56. * agent-scoped listeners hear only their own session's teardown.
  57. * @param session - the session that is no longer live in the store.
  58. * @mode emit
  59. */
  60. 'session/disposed'(this: Scoped<Session>, session: Session): void
  61. /**
  62. * An event was appended to a session log (sync, fire-and-forget). This is
  63. * the per-append feed a UI or invariant plugin tails. The log push is the
  64. * commit point; synchronous throws and returned-promise rejections from
  65. * observers are logged and contained per listener, so they cannot make a
  66. * committed append appear to fail or starve later listeners. The exact
  67. * callback list and Cordis internal-dispatch checks resolve before the push;
  68. * callbacks themselves run only after it.
  69. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
  70. * session's owner scope, captured when the session was ENTERED (an agent's
  71. * session is entered through `agent.ctx`, so its events dispatch in that
  72. * agent's scope; a bare `sessions.create()` from a plain plugin dispatches
  73. * subject-less). A listener registered through `agent.ctx` hears only that
  74. * agent's sessions; a plain plugin listener hears every session.
  75. * @param session - the session whose log grew.
  76. * @param event - the appended event, exactly as recorded.
  77. * @mode emit
  78. */
  79. 'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
  80. /**
  81. * Awaited durability checkpoint. The agent loop awaits
  82. * `ctx.sessions.flush(session)` at every turn end; persistence
  83. * plugins (JSONL, SQLite) drain their write-behind buffers here and on
  84. * fiber dispose. Awaited (parallel), not a waterfall: every listener runs
  85. * and the caller waits for all of them, but none can veto. Dispatch it
  86. * through {@link SessionStore.flush} — the store owns the carrier — never
  87. * via a raw `ctx.parallel`.
  88. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
  89. * session's owner scope, captured when the session was ENTERED (an agent's
  90. * session is entered through `agent.ctx`, so its events dispatch in that
  91. * agent's scope; a bare `sessions.create()` from a plain plugin dispatches
  92. * subject-less). A listener registered through `agent.ctx` hears only that
  93. * agent's sessions; a plain plugin listener hears every session.
  94. * @param session - the session whose buffered events must reach durable storage.
  95. * @mode parallel
  96. */
  97. 'session/flush'(this: Scoped<Session>, session: Session): Promise<void> | void
  98. }
  99. }
  100. /**
  101. * Renders a `context/message` or `steering/message` event as a tagged
  102. * synthetic user-role message (the system-reminder pattern: zero adapter
  103. * burden, models distinguish it from real user prompts by the envelope).
  104. *
  105. * Live-adapter review has validated the tagged-envelope rendering against
  106. * current DeepSeek behavior; provider-specific mismatches belong in that
  107. * adapter, not in the canonical session vocabulary.
  108. */
  109. function renderTagged(tag: string, content: ContentBlock[], source: MessageSource): ContentBlock[] {
  110. const open = `<${tag} source=${JSON.stringify(source.kind)}>`
  111. const close = `</${tag}>`
  112. return [
  113. { type: 'text', text: open },
  114. ...content,
  115. { type: 'text', text: close },
  116. ]
  117. }
  118. /** Detach, validate, and freeze the creation metadata published by a session. */
  119. function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
  120. const input: unknown = source === undefined
  121. ? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
  122. : source
  123. const snapshot = snapshotJsonValue(input)
  124. if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable')
  125. if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
  126. throw new Error('session header is not a plain JSON record')
  127. }
  128. const record = snapshot as Record<string, unknown>
  129. if (record.version !== SESSION_FORMAT_VERSION) {
  130. throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(record.version)}`)
  131. }
  132. if (record.id !== id) {
  133. throw new Error(`session header id "${String(record.id)}" does not match session id "${id}"`)
  134. }
  135. if (typeof record.createdAt !== 'number' || !Number.isFinite(record.createdAt)) {
  136. throw new Error('session header createdAt must be a finite number')
  137. }
  138. if (record.cwd !== undefined) {
  139. if (typeof record.cwd !== 'string') throw new Error('session header cwd must be a string')
  140. if (!isAbsolute(record.cwd)) {
  141. throw new Error(`session header cwd must be an absolute path, got "${record.cwd}"`)
  142. }
  143. }
  144. if (record.parentSession !== undefined && typeof record.parentSession !== 'string') {
  145. throw new Error('session header parentSession must be a string')
  146. }
  147. if (record.seedLength !== undefined
  148. && (typeof record.seedLength !== 'number' || !Number.isSafeInteger(record.seedLength) || record.seedLength < 0)) {
  149. throw new Error('session header seedLength must be a non-negative safe integer')
  150. }
  151. return deepFreeze(record as unknown as SessionHeader)
  152. }
  153. /** Validate the runtime shape of surface metadata after its JSON snapshot. */
  154. function assertSurfaceMetadataShape(
  155. type: string,
  156. surfaceOp: unknown,
  157. sourceEventSeqs: unknown,
  158. ): void {
  159. const eligible = isSurfaceEligibleType(type)
  160. if (!eligible) {
  161. if (surfaceOp !== undefined || sourceEventSeqs !== undefined) {
  162. throw new Error(`session event "${type}" is not surface-eligible and cannot carry surface metadata`)
  163. }
  164. return
  165. }
  166. if (surfaceOp === undefined) {
  167. throw new Error(`session event "${type}" is surface-eligible and requires a surfaceOp marker`)
  168. }
  169. if (surfaceOp !== 'append') {
  170. if (surfaceOp === null || typeof surfaceOp !== 'object' || Array.isArray(surfaceOp)) {
  171. throw new Error(`session event "${type}" carries an invalid surfaceOp`)
  172. }
  173. const op = surfaceOp as Record<string, unknown>
  174. const keys = Object.keys(op)
  175. if (keys.length !== 3 || !Object.hasOwn(op, 'op') || !Object.hasOwn(op, 'start') || !Object.hasOwn(op, 'end')
  176. || op['op'] !== 'replace'
  177. || typeof op['start'] !== 'number' || !Number.isSafeInteger(op['start']) || op['start'] < 0
  178. || typeof op['end'] !== 'number' || !Number.isSafeInteger(op['end']) || op['end'] < 0) {
  179. throw new Error(`session event "${type}" carries an invalid replace surfaceOp`)
  180. }
  181. }
  182. if (sourceEventSeqs !== undefined) {
  183. if (!Array.isArray(sourceEventSeqs)
  184. || sourceEventSeqs.some(seq => typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0)) {
  185. throw new Error(`session event "${type}" sourceEventSeqs must contain non-negative safe integers`)
  186. }
  187. }
  188. }
  189. /** Validate the fixed event envelope after one-pass JSON materialization. */
  190. function assertSessionEventEnvelope(value: Record<string, unknown>, index: number): asserts value is SessionEvent {
  191. const event = value
  192. const allowed = new Set(['type', 'seq', 'time', 'data', 'surfaceOp', 'sourceEventSeqs'])
  193. if (Object.keys(event).some(key => !allowed.has(key))
  194. || !Object.hasOwn(event, 'type') || typeof event['type'] !== 'string'
  195. || !Object.hasOwn(event, 'seq') || typeof event['seq'] !== 'number'
  196. || !Number.isSafeInteger(event['seq']) || event['seq'] < 0
  197. || !Object.hasOwn(event, 'time') || typeof event['time'] !== 'number'
  198. || !Number.isSafeInteger(event['time']) || event['time'] < 0
  199. || !Object.hasOwn(event, 'data')) {
  200. throw new Error(`seed event at index ${index} has an invalid event envelope`)
  201. }
  202. }
  203. type SessionCallback = (...args: unknown[]) => unknown
  204. /** Resolve one listener snapshot, including Cordis's internal dispatch checks. */
  205. function collectSessionCallbacks(ctx: Context, args: unknown[]): SessionCallback[] {
  206. return [...ctx.events.dispatch('emit', args)] as SessionCallback[]
  207. }
  208. /** Invoke one resolved observe-only listener snapshot with per-listener containment. */
  209. function invokeContainedSessionObservers(
  210. ctx: Context,
  211. name: 'session/event' | 'session/disposed',
  212. id: SessionId,
  213. args: unknown[],
  214. callbacks: SessionCallback[],
  215. ): void {
  216. for (const callback of callbacks) {
  217. try {
  218. const returned: unknown = callback(...args)
  219. void Promise.resolve(returned).catch((error: unknown) => {
  220. ctx.logger.warn(`session "${id}": ${name} listener rejected: ${String(error)}`)
  221. })
  222. } catch (error: unknown) {
  223. ctx.logger.warn(`session "${id}": ${name} listener threw: ${String(error)}`)
  224. }
  225. }
  226. }
  227. /** All mutable lifecycle state for one exact store entry. */
  228. interface SessionEntry {
  229. readonly id: SessionId
  230. readonly session: Session
  231. readonly carrier: Scoped<Session>
  232. readonly emitCtx: Context
  233. announced: boolean
  234. announcing: boolean
  235. appending: boolean
  236. detachRequested: boolean
  237. detach(): void
  238. }
  239. /** Store attachment for the append path; module-private to keep Session store-agnostic publicly. */
  240. const attachments = new WeakMap<Session, SessionEntry>()
  241. /**
  242. * An event-sourced session: an append-only log of {@link SessionEvent}s.
  243. *
  244. * Plain class (not a Service) — create instances via `ctx.sessions.create()`.
  245. * Seeding with an existing event log replays/forks a session.
  246. */
  247. export class Session {
  248. private log: SessionEvent[] = []
  249. /**
  250. * Derived surface — a cached linked list of message-producing events.
  251. * Lazily rebuilt from `surfaceOp` markers in the log; processes only new
  252. * events (delta) on each access — the log is append-only, so prior events
  253. * never change.
  254. * `append`. Undefined until first accessed (including after fork/seed).
  255. */
  256. private _surface: SurfaceManager | undefined
  257. /** The surface linked list over this session's event log. */
  258. get surface(): SurfaceManager {
  259. if (!this._surface) this._surface = new SurfaceManager(this.log)
  260. return this._surface
  261. }
  262. /**
  263. * Detached, deep-frozen creation metadata (format version, cwd, lineage,
  264. * seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
  265. * `Session` is constructed bare (tests, ad-hoc replay), a minimal header is
  266. * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so
  267. * `session.header` is always present. Kept out of the event log — it is a
  268. * storage concern, not replayable conversation state.
  269. */
  270. readonly header: SessionHeader
  271. constructor(public readonly id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
  272. if (seed) {
  273. // Validate the seed to the SAME invariants `append` enforces, so a
  274. // replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
  275. // live log that no persistence backend could store: each event's `data`
  276. // must be JSON-serializable, and `seq` must be contiguous from 0 (the
  277. // `seq = log.length` contract the whole system relies on). Without this,
  278. // a bad seed would surface only later as a backend rejection or a silent
  279. // divergence between the live log and disk.
  280. this.log = Array.from(seed, (source, index) => {
  281. // The seed is a persistence/replay boundary: validate and detach the
  282. // complete event in one lossless-JSON pass.
  283. const snapshot = snapshotJsonValue(source)
  284. if (snapshot === undefined) {
  285. throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`)
  286. }
  287. assertSessionEventEnvelope(snapshot, index)
  288. if (snapshot.seq !== index) {
  289. throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`)
  290. }
  291. // Surface-eligible events MUST carry a surfaceOp marker — the surface is
  292. // the sole source of derived history, so a marker-less message event
  293. // would load fine yet vanish from deriveMessages(). `append` enforces
  294. // this at compile time via its typed overload; a seed arrives as raw
  295. // SessionEvent[] (replay/fork/load), bypassing that, so re-check at
  296. // runtime here rather than silently resuming with empty history.
  297. const structural = snapshot as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: unknown }
  298. try {
  299. assertSurfaceMetadataShape(snapshot.type, structural.surfaceOp, structural.sourceEventSeqs)
  300. } catch (error: unknown) {
  301. throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`)
  302. }
  303. return deepFreeze(snapshot)
  304. })
  305. }
  306. this.header = snapshotSessionHeader(id, header)
  307. }
  308. /** Cached immutable public snapshot of the private append-only log. */
  309. private eventsSnapshot: readonly SessionEvent[] | undefined
  310. /**
  311. * An immutable snapshot of the append-only event log. The snapshot is reused
  312. * until the next append; a previously returned array does not grow later.
  313. * Events and their nested data are deep-frozen at acceptance, so neither a
  314. * cast nor ordinary JavaScript can rewrite durable history.
  315. */
  316. get events(): readonly SessionEvent[] {
  317. this.eventsSnapshot ??= Object.freeze([...this.log])
  318. return this.eventsSnapshot
  319. }
  320. /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */
  321. get seq(): number {
  322. return this.log.length
  323. }
  324. /**
  325. * Append one typed event to the log and synchronously notify observers via
  326. * the store-owned, module-private publication hooks. The hot path never blocks
  327. * on I/O — persistence plugins buffer asynchronously. Once the event enters
  328. * the log, the append is committed: observer failures are logged and
  329. * contained per listener, so they do not change the return value or prevent
  330. * later listeners from observing the same accepted event.
  331. *
  332. * @param type - The event type (key of {@link SessionEventMap}).
  333. * @param data - The event payload; must be JSON-serializable.
  334. * @param opts - Surface metadata: `surfaceOp` controls how the event enters
  335. * the surface linked list; `sourceEventSeqs` records provenance (the seq
  336. * numbers of events this one derives from). REQUIRED for
  337. * {@link SurfaceEventType} events (every message-producing event must
  338. * declare how it joins the surface, the sole source of derived history) and
  339. * rejected by the compiler for non-surface types like `turn/start` or
  340. * `assistant/chunk`.
  341. * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
  342. * `data` that entered the log, so reading `event.data` back sees the logged
  343. * value, never the caller's still-mutable input.
  344. * @throws if `data` or surface metadata is not losslessly JSON-serializable
  345. * (BigInt, function, symbol, undefined, negative zero, non-finite number,
  346. * circular reference, sparse array, or an exotic object such as
  347. * Map/Set/Date/class instance). One recursive pass reads, validates, and
  348. * copies each nested value once, so a stateful getter cannot supply one value
  349. * to validation and another to storage. The event log is the durable source
  350. * of truth, so a bad event fails at the append site rather than later during
  351. * a backend flush. A synchronous internal dispatch validation failure or an
  352. * append reentered while this acceptance/publication boundary is open also
  353. * rejects before the log changes.
  354. */
  355. append<T extends SessionEventType>(
  356. type: T,
  357. data: SessionEventMap[T],
  358. ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []
  359. ): SessionEvent<T> {
  360. const surfaceOpts: SurfaceIntent | undefined = opts[0]
  361. const surfaceMetadata = {
  362. ...surfaceOpts?.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: surfaceOpts.sourceEventSeqs },
  363. ...surfaceOpts?.surfaceOp === undefined ? {} : { surfaceOp: surfaceOpts.surfaceOp },
  364. }
  365. const dataSnapshot = snapshotJsonValue(data)
  366. if (dataSnapshot === undefined) {
  367. throw new Error(`session event "${type}" carries non-JSON-serializable data`)
  368. }
  369. const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata)
  370. if (surfaceMetadataSnapshot === undefined) {
  371. throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`)
  372. }
  373. assertSurfaceMetadataShape(
  374. type,
  375. (surfaceMetadataSnapshot as { surfaceOp?: unknown }).surfaceOp,
  376. (surfaceMetadataSnapshot as { sourceEventSeqs?: unknown }).sourceEventSeqs,
  377. )
  378. const entry = attachments.get(this)
  379. if (entry?.appending) {
  380. throw new Error('session append cannot reenter while another append is being published')
  381. }
  382. if (entry !== undefined) entry.appending = true
  383. try {
  384. const event = deepFreeze({
  385. type,
  386. seq: this.log.length,
  387. time: Date.now(),
  388. data: dataSnapshot,
  389. ...surfaceMetadataSnapshot,
  390. } as unknown as SessionEvent<T>)
  391. let callbacks: SessionCallback[] | undefined
  392. const callbackArgs: unknown[] = [this, event]
  393. if (entry !== undefined) {
  394. callbacks = collectSessionCallbacks(entry.emitCtx, [entry.carrier, 'session/event', ...callbackArgs])
  395. }
  396. this.log.push(event as SessionEvent)
  397. this.eventsSnapshot = undefined
  398. if (callbacks !== undefined && entry !== undefined) {
  399. invokeContainedSessionObservers(entry.emitCtx, 'session/event', entry.id, callbackArgs, callbacks)
  400. }
  401. return event
  402. } finally {
  403. if (entry !== undefined) {
  404. entry.appending = false
  405. if (entry.detachRequested && !entry.announcing) entry.detach()
  406. }
  407. }
  408. }
  409. /** Cached fold of the request-header events — see {@link requestHeader}. */
  410. private headerFold: EpochHeader | undefined
  411. /** Log position (events consumed) the header fold has reached. */
  412. private headerFoldSeq = 0
  413. /**
  414. * The {@link EpochHeader} in force after the log's last header event — the
  415. * header the NEXT request will be compared against — or undefined before
  416. * the first `request/header` snapshot. The live, incrementally-maintained
  417. * form of `foldRequestHeader(session.events)`: each header event is folded
  418. * once, when first seen, so a per-step read costs O(new events).
  419. * @returns the folded header, or undefined when no header event exists yet.
  420. */
  421. requestHeader(): EpochHeader | undefined {
  422. if (this.headerFoldSeq < this.log.length) {
  423. // Frozen on update: the fold is session state exposed by reference — a
  424. // consumer mutating it in place (instead of building a replacement)
  425. // would desync every later comparison against the log, so mutation
  426. // throws instead.
  427. this.headerFold = deepFreeze(foldRequestHeader(this.log.slice(this.headerFoldSeq), this.headerFold))
  428. this.headerFoldSeq = this.log.length
  429. }
  430. return this.headerFold
  431. }
  432. /** The derived-message cache: frozen projections, extended per unseen node. */
  433. private derived: Message[] = []
  434. /** Surface position (nodes projected) the cache has reached. */
  435. private derivedNodes = 0
  436. /** {@link SurfaceManager.replaceGeneration} the cache was built under. */
  437. private derivedGeneration = 0
  438. /**
  439. * Derive the LLM message history by walking the session surface — the linked
  440. * list of message-producing events maintained by `surfaceOp` markers. The
  441. * surface is the single source of derived history: every message-producing
  442. * append records its `surfaceOp`, so a raw event with no marker (a chunk, a
  443. * turn boundary) is correctly absent, and a compaction `replace` deletes the
  444. * shadowed nodes from the derivation. The projection rules are
  445. * {@link deriveEventMessage}, folded per node.
  446. *
  447. * CACHED: each surface node is projected exactly once, when first seen — a
  448. * call costs O(new nodes), and a surface rewrite (a `replace`;
  449. * {@link SurfaceManager.replaceGeneration}) rebuilds. The returned array is
  450. * a fresh snapshot per call (later appends never grow an array a caller
  451. * already holds); the `Message` objects in it are SHARED and **deep-frozen**.
  452. * Their content reuses the already frozen durable event data, so the cache
  453. * needs no second deep clone and consumers still cannot mutate the log.
  454. * @returns a fresh array of the shared, frozen derived history.
  455. */
  456. deriveMessages(): Message[] {
  457. const nodes = this.surface.nodes
  458. const generation = this.surface.replaceGeneration
  459. if (generation !== this.derivedGeneration) {
  460. this.derived = []
  461. this.derivedNodes = 0
  462. this.derivedGeneration = generation
  463. }
  464. for (const node of nodes.slice(this.derivedNodes)) {
  465. // Surface nodes are built from this.log — node.seq is always a valid
  466. // index by construction. The non-null assertion expresses that invariant.
  467. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  468. const msg = this.deriveEventMessage(this.log[node.seq]!)
  469. // A surface node is one of the five message-producing types, but an
  470. // empty-content assistant/message (a max-tokens step that hosts only
  471. // usage) derives to null and must not enter the transcript.
  472. if (msg) this.derived.push(deepFreeze(msg))
  473. }
  474. this.derivedNodes = nodes.length
  475. return [...this.derived]
  476. }
  477. /**
  478. * Project a single event into the LLM message it derives to, or null when
  479. * it produces none — a non-surface event (chunk, boundary, log-only record)
  480. * or an empty-content assistant/message (which exists only to host usage).
  481. * The per-node pure function {@link deriveMessages} folds over the surface;
  482. * an external reconstructor (or the dev invariant) folds the same function
  483. * over a log prefix's surface to rebuild the exact messages any request was
  484. * built from (the reconstructability RFC). The returned message wrapper is
  485. * fresh; its content reuses the logged event's already deep-frozen durable
  486. * data, so changing the wrapper cannot rewrite the log and changing content
  487. * throws.
  488. * @param event - the event to project.
  489. * @returns the derived message, or null when the event produces none.
  490. */
  491. deriveEventMessage(event: SessionEvent): Message | null {
  492. // Intentionally non-exhaustive: only message-producing events derive
  493. // history; turn/step boundaries, chunks, usage, and errors are
  494. // trace/replay data.
  495. switch (event.type) {
  496. case 'user/message': {
  497. return { role: 'user', content: event.data.content }
  498. }
  499. case 'assistant/message': {
  500. // Skip an empty-content assistant/message: it exists only to host a
  501. // max-tokens step's usage and must not inject a content-less assistant
  502. // turn into the provider transcript.
  503. if (event.data.content.length === 0) return null
  504. return { role: 'assistant', content: event.data.content }
  505. }
  506. case 'tool/result': {
  507. const { callId, content, isError } = event.data
  508. return {
  509. role: 'user',
  510. content: [{ type: 'tool-result', toolCallId: callId, content, isError }],
  511. }
  512. }
  513. case 'context/message': {
  514. const { content, source } = event.data
  515. return { role: 'user', content: renderTagged('context', content, source) }
  516. }
  517. case 'steering/message': {
  518. const { content, source } = event.data
  519. return { role: 'user', content: renderTagged('steering', content, source) }
  520. }
  521. default:
  522. // A non-surface event (boundary, chunk, log-only record) projects to
  523. // no message. Merge-extensible union: no assertNever here.
  524. return null
  525. }
  526. }
  527. }
  528. /** A fork source: either the live session object or its live store id. */
  529. export type SessionForkSource = Session | SessionId
  530. /**
  531. * Rejection codes for session forking: the fork source id is unknown to the
  532. * live store (`SESSION_NOT_FOUND`) or names a session object that is not the
  533. * store's live instance (`SESSION_NOT_LIVE`); the requested child id is
  534. * already taken (`SESSION_ALREADY_EXISTS`); the boundary is not a contiguous
  535. * existing seq (`INVALID_BOUNDARY`); or the boundary event is not a
  536. * `turn/end` — a fork must cut on a closed turn (`OPEN_TURN`).
  537. */
  538. export type SessionForkErrorCode =
  539. | 'SESSION_NOT_FOUND'
  540. | 'SESSION_NOT_LIVE'
  541. | 'SESSION_ALREADY_EXISTS'
  542. | 'INVALID_BOUNDARY'
  543. | 'OPEN_TURN'
  544. /** Typed error for session fork rejections. */
  545. export class SessionForkError extends Error {
  546. constructor(message: string, public readonly code: SessionForkErrorCode) {
  547. super(message)
  548. this.name = 'SessionForkError'
  549. }
  550. }
  551. /**
  552. * In-memory session store (`ctx.sessions`).
  553. *
  554. * Persistence is intentionally not implemented here — persistence plugins
  555. * subscribe to `session/event` and flush on `session/flush` / dispose.
  556. */
  557. export class SessionStore extends Service {
  558. private store = new Map<SessionId, SessionEntry>()
  559. private counter = 0
  560. constructor(ctx: Context) {
  561. super(ctx, 'sessions')
  562. }
  563. /**
  564. * Create a session owned by the calling fiber: disposing that fiber stops
  565. * event notification and removes the session from the store. `options.seed`
  566. * populates the session with a copy of those events (replay/fork);
  567. * `options.meta` attaches creation metadata (validated absolute `cwd`,
  568. * `parentSession` lineage) as the immutable {@link SessionHeader} (the store
  569. * fills `version`/`id`/`createdAt`).
  570. *
  571. * For an agent whose session must be torn down IN ORDER with its loop (so the
  572. * loop's final flush is captured before the store attachment ends), do NOT use this
  573. * — fold the session lifecycle into the agent's own effect via
  574. * {@link prepare} + {@link enter} + {@link announce} (see
  575. * `dsh-agent-loop`'s creation transaction).
  576. *
  577. * @param id - the session id; omitted, the store mints `session-<n>`.
  578. * @param options - seed events and/or creation metadata for the header.
  579. * @returns the live session, already entered and announced.
  580. * @throws if a session with `id` already exists, metadata is not a plain
  581. * lossless-JSON record with valid scalar fields, or `meta.cwd` is a
  582. * non-absolute path (storage backends key directories off it).
  583. */
  584. create(id?: SessionId, options?: CreateSessionOptions): Session {
  585. const session = this.prepare(id, options)
  586. // Single effect owned by the calling fiber. Yield the detach BEFORE
  587. // announcing so a throwing `session/created` listener rolls the attach back
  588. // (the generator effect disposes already-yielded disposers on a throw)
  589. // instead of leaking the store entry and its publication hooks.
  590. this.ctx.effect(function* (this: SessionStore) {
  591. yield this.enter(session)
  592. this.announce(session)
  593. }.bind(this), 'sessions.create()')
  594. return session
  595. }
  596. /**
  597. * Build a session WITHOUT entering it into the store — validate the id/cwd and
  598. * construct the {@link Session} (with its immutable {@link SessionHeader}).
  599. * Pairs with {@link enter} + {@link announce}: a caller that owns a composite
  600. * `ctx.effect` (the agent factory) folds the session lifecycle into that ONE
  601. * effect so a fiber unload tears the session + agent down as a single ORDERED
  602. * chain rather than as racing sibling effects — which would remove the publication hooks
  603. * before the loop's closing `session/flush`, dropping the closing events.
  604. *
  605. * @param id - the session id; omitted, the store mints `session-<n>`.
  606. * @param options - seed events and/or creation metadata for the header.
  607. * @returns the constructed session, NOT yet in the store.
  608. * @throws if a session with `id` already exists, metadata is not a plain
  609. * lossless-JSON record with valid scalar fields, or `meta.cwd` is a
  610. * non-absolute path.
  611. */
  612. prepare(id?: SessionId, options?: CreateSessionOptions): Session {
  613. let sessionId: SessionId
  614. if (id === undefined) {
  615. do sessionId = SessionId(`session-${++this.counter}`)
  616. while (this.store.has(sessionId))
  617. } else {
  618. sessionId = SessionId(id)
  619. }
  620. if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
  621. const seed = options?.seed
  622. const meta = options?.meta
  623. const header: SessionHeader = {
  624. version: SESSION_FORMAT_VERSION,
  625. id: sessionId,
  626. createdAt: meta?.createdAt ?? Date.now(),
  627. ...meta?.cwd === undefined ? {} : { cwd: meta.cwd },
  628. ...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession },
  629. ...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength },
  630. }
  631. return new Session(sessionId, seed, header)
  632. }
  633. /**
  634. * Enter a {@link prepare}d session into the store: install the module-private
  635. * append publication hooks and add it to the store. Returns the DETACH
  636. * disposer (hooks + store removal). Does NOT emit `session/created` —
  637. * the caller yields this disposer inside its effect and THEN calls
  638. * {@link announce}, so a throwing `session/created` listener rolls the attach
  639. * back instead of leaking it.
  640. *
  641. * Re-checks the id for a duplicate: `prepare` and `enter` are public
  642. * cross-package primitives and a caller may interleave arbitrary work (or
  643. * another create) between them, so a stale prepared session must NOT overwrite
  644. * a live store entry of the same id — its detach disposer would later delete
  645. * the REAL session. The {@link create} convenience and the agent factory call
  646. * the two back-to-back so they never trip this, but the public seam cannot
  647. * assume that.
  648. *
  649. * @param session - a {@link prepare}d session not yet in the store.
  650. * @returns the detach disposer (publication hooks + store removal). When called from
  651. * a synchronous `session/created` listener, removal and disposal wait until
  652. * that creation dispatch unwinds.
  653. * @throws if a session with this id is already in the store.
  654. */
  655. enter(session: Session): () => void {
  656. const id = session.id
  657. const carrier = scopeTarget(session, scopeOf(this.ctx))
  658. // This is the authoritative collision boundary after arbitrary unpublished
  659. // preparation. Only one exact same-id transaction can publish.
  660. if (this.store.has(id)) throw new Error(`session "${id}" already exists`)
  661. if (attachments.has(session)) throw new Error(`session "${id}" is already attached to a store`)
  662. const entry: SessionEntry = {
  663. id,
  664. session,
  665. carrier,
  666. emitCtx: this.ctx,
  667. announced: false,
  668. announcing: false,
  669. appending: false,
  670. detachRequested: false,
  671. detach: () => { this.detachEntered(entry) },
  672. }
  673. this.store.set(id, entry)
  674. attachments.set(session, entry)
  675. let entered = true
  676. const detach = (): void => {
  677. if (!entered) return
  678. entered = false
  679. // A lifecycle listener may own the advanced detach capability. Keep the
  680. // entry and its publication hooks live until synchronous creation or append
  681. // publication unwinds, then publish the paired disposal edge.
  682. if (entry.announcing || entry.appending) {
  683. entry.detachRequested = true
  684. return
  685. }
  686. entry.detach()
  687. }
  688. return detach
  689. }
  690. /** Remove one exact entered session and emit its paired disposal when announced. */
  691. private detachEntered(entry: SessionEntry): void {
  692. entry.detachRequested = false
  693. // A stale capability cannot remove observers or storage belonging to a
  694. // later same-id lifecycle.
  695. /* v8 ignore next -- enter() rejects replacement while this single-shot detach capability is live. */
  696. if (this.store.get(entry.id) !== entry) return
  697. this.store.delete(entry.id)
  698. attachments.delete(entry.session)
  699. if (entry.announced) this.emitDisposed(entry)
  700. }
  701. /** Emit `session/created` exactly once for an {@link enter}ed session (with
  702. * the carrier {@link enter} captured). Separate from {@link enter} so the
  703. * caller can yield the detach disposer first (rollback safety — see
  704. * {@link enter}).
  705. * @param session - the entered session to announce to listeners.
  706. * @throws if the session is not live or its announcement already began,
  707. * including a reentrant call from a creation listener. */
  708. announce(session: Session): void {
  709. const entry = this.liveEntryFor(session)
  710. if (entry.announced || entry.announcing) {
  711. throw new Error(`session "${entry.id}" was already announced`)
  712. }
  713. // Mark before emit: Cordis emit may deliver to earlier listeners and then
  714. // throw. Rollback must still pair that partial creation with disposal, and
  715. // a listener cannot recursively create a second lifecycle edge.
  716. entry.announced = true
  717. const callbackArgs: unknown[] = [session]
  718. entry.announcing = true
  719. try {
  720. const callbacks = collectSessionCallbacks(this.ctx, [entry.carrier, 'session/created', session])
  721. for (const callback of callbacks) {
  722. // Synchronous throws intentionally propagate and veto publication; the
  723. // yielded detach then emits the paired disposal edge. An async function
  724. // is nevertheless assignable to a void listener, so observe its returned
  725. // promise: rejection is too late to roll back and must be logged instead
  726. // of becoming unhandled.
  727. const returned: unknown = callback(...callbackArgs)
  728. void Promise.resolve(returned).catch((error: unknown) => {
  729. this.ctx.logger.warn(`session "${entry.id}": session/created listener rejected: ${String(error)}`)
  730. })
  731. }
  732. } finally {
  733. entry.announcing = false
  734. if (entry.detachRequested && !entry.appending) entry.detach()
  735. }
  736. }
  737. /** Emit the paired teardown notification with per-listener containment. */
  738. private emitDisposed(entry: SessionEntry): void {
  739. const callbackArgs: unknown[] = [entry.session]
  740. try {
  741. const callbacks = collectSessionCallbacks(this.ctx, [entry.carrier, 'session/disposed', entry.session])
  742. invokeContainedSessionObservers(this.ctx, 'session/disposed', entry.id, callbackArgs, callbacks)
  743. } catch (error: unknown) {
  744. this.ctx.logger.warn(`session "${entry.id}": session/disposed dispatch threw: ${String(error)}`)
  745. }
  746. }
  747. /**
  748. * Dispatch the awaited `session/flush` durability checkpoint for `session`,
  749. * with the carrier captured at {@link enter}. THE flush entry point: the
  750. * store owns the carrier, so callers (the loop's turn-end checkpoint, idle
  751. * injection, teardown drains) must come through here rather than dispatch a
  752. * raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the
  753. * scoped-dispatch invariant can pin it.
  754. * @param session - the session whose buffered events must reach durable storage.
  755. * @returns resolves when every flush listener has settled; after all settle,
  756. * rejects with the first registered listener failure if any listener failed.
  757. */
  758. async flush(session: Session): Promise<void> {
  759. const { carrier } = this.liveEntryFor(session)
  760. const callbackArgs: unknown[] = [session]
  761. const callbacks = collectSessionCallbacks(this.ctx, [carrier, 'session/flush', session])
  762. const results = await Promise.allSettled(callbacks.map((callback) => {
  763. try {
  764. return callback(...callbackArgs)
  765. } catch (error: unknown) {
  766. // Preserve the listener's exact rejection value; flush is a caller-owned
  767. // failure boundary, and Cordis listeners may throw arbitrary values.
  768. // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
  769. return Promise.reject(error)
  770. }
  771. }))
  772. const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected')
  773. if (failure !== undefined) throw failure.reason
  774. }
  775. /** Return the exact live entry; detached/prepared objects reject. */
  776. private liveEntryFor(session: Session): SessionEntry {
  777. const entry = attachments.get(session)
  778. if (entry === undefined || this.store.get(entry.id) !== entry) {
  779. throw new Error(`session "${session.id}" is not live in this store`)
  780. }
  781. return entry
  782. }
  783. /**
  784. * Look up a live session.
  785. * @param id - the session id to look up.
  786. * @returns the session, or undefined when no live session has that id.
  787. */
  788. get(id: SessionId): Session | undefined {
  789. return this.store.get(id)?.session
  790. }
  791. /**
  792. * All live sessions, in creation order.
  793. * @returns a fresh array; mutating it does not affect the store.
  794. */
  795. list(): Session[] {
  796. return [...this.store.values()].map(entry => entry.session)
  797. }
  798. /**
  799. * Create a live child session from a turn-enclosed prefix of a live source.
  800. * `boundary` is an inclusive source event seq; omitted means the source's
  801. * current last event. A non-empty selected slice must end at `turn/end`.
  802. *
  803. * @param source - Live source session object or id.
  804. * @param boundary - Inclusive source event seq to fork through; omitted means
  805. * the source's current last event, and omitted on an empty source forks an
  806. * empty child.
  807. * @param childSessionId - Optional child session id; omitted delegates to
  808. * `SessionStore`'s id policy.
  809. * @returns The created live child session.
  810. */
  811. fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session {
  812. if (childSessionId !== undefined && this.get(childSessionId) !== undefined) {
  813. throw new SessionForkError(`session "${childSessionId}" already exists`, 'SESSION_ALREADY_EXISTS')
  814. }
  815. const liveSource = this._resolveForkSource(source)
  816. const seed = this._forkSeed(liveSource, boundary)
  817. return this.create(childSessionId, {
  818. seed,
  819. meta: {
  820. ...liveSource.header.cwd !== undefined ? { cwd: liveSource.header.cwd } : {},
  821. parentSession: liveSource.id,
  822. seedLength: seed.length,
  823. },
  824. })
  825. }
  826. private _forkSeed(session: Session, requestedBoundary: number | undefined): SessionEvent[] {
  827. const events = session.events
  828. const lastEvent = events.at(-1)
  829. let boundary: number
  830. if (requestedBoundary !== undefined) {
  831. boundary = requestedBoundary
  832. } else {
  833. if (lastEvent === undefined) return []
  834. boundary = lastEvent.seq
  835. }
  836. if (!Number.isSafeInteger(boundary) || boundary < 0) {
  837. throw new SessionForkError(
  838. `fork boundary for session "${session.id}" must be a non-negative safe integer, got ${String(boundary)}`,
  839. 'INVALID_BOUNDARY',
  840. )
  841. }
  842. if (boundary >= events.length) {
  843. const lastSeq = events.at(-1)?.seq
  844. throw new SessionForkError(
  845. `fork boundary ${boundary} does not exist in session "${session.id}" (last seq: ${lastSeq ?? 'none'})`,
  846. 'INVALID_BOUNDARY',
  847. )
  848. }
  849. const boundaryEvent = events[boundary]
  850. if (boundaryEvent === undefined || boundaryEvent.seq !== boundary) {
  851. throw new SessionForkError(
  852. `fork boundary ${boundary} does not match a contiguous event seq in session "${session.id}"`,
  853. 'INVALID_BOUNDARY',
  854. )
  855. }
  856. if (boundaryEvent.type !== 'turn/end') {
  857. throw new SessionForkError(
  858. `fork boundary ${boundary} in session "${session.id}" must be turn/end, got ${boundaryEvent.type}`,
  859. 'OPEN_TURN',
  860. )
  861. }
  862. return events.slice(0, boundary + 1)
  863. }
  864. private _resolveForkSource(source: SessionForkSource): Session {
  865. if (typeof source === 'string') {
  866. const session = this.get(source)
  867. if (session === undefined) throw new SessionForkError(`session "${source}" not found`, 'SESSION_NOT_FOUND')
  868. return session
  869. }
  870. const live = this.get(source.id)
  871. if (live === undefined) {
  872. throw new SessionForkError(`session "${source.id}" not found`, 'SESSION_NOT_FOUND')
  873. }
  874. if (live !== source) throw new SessionForkError(`session "${source.id}" is not the live store instance`, 'SESSION_NOT_LIVE')
  875. return source
  876. }
  877. }
  878. export default SessionStore