Merge remote-tracking branch 'origin/master' into session-query-trace
# Conflicts: # docs/cordis-catalog/services.md # packages/core/session/README.md # packages/support/invariants/src/index.ts # packages/support/invariants/tests/invariants.spec.ts
This commit is contained in:
@@ -8,35 +8,35 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.sessions.create(id?: SessionId, options?: { seed?: readonly SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. The persistence/replay seed and resulting header are validated, detached, and deep-frozen at this durable boundary. The store fills `version`/`id` and defaults `createdAt` to now; a persisted reconstruction supplies the original `createdAt` and `seedLength`. Disposed with the calling fiber.
|
||||
- `ctx.sessions.flush(session: Session): Promise<void>` Dispatch the awaited `session/flush` durability checkpoint with the carrier captured at enter — THE flush entry point (the loop's turn-end checkpoint and idle injection call it; never dispatch a raw `ctx.parallel`). Every captured listener starts, the call waits for all of them to settle, and a failure rejects only after the other listeners finish. Rejects a prepared, detached, or stale same-id object instead of inventing a subject-less carrier.
|
||||
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt` and `seedLength`.
|
||||
- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject.
|
||||
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata.
|
||||
- `ctx.sessions.get(id: SessionId): Session | undefined`
|
||||
- `ctx.sessions.list(): Session[]`
|
||||
|
||||
#### Advanced: ordered-teardown lifecycle primitives
|
||||
|
||||
`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before the store attachment and publication hooks are removed — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect:
|
||||
Use the split lifecycle only when teardown must be ordered with another resource:
|
||||
|
||||
- `ctx.sessions.prepare(id?, options?): Session` — validate durable seed/header data and construct the `Session` WITHOUT entering it into the store. Same options as `create`.
|
||||
- `ctx.sessions.enter(session): () => void` — perform the authoritative ID collision check, install append publication state, and insert the exact session without announcing it. Returns an idempotent detach bound to the captured entry object, so a stale disposer cannot remove a later same-ID replacement. Concurrent same-ID preparation is allowed; only one final entry succeeds.
|
||||
- `ctx.sessions.announce(session): void` — begin the one allowed `session/created` announcement for an entered session; repeat and reentrant calls reject before dispatch. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, so another creation listener cannot observe `session/disposed` before its own `session/created` callback. Detach emits `session/disposed` exactly once, including rollback after a partially delivered creation notification; a never-announced entry emits neither edge.
|
||||
- `prepare(id?, options?)` validates and constructs without publication.
|
||||
- `enter(session)` performs the collision check, publishes without announcing, and returns an entry-bound idempotent detach. Concurrent same-id preparations are allowed, but only one entry succeeds; a stale detach cannot remove its replacement.
|
||||
- `announce(session)` emits the single creation edge and rejects repeat or reentrant announcements. Detach during that dispatch is deferred and later emits the paired disposal edge; an unannounced entry emits neither lifecycle edge.
|
||||
|
||||
`dsh-agent-loop` is the canonical consumer: after unpublished agent setup it enters both session and agent before announcing either, then nests loop stop, agent removal, session detach, and scope unwind in one ordered lifecycle. The final flush therefore settles before this package detaches the session, whether teardown starts from an `AgentHandle` or owner-fiber unload.
|
||||
`dsh-agent-loop` uses this split so final loop flush precedes session detach; see the [ownership RFC](../../../docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md).
|
||||
|
||||
### Live service events
|
||||
|
||||
The store pairs announced creation with disposal, publishes each append, and provides an awaited durability checkpoint. Before the log push it resolves the scoped `session/event` callback list. The push is the commit point; callback throws or returned-promise rejections are logged and contained per observer. A committed append therefore returns normally, later observers still run, and detach waits until publication unwinds. Exact `session/*` signatures, modes, and scope-carrier behavior live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the append-only payload vocabulary is separately generated into the [persistence catalog](../../../docs/persistence-catalog.md).
|
||||
The store pairs announced creation with disposal, publishes post-commit append notifications with per-listener containment, and provides an awaited durability checkpoint. Exact signatures and scope behavior live in the generated [event catalog](../../../docs/cordis-catalog/events.md); payloads live in the [persistence catalog](../../../docs/persistence-catalog.md).
|
||||
|
||||
### Class: `Session`
|
||||
|
||||
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
|
||||
- `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. At this durable boundary, data and surface metadata are lossless-JSON snapshotted and deep-frozen, then the same atomic surface transition used by replay validates marker shape, provenance, and complete replacement coverage before the log changes. For an attached session, a reentrant append during dispatch/observer publication rejects, and detach waits for that publication to unwind. Callbacks resolve before the log push; the push is the commit point, after which each observer failure is contained independently.
|
||||
- `session.deriveMessages(): Message[]` — the LLM message history, CACHED: each surface node is projected exactly once, when first seen (O(new nodes) per call; a surface rewrite rebuilds via `surface.replaceGeneration`). Returns a fresh array per call over shared, deep-frozen `Message` objects. Each projection reuses the already deep-frozen content in its durable log event, so no second deep clone is needed and a consumer still cannot mutate logged data. The surface is the single source of derived history — there is no raw-log fallback.
|
||||
- `session.deriveEventMessage(event): Message | null` — the per-event projection `deriveMessages()` folds: a fresh message wrapper that reuses the event's already frozen content, or `null` when the event produces none (a non-surface event, or an empty-content `assistant/message` hosting only usage). External reconstructors and the dev invariant fold the same function over a log prefix's surface, so no two paths can disagree about what a request's messages were (the reconstructability RFC).
|
||||
- `session.surface: SurfaceManager` — the derived surface, lazily rebuilt from `surfaceOp` markers in the log. It processes only new events (delta) on each access; event acceptance uses a separate manager with the same transition so validation does not eagerly mutate this public view. `surface.replaceGeneration` is the rewrite signal: bumped by every folded `replace` and by `invalidate()`, never reset, so an incremental consumer comparing generations cannot be fooled.
|
||||
- `session.events` — a cached, frozen array snapshot over deep-frozen events. Repeated reads without an append return the same array; an append invalidates the cache and the next read returns a new snapshot, while earlier snapshots stay unchanged. Neither a cast nor a retained reference can push into the live log or rewrite an accepted event.
|
||||
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, and complete replacement coverage, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
|
||||
- `session.deriveMessages()` incrementally projects each new surface node once and returns a fresh array over shared frozen messages. A surface rewrite rebuilds the projection; there is no raw-log fallback.
|
||||
- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants.
|
||||
- `session.surface` lazily folds only new `surfaceOp` markers; `replaceGeneration` changes on every rewrite or invalidation.
|
||||
- `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen.
|
||||
- `session.seq`, `session.id` — current sequence and readonly typed identity.
|
||||
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates the durable record and requires its id to match `session.id`.
|
||||
|
||||
@@ -54,7 +54,7 @@ Durable values need one accepted representation, not a check followed by a secon
|
||||
|
||||
### Request-header reconstruction (`request-header.ts`)
|
||||
|
||||
The `request/header` (full `EpochHeader` snapshot with a `RequestHeaderReason`) and `request/header-delta` (system line-trim / name-keyed tools delta / whole config / whole session prefix) events make the request envelope logged session state, so every conversation request is a pure function of the log. The pure trio reconstructs it: `foldRequestHeader(events)` folds a log (or any prefix) into the header in force; `diffHeader(prev, next)` encodes a change (undefined when equal); `applyHeaderDelta(prev, delta)` replays one. Writer contract: every logged delta is round-trip-verified (`apply(prev, delta)` deep-equals the new header) with a `'fallback'` snapshot when the encoding cannot express the change (a pure tool reordering), so folding never needs error recovery on a well-formed log. `canonicalHeader` pins the one representation of absence (empty system/tools/messagePrefix ≡ absent fields; a delta's EMPTY prefix array encodes the transition back to absence). `EpochHeader.messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product — composed once per loop instance, the request is `messagePrefix + derived history`, and `deriveMessages()` never returns it.
|
||||
`request/header` and `request/header-delta` make the non-history request envelope reconstructable from the log. `foldRequestHeader()` reconstructs the active header, `diffHeader()` encodes changes, and `applyHeaderDelta()` replays them; unsupported deltas fall back to a full snapshot. `messagePrefix` remains separate from derived history. See the [reconstructable-requests RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md).
|
||||
|
||||
### Session event vocabulary (`types.ts`)
|
||||
|
||||
@@ -76,7 +76,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
|
||||
### Extension points
|
||||
|
||||
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log.
|
||||
- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The constructor reads each seed entry once and uses the same one-pass lossless-JSON snapshot and exact surface-metadata shape checks as `append`, then enforces contiguous seqs and deep-freezes every accepted record; a stateful caller, exotic nested value, marker-less or malformed surface event, metadata on a non-surface event, or retained seed reference therefore cannot silently change the reconstructed history. Broader turn-enclosure checks stay in `dsh-invariants` and persistence repair. Ordinary live-session forks use `ctx.sessions.fork(source, boundary?, childSessionId?)`, where `boundary` is the inclusive source event seq to fork through.
|
||||
- Replay/fork: `create(id, { seed })` validates and freezes a contiguous log and rebuilds its surface. `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage.
|
||||
- Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -34,67 +34,41 @@ declare module 'cordis' {
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* A session was created in the store. A synchronous listener throw vetoes
|
||||
* publication and rollback emits the matching `session/disposed` edge;
|
||||
* returned-promise rejection is observed and logged but cannot retroactively
|
||||
* veto this synchronous boundary. A synchronous listener that requests the
|
||||
* advanced detach does not remove the entry immediately: removal and the
|
||||
* paired `session/disposed` edge wait until the creation dispatch unwinds.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
|
||||
* session's owner scope, captured when the session was ENTERED (an agent's
|
||||
* session is entered through `agent.ctx`, so its events dispatch in that
|
||||
* agent's scope; a bare `sessions.create()` from a plain plugin dispatches
|
||||
* subject-less). A listener registered through `agent.ctx` hears only that
|
||||
* agent's sessions; a plain plugin listener hears every session.
|
||||
* Creation announcement during session publication. A synchronous throw vetoes and rolls
|
||||
* back with a paired disposal; detach requested during dispatch is deferred.
|
||||
* A returned-promise rejection is logged but cannot retroactively veto this
|
||||
* synchronous boundary.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners
|
||||
* receive only sessions entered through that agent's context.
|
||||
* @param session - the session just entered and announced.
|
||||
* @mode emit
|
||||
*/
|
||||
'session/created'(this: Scoped<Session>, session: Session): void
|
||||
/**
|
||||
* A previously announced session left the store. Emitted exactly once on
|
||||
* normal detach or publication rollback, and never for a prepared/entered
|
||||
* session whose `session/created` announcement did not begin. Listener
|
||||
* failures (including returned-promise rejections) are logged and contained
|
||||
* per listener so teardown always reaches quiescence.
|
||||
* Scope-filtered dispatch uses the same owner carrier captured at entry;
|
||||
* agent-scoped listeners hear only their own session's teardown.
|
||||
* Emitted once when an announced session leaves the store, including
|
||||
* publication rollback, but never for an entry whose creation announcement
|
||||
* did not begin. Listener failures are logged and contained.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope.
|
||||
* @param session - the session that is no longer live in the store.
|
||||
* @mode emit
|
||||
*/
|
||||
'session/disposed'(this: Scoped<Session>, session: Session): void
|
||||
/**
|
||||
* An event was appended to a session log (sync, fire-and-forget). This is
|
||||
* the per-append feed a UI or invariant plugin tails. The log push is the
|
||||
* commit point; synchronous throws and returned-promise rejections from
|
||||
* observers are logged and contained per listener, so they cannot make a
|
||||
* committed append appear to fail or starve later listeners. The exact
|
||||
* callback list and Cordis internal-dispatch checks resolve before the push;
|
||||
* callbacks themselves run only after it.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
|
||||
* session's owner scope, captured when the session was ENTERED (an agent's
|
||||
* session is entered through `agent.ctx`, so its events dispatch in that
|
||||
* agent's scope; a bare `sessions.create()` from a plain plugin dispatches
|
||||
* subject-less). A listener registered through `agent.ctx` hears only that
|
||||
* agent's sessions; a plain plugin listener hears every session.
|
||||
* Post-commit, fire-and-forget append feed. The listener snapshot resolves
|
||||
* before the log push, but callbacks run after it; observer failures are
|
||||
* logged and contained without making the committed append fail.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners
|
||||
* receive only events from sessions entered through that agent's context.
|
||||
* @param session - the session whose log grew.
|
||||
* @param event - the appended event, exactly as recorded.
|
||||
* @mode emit
|
||||
*/
|
||||
'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
|
||||
/**
|
||||
* Awaited durability checkpoint. The agent loop awaits
|
||||
* `ctx.sessions.flush(session)` at every turn end; persistence
|
||||
* plugins (JSONL, SQLite) drain their write-behind buffers here and on
|
||||
* fiber dispose. Awaited (parallel), not a waterfall: every listener runs
|
||||
* and the caller waits for all of them, but none can veto. Dispatch it
|
||||
* through {@link SessionStore.flush} — the store owns the carrier — never
|
||||
* via a raw `ctx.parallel`.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
|
||||
* session's owner scope, captured when the session was ENTERED (an agent's
|
||||
* session is entered through `agent.ctx`, so its events dispatch in that
|
||||
* agent's scope; a bare `sessions.create()` from a plain plugin dispatches
|
||||
* subject-less). A listener registered through `agent.ctx` hears only that
|
||||
* agent's sessions; a plain plugin listener hears every session.
|
||||
* Awaited parallel durability checkpoint: every listener runs and the
|
||||
* caller awaits all of them, with no waterfall veto. Dispatch through
|
||||
* {@link SessionStore.flush}. Scope-filtered dispatch
|
||||
* (`@deepseek-ai/dsh-scope`) reuses the session's owner scope.
|
||||
* @param session - the session whose buffered events must reach durable storage.
|
||||
* @mode parallel
|
||||
*/
|
||||
@@ -103,13 +77,9 @@ declare module 'cordis' {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a `context/message` or `steering/message` event as a tagged
|
||||
* synthetic user-role message (the system-reminder pattern: zero adapter
|
||||
* burden, models distinguish it from real user prompts by the envelope).
|
||||
*
|
||||
* Live-adapter review has validated the tagged-envelope rendering against
|
||||
* current DeepSeek behavior; provider-specific mismatches belong in that
|
||||
* adapter, not in the canonical session vocabulary.
|
||||
* Render injected context as tagged synthetic user-role content, keeping the
|
||||
* canonical session vocabulary provider-neutral. Adapter-specific exceptions
|
||||
* belong in the adapter.
|
||||
*/
|
||||
function renderTagged(tag: string, content: ContentBlock[], source: MessageSource): ContentBlock[] {
|
||||
const open = `<${tag} source=${JSON.stringify(source.kind)}>`
|
||||
|
||||
@@ -1,17 +1,4 @@
|
||||
/**
|
||||
* Lossless-JSON validation and snapshot materialization for session data.
|
||||
*
|
||||
* The session event log is the durable source of truth (the event-sourcing / session-persistence RFCs): every
|
||||
* `event.data` must round-trip losslessly through JSON so any persistence
|
||||
* backend can store and reload it byte-identically. This invariant belongs to
|
||||
* the log itself — `Session.append` enforces it at the source, so a
|
||||
* non-serializable event never enters `session.events` and the live log can
|
||||
* never diverge from what a backend can persist. Other public boundaries use
|
||||
* {@link snapshotJsonValue} when they must validate and detach in one pass;
|
||||
* {@link isJsonValue} remains the non-copying structural predicate.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session/json
|
||||
*/
|
||||
/** Lossless-JSON validation and detached snapshots for durable session data. @module @deepseek-ai/dsh-session/json */
|
||||
|
||||
/**
|
||||
* A value that round-trips losslessly through JSON: `null`, a boolean, a finite
|
||||
@@ -25,19 +12,10 @@
|
||||
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
||||
|
||||
/**
|
||||
* Materialize one detached lossless-JSON snapshot in a SINGLE recursive pass.
|
||||
* Each array slot or own enumerable string-keyed object value is read exactly
|
||||
* once, validated, and copied immediately. This is intentionally not
|
||||
* `isJsonValue(value)` followed by `structuredClone(value)`: a stateful getter
|
||||
* could return plain JSON to the check and an exotic class instance to the
|
||||
* clone, whose prototype `structuredClone` would erase before a later check.
|
||||
*
|
||||
* Accepts the same scalar/object vocabulary as {@link isJsonValue}: arrays use
|
||||
* the ordinary `Array.prototype` (subclass instances are not plain JSON
|
||||
* containers), while null-prototype objects are accepted and normalized to
|
||||
* ordinary plain objects. Sparse arrays, cycles, negative zero, non-finite
|
||||
* numbers, unsupported scalar types, and exotic object or array shells return
|
||||
* `undefined`. A throwing getter is a caller failure and propagates unchanged.
|
||||
* Validate and detach lossless JSON in one read per property, so a stateful
|
||||
* getter cannot change between validation and copying. Accepts ordinary arrays,
|
||||
* plain or null-prototype objects, and JSON scalars; rejects sparse, cyclic,
|
||||
* exotic, negative-zero, and non-finite values. Getter throws propagate.
|
||||
*
|
||||
* @param value - the candidate value to validate and detach.
|
||||
* @returns the detached snapshot, or `undefined` when the value is not
|
||||
@@ -104,28 +82,12 @@ export function snapshotJsonValue<T>(value: T): T | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `value` is losslessly JSON-serializable: only `null`, finite numbers
|
||||
* other than negative zero, booleans, strings, plain arrays, and plain objects
|
||||
* of such values. Rejects `BigInt`, function, symbol, `undefined`, `-0` (which
|
||||
* JSON rewrites to `0`), non-finite numbers (`NaN`/`Infinity`, which JSON turns
|
||||
* into `null`), and exotic objects (`Map`/`Set`/`Date`/class instances) —
|
||||
* anything `JSON.stringify` would drop, throw on, or convert lossily. Sparse
|
||||
* arrays are rejected too: a hole serializes to `null`, so `[1, , 3]` would not
|
||||
* round-trip. Detects circular references (which would throw) and reports them
|
||||
* as non-serializable rather than propagating the throw.
|
||||
*
|
||||
* Scope — this is a structural plain-data predicate, not an invocation of
|
||||
* `JSON.stringify`: only an object's OWN ENUMERABLE STRING-keyed properties are
|
||||
* inspected (`Object.values`). Symbol-keyed and non-enumerable properties are
|
||||
* omitted from the durable data surface. Custom `toJSON` behavior is not
|
||||
* executed; boundaries that persist a value first materialize a new plain-data
|
||||
* record with {@link snapshotJsonValue}. Getters are invoked during this check,
|
||||
* so callers that need a stable detached value use that one-pass materializer
|
||||
* instead of checking and then rereading a side-effecting record.
|
||||
* Test the same lossless JSON boundary as {@link snapshotJsonValue} without
|
||||
* detaching it. Only own enumerable string properties participate; `toJSON`
|
||||
* is ignored and getters run, so persistence boundaries use the snapshotter.
|
||||
* @param value - the candidate event data to test.
|
||||
* @param seen - objects on the current descent path, for circular-reference
|
||||
* detection; the recursion threads it — callers omit it.
|
||||
* @returns true when `value` survives a JSON round-trip losslessly.
|
||||
* @param seen - current recursion path; callers omit it.
|
||||
* @returns whether `value` survives JSON round-trip losslessly.
|
||||
*/
|
||||
export function isJsonValue(value: unknown, seen: Set<object> = new Set()): boolean {
|
||||
if (value === null) return true
|
||||
|
||||
@@ -1,37 +1,7 @@
|
||||
/**
|
||||
* Crash-recovery repair for an interrupted session log.
|
||||
*
|
||||
* A persistence backend flushes only at `turn/end`, so a crash can leave a
|
||||
* durable log whose final turn never closed: real, fully-written events sit
|
||||
* after the last `turn/end` with no closing boundary. A single turn can be huge
|
||||
* in a long-horizon task (many steps, large tool output), so those events MUST
|
||||
* be preserved — truncating the turn would silently destroy real work. Instead,
|
||||
* on reload the backend CLOSES the orphaned turn by appending the minimal
|
||||
* synthetic boundary events:
|
||||
*
|
||||
* 1. an error `tool/result` for every `tool-call` in the interrupted turn that
|
||||
* never got its matching `tool/result` (so the rehydrated history is a
|
||||
* VALID provider transcript — see below),
|
||||
* 2. a `step/end` if a step was still open, then
|
||||
* 3. a `turn/end` carrying the merge-extensible `{ kind: 'interrupted' }` reason.
|
||||
*
|
||||
* The marker records that the turn was cut short by a crash, not completed by
|
||||
* the model. See the session-persistence RFC.
|
||||
*
|
||||
* Why the synthetic tool results matter: `deriveMessages()` renders the
|
||||
* `tool-call` blocks inside a durable `assistant/message` but only emits a
|
||||
* matching tool-result when a `tool/result` EVENT exists. A crash between the
|
||||
* assistant message and its tool results (the loop runs the tools AFTER logging
|
||||
* the assistant message, so a process killed mid-tool leaves the calls without
|
||||
* results) would otherwise reload a history with a dangling assistant tool-call
|
||||
* — which every provider rejects as an invalid transcript on the next request.
|
||||
* Synthesizing an error result per orphaned call keeps resume safe.
|
||||
*
|
||||
* This module computes those synthetic closers from an event list; backends
|
||||
* return them inline from `load` (so the reconstructed session is balanced and
|
||||
* immediately usable) and persist them during that mutating load before any
|
||||
* later append continues the log.
|
||||
*
|
||||
* Crash-recovery repair for an interrupted session log. It preserves a fully
|
||||
* written final turn and supplies the missing tool, step, and turn boundaries
|
||||
* needed to resume with a provider-valid transcript.
|
||||
* @module @deepseek-ai/dsh-session/repair
|
||||
*/
|
||||
|
||||
@@ -39,36 +9,19 @@ import type { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from './types.ts'
|
||||
|
||||
/**
|
||||
* Scan `events` for an open turn/step at the tail and return the synthetic
|
||||
* boundary events that close them, with `seq` continuing the log and `time`
|
||||
* copied from the last real event (the closers stand in for the crash moment;
|
||||
* reusing the last timestamp keeps them deterministic and never invents a
|
||||
* "future" time). Returns an empty array when the log is already balanced
|
||||
* (ends on a `turn/end`, or is empty) — the common, non-crash case.
|
||||
* Return deterministic synthetic events that close an open tail turn. Unmatched
|
||||
* calls receive error results first, followed by an open `step/end` and an
|
||||
* interrupted `turn/end`; sequences continue the log and timestamps reuse the
|
||||
* last real event. A balanced or empty log returns no events.
|
||||
*
|
||||
* The closers, in order: an error `tool/result` for each unmatched `tool-call`
|
||||
* in the interrupted turn, then a `step/end` if a step is open, then the
|
||||
* `turn/end {interrupted}`. The tool-results come first so a step that issued
|
||||
* tool calls is balanced (every call has a result) before its `step/end`.
|
||||
*
|
||||
* Only the LAST turn can be open: the invariants plugin guarantees a `turn/end`
|
||||
* before any later `turn/start`, so an interior open turn is impossible in a
|
||||
* valid committed log. Likewise at most one step is open within that turn.
|
||||
* @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
|
||||
* @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
|
||||
*/
|
||||
export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] {
|
||||
let openTurn: number | null = null
|
||||
let openStep: number | null = null
|
||||
// Track tool calls vs. their results WITHIN the currently-open turn only: a
|
||||
// call is "pending" until its matching tool/result arrives. Reset at every
|
||||
// turn boundary so a committed earlier turn (already balanced) never leaks a
|
||||
// phantom pending call into the interrupted-turn repair.
|
||||
// Track pending tool calls with their callSeq (the seq of the `tool/call`
|
||||
// event, captured for surface sourceEventSeqs provenance on the synthetic
|
||||
// result). CallSeq is set from `tool/call` events; the assistant/message
|
||||
// block scan may register a call first (it appears earlier in the log), and
|
||||
// the later `tool/call` event fills in the seq.
|
||||
// Reset at each turn boundary so earlier calls cannot leak into tail repair.
|
||||
// Assistant blocks register calls; later tool/call events add provenance seqs.
|
||||
const pendingCalls = new Map<CallId, { step: number; callSeq?: number }>()
|
||||
for (const event of events) {
|
||||
switch (event.type) {
|
||||
@@ -97,10 +50,7 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
|
||||
}
|
||||
break
|
||||
case 'tool/call':
|
||||
// Capture the tool/call event seq for surface provenance on the
|
||||
// synthesized tool/result. The entry may already exist (registered by
|
||||
// the assistant/message above) or may be new (if the assistant/message
|
||||
// came from a prior step that was already closed).
|
||||
// Add the tool/call seq used as provenance on a synthetic result.
|
||||
{
|
||||
const entry = pendingCalls.get(event.data.callId)
|
||||
if (entry) {
|
||||
@@ -129,10 +79,8 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
|
||||
const time = last.time
|
||||
const closers: SessionEvent[] = []
|
||||
|
||||
// Synthesize an error tool/result for each tool-call left unanswered by the
|
||||
// crash, so deriveMessages() yields a valid provider transcript on resume (a
|
||||
// dangling assistant tool-call is rejected by every provider). Insertion
|
||||
// order follows the Map (insertion = log order of the assistant messages).
|
||||
// Close calls before their step: providers reject dangling assistant calls,
|
||||
// and Map insertion order preserves their transcript order.
|
||||
for (const [callId, { step, callSeq }] of pendingCalls) {
|
||||
closers.push({
|
||||
type: 'tool/result',
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
/**
|
||||
* Request-header reconstruction utilities: the pure fold/diff/apply trio over
|
||||
* the `request/header` / `request/header-delta` session events. Anyone
|
||||
* holding a session log reconstructs the {@link EpochHeader} any request was
|
||||
* built under by folding these events in log order; the loop uses the same
|
||||
* functions to decide whether a step's header changed and to encode the
|
||||
* change. Deltas are an encoding optimization with a safety valve — the
|
||||
* writer round-trip-verifies every delta before appending and falls back to
|
||||
* a full snapshot when the encoding cannot express the change — so folding
|
||||
* never needs error recovery on a well-formed log.
|
||||
*
|
||||
* Request-header reconstruction utilities over `request/header` snapshots and
|
||||
* `request/header-delta` events. Writers round-trip each proposed delta and use
|
||||
* a full snapshot when the encoding cannot represent the change.
|
||||
* @module dsh-session/request-header
|
||||
*/
|
||||
|
||||
@@ -114,13 +107,10 @@ function applyTools(prev: readonly ToolSchema[], delta: ToolsDelta): ToolSchema[
|
||||
}
|
||||
|
||||
/**
|
||||
* Field-wise equality over canonical headers — the cheap comparison the
|
||||
* writer's round-trip guard runs (`applyHeaderDelta(prev, delta)` must equal
|
||||
* the intended header) and the loop runs to skip logging an unchanged header.
|
||||
* Tools compare per-schema IN ORDER (canonical JSON), so a pure reordering is
|
||||
* correctly unequal; the session prefix compares as canonical JSON (both
|
||||
* sides come from the same build path, so key order matches when the values
|
||||
* do).
|
||||
* Field-wise equality over canonical headers — the cheap comparison the writer's round-trip
|
||||
* guard runs (`applyHeaderDelta(prev, delta)` must equal the intended header) and the loop
|
||||
* runs to skip logging an unchanged header.
|
||||
*
|
||||
* @param a - one canonical header.
|
||||
* @param b - the other.
|
||||
* @returns whether config, system, tools (in order), and the session prefix all match.
|
||||
@@ -139,13 +129,12 @@ function sameMessages(a: readonly Message[] | undefined, b: readonly Message[] |
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the `request/header-delta` payload between two canonical headers,
|
||||
* or undefined when they are equal. The caller MUST round-trip the result
|
||||
* ({@link applyHeaderDelta} on `prev` deep-equals `next`) before logging it —
|
||||
* the encoding cannot express every change (a pure tool reordering) — and
|
||||
* fall back to a full `request/header` snapshot when the check fails.
|
||||
* The session prefix is replaced whole (small advisory content, not worth
|
||||
* diffing); an empty replacement array encodes the transition to "none".
|
||||
* Compute the `request/header-delta` payload between two canonical headers, or
|
||||
* `undefined` when they are equal. The encoding cannot represent every change,
|
||||
* including pure tool reordering, so callers must apply and compare the result
|
||||
* before logging it and fall back to a full snapshot on mismatch. The session
|
||||
* prefix is replaced whole; an empty array removes it.
|
||||
*
|
||||
* @param prev - the folded header the log currently implies.
|
||||
* @param next - the header the next request will actually use.
|
||||
* @returns the delta payload, or undefined when nothing changed.
|
||||
@@ -182,15 +171,13 @@ export function applyHeaderDelta(prev: EpochHeader, delta: HeaderDelta): EpochHe
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the header events of a log (or any prefix of one) into the
|
||||
* {@link EpochHeader} in force after the last of them: each
|
||||
* `request/header` snapshot replaces the state, each `request/header-delta`
|
||||
* amends it. The pure, offline form of reconstruction — external tooling and
|
||||
* the dev invariant both use it; the live session tracks the same fold
|
||||
* incrementally.
|
||||
* Fold the header events of a log (or any prefix of one) into the {@link EpochHeader} in
|
||||
* force after the last of them: each `request/header` snapshot replaces the state, each
|
||||
* `request/header-delta` amends it.
|
||||
*
|
||||
* @param events - session events in log order (non-header events are skipped).
|
||||
* @param from - a previously folded state to continue from (the live session's
|
||||
* incremental cursor); omit to fold from nothing.
|
||||
* @param from - a previously folded state to continue from (the live session's incremental
|
||||
* cursor); omit to fold from nothing.
|
||||
* @returns the folded header, or undefined when no header event exists yet.
|
||||
*/
|
||||
export function foldRequestHeader(events: readonly SessionEvent[], from?: EpochHeader): EpochHeader | undefined {
|
||||
|
||||
@@ -23,12 +23,9 @@ const SURFACE_EVENT_TYPES = new Set<string>([
|
||||
])
|
||||
|
||||
/**
|
||||
* Whether an event's `type` is surface-eligible (one of the five
|
||||
* message-producing {@link SurfaceEventType} values). This is the TYPE check
|
||||
* only — it does NOT require `surfaceOp` to be present. Use it to detect a
|
||||
* surface-eligible event that is MISSING its mandatory marker (e.g. validating
|
||||
* a seed/load log); use {@link isSurfaceEvent} to narrow to a fully-formed
|
||||
* {@link SurfaceEvent} with `surfaceOp` present.
|
||||
* Check only whether a type may enter the message surface; it does not require `surfaceOp`. This
|
||||
* detects eligible seed/load events missing their mandatory marker. Use {@link isSurfaceEvent} to
|
||||
* narrow a fully formed event whose marker is present.
|
||||
* @param type - the event type string to test.
|
||||
* @returns true when the type is one of the five message-producing types.
|
||||
*/
|
||||
|
||||
@@ -1,36 +1,7 @@
|
||||
/**
|
||||
* Tool-pairing balance over a session's SURFACE: is a given cut point in the
|
||||
* surface a safe edge for a collapsed region (e.g. compaction)?
|
||||
*
|
||||
* The invariant a consumer needs: a collapsed region must never separate an
|
||||
* `assistant/message`'s `tool-call` blocks from their answering `tool/result`s
|
||||
* — that would leave the rehydrated transcript with a dangling tool-call or an
|
||||
* orphaned tool-result, which every provider rejects. (This is the
|
||||
* compaction-time mirror of the crash-recovery imbalance that
|
||||
* {@link interruptedTurnClosers} repairs on load.) Steps were once used as a
|
||||
* proxy for this bracketing, but a compaction REWRITES the surface — it lands a
|
||||
* replacement node at a high log seq whose SURFACE position is the head — so a
|
||||
* scan over the LOG's `step/*` markers mis-reads such a node's neighbours. The
|
||||
* pairing the invariant actually protects lives in the surface nodes' own
|
||||
* content (a `tool-call` block's id, a `tool/result`'s `callId`), which travels
|
||||
* with the node through any reshaping, so alignment is decided over the surface
|
||||
* directly.
|
||||
*
|
||||
* A **cut** is a gap between two adjacent surface nodes (named by the node it
|
||||
* sits immediately before), or the after-tail gap (`null`). Walking the surface
|
||||
* head→tail and assigning each node a delta — `+1` per `tool-call` block on an
|
||||
* `assistant/message`, `-1` per `tool/result`, `0` otherwise — the depth at a
|
||||
* cut is the number of still-unanswered tool calls before it. A cut is
|
||||
* **balanced** when that depth is `0`. A region `[start..end]` is safe to
|
||||
* collapse iff BOTH its edges are balanced cuts: the cut before `start` and the
|
||||
* cut after `end`. Nodes that belong to no step (a pre-step `user/message`, an
|
||||
* inter-step `steering/message`, an injection `context/message`) carry no
|
||||
* pairing, contribute `0`, and so are free boundaries — exactly as before, but
|
||||
* now as a consequence of the balance rather than a special case. An open
|
||||
* trailing step (an assistant whose `tool/result`s have not landed yet) keeps
|
||||
* the depth positive through the tail, so no cut inside it is balanced — the
|
||||
* old explicit open-step check falls out of the same counter.
|
||||
*
|
||||
* Tool-pairing balance over a session surface. Compaction changes surface
|
||||
* positions, so safe cuts are derived from tool-call/result content on the
|
||||
* surface rather than step markers in the append-only log.
|
||||
* @module @deepseek-ai/dsh-session/tool-pairing
|
||||
*/
|
||||
|
||||
@@ -57,33 +28,14 @@ function nodeDelta(event: SessionEvent): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the surface prefix ending at the given cut has BALANCED tool-call /
|
||||
* tool-result brackets — i.e. every `tool-call` block on the surface before the
|
||||
* cut has its answering `tool/result` before the cut too, so the cut is a safe
|
||||
* edge for a collapsed region (it cannot split an assistant↔result pair).
|
||||
*
|
||||
* `nodes` is the surface linked list in head→tail order (e.g.
|
||||
* `session.surface.nodes`); `events` is the session log, used to look each
|
||||
* node's event up by `seq`. `beforeSeq` names the cut by the surface node it
|
||||
* sits immediately before; the after-tail cut (the whole surface) is `null`,
|
||||
* as is any `beforeSeq` not present on the surface.
|
||||
*
|
||||
* A region `[start..end]` is collapsible iff both edges are balanced cuts: call
|
||||
* `isToolPairingBalanced(nodes, events, start)` for the cut before `start`, and
|
||||
* `isToolPairingBalanced(nodes, events, after)` — where `after` is `end`'s
|
||||
* surface successor (`SurfaceNode.next`), or `null` when `end` is the tail —
|
||||
* for the cut after `end`.
|
||||
*
|
||||
* Check that a surface cut does not split a tool call from its result. A region
|
||||
* is safe to collapse only when the cuts before its first node and after its
|
||||
* last node both return `true`.
|
||||
* @param nodes - the surface linked list in head→tail order.
|
||||
* @param events - the session log each node's `seq` indexes into.
|
||||
* @param beforeSeq - names the cut (the node it sits immediately before);
|
||||
* `null` — or any seq not on the surface — means the after-tail cut.
|
||||
* @returns true when every `tool-call` before the cut is answered before it
|
||||
* (the unanswered-call depth at the cut is zero).
|
||||
* @throws if the surface prefix drives the unanswered-call depth negative — a
|
||||
* `tool/result` with no preceding open `tool-call` on the surface. That is a
|
||||
* corrupt surface (a structural invariant violation), surfaced loudly here
|
||||
* rather than silently mis-classifying a boundary.
|
||||
* @param beforeSeq - node immediately after the cut; `null` or a seq absent from the surface means after-tail.
|
||||
* @returns whether every call before the cut has its result before the cut.
|
||||
* @throws if a result appears without a preceding open call.
|
||||
*/
|
||||
export function isToolPairingBalanced(
|
||||
nodes: readonly SurfaceNode[],
|
||||
@@ -93,14 +45,12 @@ export function isToolPairingBalanced(
|
||||
let depth = 0
|
||||
for (const node of nodes) {
|
||||
if (node.seq === beforeSeq) return depth === 0
|
||||
// node.seq is a surface-node seq, always a valid log index by construction.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
depth += nodeDelta(events[node.seq]!)
|
||||
if (depth < 0) {
|
||||
throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`)
|
||||
}
|
||||
}
|
||||
// Reached the after-tail cut (beforeSeq === null, or a seq not on the
|
||||
// surface): the whole-surface prefix is balanced iff depth returned to 0.
|
||||
// A missing cut node means the after-tail boundary.
|
||||
return depth === 0
|
||||
}
|
||||
|
||||
@@ -14,33 +14,17 @@ export function SessionId(id: string): SessionId {
|
||||
}
|
||||
|
||||
/**
|
||||
* The on-disk session format version, stamped into every newly-written
|
||||
* {@link SessionHeader} and enforced by every persistence backend on load. The
|
||||
* single source of truth for the version — write sites and the load-time check
|
||||
* all read it.
|
||||
*
|
||||
* It is **`0`** deliberately: while the harness is unreleased the on-disk format
|
||||
* is **unstable / pre-release, with no compatibility implied**. Breaking changes
|
||||
* to the persisted {@link SessionEventMap} shape (folding fields onto an event,
|
||||
* removing a variant, …) happen freely and do NOT bump this — v0 absorbs all
|
||||
* pre-release churn, and a backend simply REJECTS any log not at v0 (there is no
|
||||
* migration; no persisted user data exists to preserve). A real, monotonically
|
||||
* bumped version policy begins at the first tagged release, when a specific
|
||||
* format boundary becomes worth distinguishing.
|
||||
* The on-disk session format version, stamped into every newly-written {@link SessionHeader}
|
||||
* and enforced by every persistence backend on load. The single source of truth for the
|
||||
* version — write sites and the load-time check all read it.
|
||||
* While the harness is unreleased it is pinned at `0`: no compatibility is
|
||||
* implied, incompatible logs are rejected, and no migration is provided. A
|
||||
* monotonic version policy starts with the first tagged release.
|
||||
*/
|
||||
export const SESSION_FORMAT_VERSION = 0
|
||||
|
||||
/**
|
||||
* Immutable session metadata — written once at creation and never rewritten.
|
||||
* {@link Session} enforces that contract at runtime: it validates and detaches
|
||||
* the accepted scalar fields, requires this header's id to match the session
|
||||
* id, and deep-freezes the published record.
|
||||
*
|
||||
* Kept SEPARATE from the event log deliberately: format-version, cwd, and
|
||||
* lineage are storage concerns, not conversation events, so they stay out of
|
||||
* {@link SessionEventMap} and never reach `deriveMessages()`. Every reference
|
||||
* system (pi's `version: 3` header, Codex's `SessionMeta`, Claude Code's tail
|
||||
* metadata) writes such a header.
|
||||
* Immutable validated storage metadata, kept outside the conversation event log.
|
||||
*/
|
||||
export interface SessionHeader {
|
||||
/**
|
||||
@@ -58,13 +42,8 @@ export interface SessionHeader {
|
||||
/** The session this one was forked from (seed lineage), if any. */
|
||||
readonly parentSession?: SessionId
|
||||
/**
|
||||
* How many leading events were INHERITED via a seed rather than produced by
|
||||
* this session — the seed boundary. Set when a fork seeds a child with a
|
||||
* prefix of the parent's log (= the seeded prefix length); absent/0 means the
|
||||
* session produced all its own events. Persisted so a reload reconstructs the
|
||||
* boundary instead of re-deriving it from the full stored log, and so a replay
|
||||
* harness can skip the inherited prefix when deriving the child's OWN script
|
||||
* (the seeded events are the parent's, not this child's model calls).
|
||||
* How many leading events were inherited through a seed. Persisting this
|
||||
* boundary lets resume and replay distinguish parent history from child work.
|
||||
*/
|
||||
readonly seedLength?: number
|
||||
}
|
||||
@@ -78,17 +57,8 @@ export interface CreateSessionOptions {
|
||||
/** Events to seed the new session with (replay/fork). */
|
||||
readonly seed?: readonly SessionEvent[]
|
||||
/**
|
||||
* Creation metadata. The store reads this plain record and each accepted
|
||||
* field once, then fills in `version`/`id` and defaults
|
||||
* `createdAt` to now; the caller supplies the storage-level fields (validated
|
||||
* absolute `cwd`, `parentSession` lineage, the seed boundary `seedLength`, and
|
||||
* — when reconstructing a persisted session — the original `createdAt` to
|
||||
* preserve it).
|
||||
*
|
||||
* `seedLength` is EXPLICIT, not inferred from `seed.length`: a reconstruction
|
||||
* (resume/load) seeds the WHOLE stored log, so its `seed.length` is the full
|
||||
* length, not the original boundary — the caller must pass the persisted
|
||||
* boundary back. A fresh fork passes its actual seeded-prefix length.
|
||||
* Storage metadata read once before publication. `seedLength` is explicit
|
||||
* because a resumed seed contains the full stored log, not only its inherited prefix.
|
||||
*/
|
||||
readonly meta?: {
|
||||
readonly cwd?: string
|
||||
@@ -119,21 +89,7 @@ export interface TurnTriggerMap {
|
||||
export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
|
||||
|
||||
/**
|
||||
* Why a turn ended.
|
||||
* Merge-extensible sum type.
|
||||
*
|
||||
* `max-tokens` mirrors the model-call `FinishReasonMap` variant (DeepSeek's
|
||||
* `length`): the turn ended because a step hit the output-token ceiling, not
|
||||
* because the model chose to stop. The agent-loop surfaces it via the rule
|
||||
* "any `max-tokens` step in the turn makes the turn end `max-tokens`" (a
|
||||
* continuation plugin can run further steps after one, but the cut-short fact
|
||||
* still wins). It is distinct from `completed` so a consumer (e.g. the ACP
|
||||
* bridge mapping to `StopReason: 'max_tokens'`) can tell a clean stop from a
|
||||
* truncated one. The next variants to add — when an adapter/loop first emits
|
||||
* them — are `refusal` and `max_turn_requests` (both named by the ACP RFC as ACP
|
||||
* stop reasons); no current adapter produces a `refusal` finish (unknown
|
||||
* DeepSeek finish reasons collapse to `error`), so it is deliberately omitted
|
||||
* until one does.
|
||||
* Why a turn ended. Merge-extensible sum type.
|
||||
*/
|
||||
export interface TurnEndReasonMap {
|
||||
completed: { kind: 'completed' }
|
||||
@@ -146,26 +102,16 @@ export interface TurnEndReasonMap {
|
||||
*/
|
||||
error: { kind: 'error'; step: number; message: string; code?: string }
|
||||
disposed: { kind: 'disposed' }
|
||||
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
|
||||
'max-tokens': { kind: 'max-tokens' }
|
||||
/**
|
||||
* The turn's entire prompt batch was BLOCKED before any step ran — every
|
||||
* drained queued message was vetoed by an `agent/prompt-submit` listener (a
|
||||
* hook). The turn still opened (so the boundary stays balanced and the block
|
||||
* is a durable in-turn fact), but ran zero steps. `reason` carries the block
|
||||
* message from the vetoing decision. Distinct from `aborted` (a user-driven
|
||||
* cancel) and `error` (a failure): the prompt was rejected by policy, not
|
||||
* interrupted or broken. A UI renders it as "prompt blocked by hook".
|
||||
* Policy blocked every prompt before the first step. The zero-step turn still
|
||||
* records a balanced durable boundary and the veto reason.
|
||||
*/
|
||||
rejected: { kind: 'rejected'; reason: string }
|
||||
/**
|
||||
* The turn never ended on its own: the process crashed mid-turn and a
|
||||
* persistence backend later closed the orphaned (open) turn on reload so the
|
||||
* log stays balanced. SYNTHESIZED by the backend's crash-recovery repair — no
|
||||
* loop ever emits this. Its events are real (they were durably appended before
|
||||
* the crash) and are PRESERVED, not discarded: a single turn can be huge in a
|
||||
* long-horizon task (many steps, large tool output), so truncating it would
|
||||
* lose real work. The marker records that the turn was cut short, not that the
|
||||
* model completed it. See the session-persistence RFC.
|
||||
* A persistence backend closed a crash-orphaned turn on reload. The loop never
|
||||
* emits this marker, and the events recorded before the crash remain intact.
|
||||
*/
|
||||
interrupted: { kind: 'interrupted' }
|
||||
}
|
||||
@@ -192,15 +138,9 @@ export interface TodoItem {
|
||||
}
|
||||
|
||||
/**
|
||||
* The request header: everything about an LLM request besides its derived
|
||||
* message history — the call configuration plus the rendered system prompt,
|
||||
* tool schemas, and the session prefix. Logged session state (the
|
||||
* reconstructability RFC): a
|
||||
* {@link SessionEventMap} `request/header` snapshot installs one, a
|
||||
* `request/header-delta` amends it, and folding those events over the log
|
||||
* (`foldRequestHeader`) reconstructs the header any request was built under.
|
||||
* Canonical form: an empty system prompt, an empty tool list, and an empty
|
||||
* prefix are ABSENT fields, matching how requests are built.
|
||||
* Logged request state outside derived history: call config, system prompt,
|
||||
* tools, and session prefix. Header snapshots and deltas reconstruct it;
|
||||
* canonical empty optional fields are absent.
|
||||
*/
|
||||
export interface EpochHeader {
|
||||
/** The conversation's call configuration (model + sampling scalars). */
|
||||
@@ -262,24 +202,10 @@ export interface ToolsDelta {
|
||||
}
|
||||
|
||||
/**
|
||||
* The session event vocabulary — the append-only source of truth for an
|
||||
* agent's whole interaction history. The LLM message history is *derived*
|
||||
* from this log; nothing else is authoritative. Replay = re-derive from the
|
||||
* same events; trace/telemetry = subscribe to the log.
|
||||
*
|
||||
* Merge-extensible: plugins declare extra event types via declaration merging
|
||||
* (e.g. the compaction plugin adds `'compact/start'`, `'compact/summary'`,
|
||||
* `'compact/end'`).
|
||||
*
|
||||
* Durability contract (what a persistence backend relies on): the durable log
|
||||
* persists every event verbatim, INCLUDING `assistant/chunk` — `seq` must stay
|
||||
* contiguous (`seq = log.length`), so chunks cannot be filtered out of the
|
||||
* canonical log. All `event.data` must be JSON-serializable — `Session.append`
|
||||
* (and the seed path in the constructor) enforces this at the source (throwing
|
||||
* on non-serializable data), so a bad event never enters the log and
|
||||
* `session.events` always equals what a backend can persist. Adding a new event
|
||||
* type that carries non-serializable data, or that breaks the turn/step nesting
|
||||
* the invariants plugin checks, is a breaking change to the on-disk format.
|
||||
* The merge-extensible, append-only source of truth for an agent interaction.
|
||||
* Message history is derived from this log. Every event is lossless JSON and
|
||||
* sequence numbers stay contiguous, including raw chunks, so persistence can
|
||||
* store the canonical log verbatim.
|
||||
*/
|
||||
export interface SessionEventMap {
|
||||
/**
|
||||
@@ -302,14 +228,8 @@ export interface SessionEventMap {
|
||||
/** A user-visible prompt (queued message drained at turn start). */
|
||||
'user/message': { content: ContentBlock[]; source: MessageSource }
|
||||
/**
|
||||
* A queued prompt an `agent/prompt-submit` listener VETOED — the durable
|
||||
* record of a blocked prompt and why. Appended in place of the `user/message`
|
||||
* the prompt would have become, so the block survives replay even in a MIXED
|
||||
* batch where another queued prompt is allowed (there the turn does not end
|
||||
* `rejected`, so the boundary reason alone would not preserve it). `content`
|
||||
* is the original prompt the listener rejected; `reason` is the veto text
|
||||
* ({@link PromptDecision} `block.reason`). NOT a {@link SurfaceEventType}: a
|
||||
* blocked prompt produces no LLM message and never reaches `deriveMessages()`.
|
||||
* Durable record of a prompt veto and its reason. It is log-only: the blocked
|
||||
* prompt never enters the model-visible surface, including in a mixed batch.
|
||||
*/
|
||||
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
|
||||
/**
|
||||
@@ -346,47 +266,19 @@ export interface SessionEventMap {
|
||||
/** Steering content injected between steps of a running turn. */
|
||||
'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource }
|
||||
/**
|
||||
* The agent's whole todo list, carried as a full snapshot and replaced
|
||||
* wholesale on each write — the current list is the most recent `todo/write`
|
||||
* (last-write-wins on replay, no fold). Appended by an owning agent via
|
||||
* `session.append('todo/write', { todos })`.
|
||||
*
|
||||
* NOT a {@link SurfaceEventType}: it produces no LLM message and never reaches
|
||||
* `deriveMessages()`, so it carries no `surfaceOp` and stays off the surface —
|
||||
* it is durable, replayable UI state, distinct from the conversation history.
|
||||
* It is a `SessionEventMap` member riding the existing `session/event` emit,
|
||||
* not a first-class Cordis `interface Events` notification, so it has no
|
||||
* cordis-catalog row.
|
||||
* Whole-list snapshot; the latest write wins on replay. It is log-only UI
|
||||
* state and never enters derived model history.
|
||||
*/
|
||||
'todo/write': { todos: TodoItem[] }
|
||||
/**
|
||||
* Full snapshot of the {@link EpochHeader} the NEXT request is built under,
|
||||
* with the {@link RequestHeaderReason} it was recorded whole. Appended by
|
||||
* the loop inside the step, before dispatch, on a loop instance's first
|
||||
* request-building step (`'initial'`/`'resume'`) or when a delta failed its
|
||||
* round-trip guard (`'fallback'`); always records what the request actually
|
||||
* used, post-`agent/request`. Anchors the header fold: reconstruction reads
|
||||
* the latest snapshot and applies the deltas after it. NOT a
|
||||
* {@link SurfaceEventType}: it produces no LLM message — it is the request
|
||||
* envelope, logged so every request is a pure function of the session log
|
||||
* (the reconstructability RFC).
|
||||
* Full {@link EpochHeader} for the next request, appended inside its step
|
||||
* before dispatch. It is log-only and anchors subsequent deltas.
|
||||
*/
|
||||
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
|
||||
/**
|
||||
* Amendment to the folded {@link EpochHeader}: at least one of a
|
||||
* {@link SystemDelta}, a {@link ToolsDelta}, a whole replacement
|
||||
* {@link LlmCallConfig} (four scalars — not worth diffing), or a whole
|
||||
* replacement session prefix (`messagePrefix` — small advisory content,
|
||||
* replaced whole; an EMPTY array encodes the transition to "none",
|
||||
* mirroring the canonical form's absent field — the loop never produces
|
||||
* one in practice: the prefix is composed once per instance and anchored
|
||||
* by that instance's snapshot, so this arm exists for codec totality).
|
||||
* Appended by the
|
||||
* loop inside the step, before dispatch, when the header for this request
|
||||
* differs from the fold of the log so far; the writer verifies
|
||||
* `applyHeaderDelta(previous, delta)` reproduces the new header exactly and
|
||||
* falls back to a `'fallback'` `request/header` snapshot when it cannot, so
|
||||
* a logged delta ALWAYS round-trips. NOT a {@link SurfaceEventType}.
|
||||
* Log-only amendment to the folded {@link EpochHeader}. System and tools use
|
||||
* their delta codecs; config and prefix replace whole, with an empty prefix
|
||||
* encoding removal. Writers verify round-trip equality or log a fallback snapshot.
|
||||
*/
|
||||
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] }
|
||||
}
|
||||
@@ -434,16 +326,8 @@ export type SurfaceOp =
|
||||
| { op: 'replace'; start: number; end: number }
|
||||
|
||||
/**
|
||||
* Surface metadata passed to {@link Session.append}.
|
||||
* `surfaceOp` controls how the event enters the surface linked list;
|
||||
* `sourceEventSeqs` records the seq numbers of events that are provenance
|
||||
* sources of this one (e.g. the `assistant/chunk` seqs behind an
|
||||
* `assistant/message`, or the shadowed nodes behind a compaction replacement).
|
||||
*
|
||||
* Required for {@link SurfaceEventType} events — every message-producing event
|
||||
* MUST declare how it enters the surface, because the surface is the sole
|
||||
* source of derived history. Non-surface event types (`turn/start`,
|
||||
* `assistant/chunk`, `error`, …) cannot carry surface metadata.
|
||||
* Surface placement and provenance for {@link Session.append}. Required on
|
||||
* message-producing events and forbidden on log-only events.
|
||||
*/
|
||||
export interface SurfaceIntent {
|
||||
surfaceOp: SurfaceOp
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Derived-message cache tests: the session projects each surface node exactly
|
||||
* once (O(new nodes) per call), rebuilds on a surface rewrite (replace /
|
||||
* invalidate — the replaceGeneration signal), returns a fresh array snapshot
|
||||
* per call over shared frozen messages, and stays deep-equal to a from-scratch
|
||||
* replay derivation at every step — the incremental==scratch property the
|
||||
* reconstructability RFC's invariant enforces in dev at request time.
|
||||
* Derived-message cache contract against a scratch oracle: project new nodes
|
||||
* once, rebuild on surface generation changes, return fresh arrays over shared
|
||||
* frozen messages, and remain value-equal to replay at every step.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -28,7 +25,6 @@ describe('derived-message cache', () => {
|
||||
userText(session, 'two')
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'reply' }] }, { surfaceOp: 'append' })
|
||||
expect(session.deriveMessages()).toEqual(scratch(session))
|
||||
// An empty-content assistant/message (usage host) projects to nothing.
|
||||
session.append('assistant/message', { turn: 1, step: 2, content: [], usage: { inputTokens: 1, outputTokens: 0 } }, { surfaceOp: 'append' })
|
||||
expect(session.deriveMessages()).toEqual(scratch(session))
|
||||
})
|
||||
@@ -48,7 +44,6 @@ describe('derived-message cache', () => {
|
||||
|
||||
expect(session.deriveMessages()).toHaveLength(1)
|
||||
expect(session.deriveMessages()).toEqual(scratch(session))
|
||||
// The array a caller took before the replace is untouched.
|
||||
expect(beforeReplace).toHaveLength(2)
|
||||
})
|
||||
|
||||
@@ -61,7 +56,7 @@ describe('derived-message cache', () => {
|
||||
const second = session.deriveMessages()
|
||||
expect(first).toHaveLength(1)
|
||||
expect(second).toHaveLength(2)
|
||||
// Shared projection objects: the same frozen message instance, once ever.
|
||||
// Array snapshots share their frozen message projections.
|
||||
expect(second[0]).toBe(first[0])
|
||||
expect(Object.isFrozen(first[0])).toBe(true)
|
||||
})
|
||||
@@ -74,7 +69,6 @@ describe('derived-message cache', () => {
|
||||
session.surface.invalidate()
|
||||
const after = session.deriveMessages()
|
||||
expect(after).toEqual(before)
|
||||
// A rebuild re-projects: fresh objects, same values.
|
||||
expect(after[0]).not.toBe(before[0])
|
||||
})
|
||||
})
|
||||
@@ -84,8 +78,7 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
|
||||
const session = new Session(SessionId('per-event'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
// The fold path (deriveMessages) and the per-event path share the
|
||||
// projection, so an external reconstructor cannot disagree with the cache.
|
||||
// Full and per-event derivation share one projection.
|
||||
expect(session.deriveEventMessage(event)).toEqual(session.deriveMessages().at(-1))
|
||||
})
|
||||
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
/**
|
||||
* Negative-path tests for the persistence log catalog generator
|
||||
* (`scripts/gen-persistence-catalog.ts`).
|
||||
*
|
||||
* The generated catalog is frozen by a regenerate-and-diff freshness gate, so
|
||||
* the freshness half is exercised by `pnpm run verify-persistence-catalog` in
|
||||
* CI. What a freshness diff CANNOT prove is that the generator REJECTS
|
||||
* malformed source the way it promises to — a member without description
|
||||
* prose, a forbidden `@mode` tag, a non-literal member name, a duplicate event
|
||||
* declaration, a missing or ambiguous `SurfaceEventType` union, a stale union
|
||||
* member. These tests drive the exported collectors against synthetic fixture
|
||||
* packages to prove each guard fires (and that well-formed declarations pass),
|
||||
* mirroring the gen-cordis-catalog negative tests.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
|
||||
@@ -13,10 +13,8 @@ import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEventMap, SessionEventType, SurfaceIntent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
// An appendable event: its type/data plus, for surface-eligible types, the
|
||||
// explicit surface intent the generator declares (mirroring how a real caller
|
||||
// passes it). The intent is part of the generated fixture, NOT synthesized by
|
||||
// `build`, so each arbitrary states the marker it produces.
|
||||
// Each arbitrary supplies its own surface intent; `build` must not synthesize
|
||||
// one or the property would fail to exercise malformed fixture choices.
|
||||
type Appendable = {
|
||||
[T in SessionEventType]: { type: T; data: SessionEventMap[T]; intent?: SurfaceIntent }
|
||||
}[SessionEventType]
|
||||
|
||||
@@ -155,11 +155,8 @@ describe('interruptedTurnClosers', () => {
|
||||
})
|
||||
|
||||
it('handles tool/call without a matching assistant/message entry gracefully', () => {
|
||||
// A tool/call event exists in the log but no assistant/message registered
|
||||
// the callId in pendingCalls (e.g., a plugin appended it directly, or the
|
||||
// assistant/message from a prior step didn't have this call). The repair
|
||||
// should still close the turn — it just won't synthesize a result for this
|
||||
// call (there's nothing to answer).
|
||||
// A raw tool/call with no assistant-registered pending call has nothing to
|
||||
// answer; repair still closes the step and turn without synthesizing a result.
|
||||
const events: SessionEvent[] = [
|
||||
userTurnStart(1, 0),
|
||||
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
|
||||
|
||||
@@ -81,9 +81,6 @@ describe('Session', () => {
|
||||
const before = structuredClone(session.events)
|
||||
|
||||
// A misbehaving consumer tries to mutate the messages it was handed.
|
||||
// Derived messages are frozen shared projections (cloned once off the
|
||||
// log, then deep-frozen): every mutation attempt THROWS in strict mode —
|
||||
// isolation by unrepresentability, not by per-call cloning.
|
||||
const messages = session.deriveMessages()
|
||||
const userBlock = messages[0]!.content[0]!
|
||||
expect(() => { if (userBlock.type === 'text') userBlock.text = 'HACKED' }).toThrow(TypeError)
|
||||
@@ -132,11 +129,8 @@ describe('Session', () => {
|
||||
it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => {
|
||||
const session = new Session(SessionId('s5b'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
// The typed overload makes surfaceOp mandatory only when the type argument is
|
||||
// a SPECIFIC SurfaceEventType literal. A caller iterating raw events widens it
|
||||
// to the SessionEventType union, where the conditional rest collapses to
|
||||
// optional — the exact shape `for (const e of log) append(e.type, e.data)`
|
||||
// produces. Reproduce that here and assert the runtime guard rejects it.
|
||||
// A widened SessionEventType bypasses the overload's conditional requirement,
|
||||
// so the runtime guard must still reject the missing surface marker.
|
||||
const widenedType = 'user/message' as SessionEventType
|
||||
expect(() => session.append(widenedType, { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }))
|
||||
.toThrow(/surface-eligible and requires a surfaceOp marker/)
|
||||
@@ -685,10 +679,8 @@ describe('SessionStore', () => {
|
||||
})
|
||||
|
||||
it('enter() rejects a stale prepared session whose id is already live (no overwrite)', async () => {
|
||||
// prepare()/enter() are public cross-package primitives that a caller may
|
||||
// separate with arbitrary work. A stale prepared session must NOT overwrite
|
||||
// a live store entry of the same id — its detach disposer would later delete
|
||||
// the REAL session, breaking the store-uniqueness invariant.
|
||||
// A stale prepared object must not replace the live same-id entry; its later
|
||||
// detach would otherwise remove the wrong session.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const stale = ctx.sessions.prepare(SessionId('racy'))
|
||||
|
||||
@@ -216,14 +216,11 @@ describe('SurfaceManager', () => {
|
||||
|
||||
it('rebuild with replace operation splices out shadowed nodes', () => {
|
||||
const s = surfaceSession()
|
||||
// seq: 0=turn/start, 1=user, 2=assistant, 3=turn/end
|
||||
// Surface nodes: seq 1 (user), seq 2 (assistant).
|
||||
// Replace both with a compaction marker. Both 1 and 2 are valid surface seqs.
|
||||
// Replace surface seqs 1 (user) and 2 (assistant) with the summary.
|
||||
s.append('assistant/message',
|
||||
{ turn: 2, step: 1, content: [{ type: 'text', text: 'summary' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] },
|
||||
)
|
||||
// Now the surface should have just the compaction node.
|
||||
expect(s.surface.nodes.length).toBe(1)
|
||||
expect(s.surface.nodes[0]!.seq).toBe(4) // seq of the compaction marker
|
||||
expect(s.surface.nodes[0]!.prev).toBeNull()
|
||||
|
||||
@@ -4,24 +4,9 @@ import { Session, SessionId, isToolPairingBalanced } from '../src/index.ts'
|
||||
import type { SessionEvent, SurfaceNode } from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* Unit coverage for the tool-pairing balance check. It decides whether a CUT in
|
||||
* the surface (a gap before a given surface node, or the after-tail gap) is a
|
||||
* safe edge for a collapsed region (compaction): a region must never split an
|
||||
* `assistant/message`'s tool-calls from their `tool/result`s. A cut is balanced
|
||||
* when no unanswered tool-call sits before it on the surface. Nodes belonging to
|
||||
* no step (pre-step user message, inter-step steering, injection context) are
|
||||
* pairing-neutral, so their cuts are free boundaries.
|
||||
*
|
||||
* The fixtures are built through a real {@link Session} so the surface linked
|
||||
* list is derived exactly as production does — including the non-monotonic
|
||||
* surface a `replace` op leaves (a compaction checkpoint at a high log seq
|
||||
* sitting at the surface head), which is the case the abandoned log-position
|
||||
* scan mis-classified.
|
||||
*
|
||||
* Builders mirror the agent loop's real append order: queued user messages land
|
||||
* BEFORE `step/start`; within a step the order is `assistant/message` then
|
||||
* `tool/result`(s); injection turns are a bare `turn/start → context/message →
|
||||
* turn/end` with no step.
|
||||
* Unit coverage for compaction-cut safety: a cut is balanced only when it
|
||||
* separates no assistant tool call from its result. Non-step nodes are neutral,
|
||||
* and replace operations prove surface order—not raw log order—is authoritative.
|
||||
*/
|
||||
|
||||
const SURFACE = { surfaceOp: 'append' as const }
|
||||
@@ -182,10 +167,8 @@ describe('isToolPairingBalanced — multiple tool calls in one assistant message
|
||||
})
|
||||
|
||||
describe('isToolPairingBalanced — a mid-step injection context/message', () => {
|
||||
// A background task-done inject() lands a context/message INSIDE an open step,
|
||||
// between the assistant (with a tool-call) and its tool/result. It is
|
||||
// pairing-neutral, so the cut on EITHER side of it is unbalanced (the call is
|
||||
// still open across it) — it is NOT a free boundary in this position.
|
||||
// The injected context is pairing-neutral, but both adjacent cuts remain
|
||||
// unbalanced because the tool call is still open across them.
|
||||
function midStepInjection(): Session {
|
||||
const s = new Session(SessionId('mid-inject'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
@@ -236,11 +219,8 @@ describe('isToolPairingBalanced on an injection turn (no step)', () => {
|
||||
})
|
||||
|
||||
describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace op', () => {
|
||||
// The case the log-position scan got wrong. After a compaction, a replacement
|
||||
// user/message lands at a HIGH log seq but sits at the SURFACE head, beside
|
||||
// the still-open step whose events follow it in the log. It carries no
|
||||
// tool-call/result pair (just summarized prose), so it must be a balanced cut
|
||||
// on BOTH sides regardless of its log neighbours.
|
||||
// A replacement checkpoint has a high log seq but sits at the surface head;
|
||||
// its cuts are balanced regardless of later raw-log neighbors.
|
||||
function checkpointHeadedSession(): Session {
|
||||
const s = new Session(SessionId('checkpoint'))
|
||||
// A closed turn with a tool step → surface [u1, asst(call), result].
|
||||
@@ -292,10 +272,8 @@ describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace
|
||||
})
|
||||
|
||||
it('end cut after the head checkpoint is balanced (it carries no tool pair)', () => {
|
||||
// This is the exact assertion the log-position scan failed: the forward log
|
||||
// scan from the checkpoint reached the open step's assistant/message and
|
||||
// wrongly reported mid-step. The surface balance sees a neutral node whose
|
||||
// following cut closes no open call.
|
||||
// This is the exact assertion the log-position scan failed: the forward log scan from the
|
||||
// checkpoint reached the open step's assistant/message and wrongly reported mid-step.
|
||||
const s = checkpointHeadedSession()
|
||||
expect(endBalanced(s, s.surface.nodes[0]!.seq)).toBe(true)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user