docs: trim generated prose
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
* the derived LLM message history. Persistence is a plugin concern (subscribe
|
||||
* to `session/event`, drain on `session/flush`).
|
||||
*
|
||||
* Scope-filtered dispatch: keyed to the session's captured owner.
|
||||
* @module @deepseek-ai/dsh-session
|
||||
*/
|
||||
|
||||
@@ -35,44 +36,24 @@ declare module 'cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* A session was created in the store.
|
||||
* 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.
|
||||
* Dispatch uses the session's captured owner scope.
|
||||
* @param session - the session just entered and announced.
|
||||
* @mode emit
|
||||
*/
|
||||
'session/created'(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.
|
||||
* 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.
|
||||
* An event was appended to a session log (sync, fire-and-forget).
|
||||
*
|
||||
* Scope-filtered dispatch: keyed to the session's captured owner.
|
||||
* @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 durability checkpoint.
|
||||
*
|
||||
* Scope-filtered dispatch: keyed to the session's captured owner.
|
||||
* @param session - the session whose buffered events must reach durable storage.
|
||||
* @mode parallel
|
||||
*/
|
||||
@@ -137,13 +118,7 @@ export class Session {
|
||||
|
||||
constructor(public readonly id: SessionId, seed?: SessionEvent[], header?: SessionHeader) {
|
||||
if (seed) {
|
||||
// Validate the seed to the SAME invariants `append` enforces, so a
|
||||
// replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
|
||||
// live log that no persistence backend could store: each event's `data`
|
||||
// must be JSON-serializable, and `seq` must be contiguous from 0 (the
|
||||
// `seq = log.length` contract the whole system relies on). Without this,
|
||||
// a bad seed would surface only later as a backend rejection or a silent
|
||||
// divergence between the live log and disk.
|
||||
// Validate seed JSON and contiguous sequence numbers just as append would.
|
||||
seed.forEach((event, index) => {
|
||||
if (event.seq !== index) {
|
||||
throw new Error(`seed event at index ${index} has seq ${event.seq} (expected ${index}); seed must be contiguous from 0`)
|
||||
@@ -151,25 +126,13 @@ export class Session {
|
||||
if (!isJsonValue(event.data)) {
|
||||
throw new Error(`seed event "${event.type}" (seq ${event.seq}) carries non-JSON-serializable data`)
|
||||
}
|
||||
// Surface-eligible events MUST carry a surfaceOp marker — the surface is
|
||||
// the sole source of derived history, so a marker-less message event
|
||||
// would load fine yet vanish from deriveMessages(). `append` enforces
|
||||
// this at compile time via its typed overload; a seed arrives as raw
|
||||
// SessionEvent[] (replay/fork/load), bypassing that, so re-check at
|
||||
// runtime here rather than silently resuming with empty history.
|
||||
// Seed events bypass append's overloads, so enforce surface markers at runtime.
|
||||
if (isSurfaceEligibleType(event.type)
|
||||
&& (event as SessionEvent<SurfaceEventType>).surfaceOp === undefined) {
|
||||
throw new Error(`seed event "${event.type}" (seq ${event.seq}) is surface-eligible but carries no surfaceOp marker`)
|
||||
}
|
||||
})
|
||||
// Deep-clone each seed event, NOT just the array: the seed events and
|
||||
// their `data` are still owned by the caller (or the source session of a
|
||||
// fork), so keeping the references would let a post-create mutation of the
|
||||
// original rewrite this session's durable log — or reintroduce a
|
||||
// non-JSON-serializable value AFTER the validation above. Snapshotting at
|
||||
// the boundary makes `session.events` independent and keeps it equal to
|
||||
// what was validated. Serializability is guaranteed by the check above, so
|
||||
// structuredClone can never hit a non-cloneable value here.
|
||||
// Clone seed events so callers cannot mutate the durable log after validation.
|
||||
this.log = seed.map(event => structuredClone(event))
|
||||
}
|
||||
this.header = header ?? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
|
||||
@@ -189,29 +152,14 @@ export class Session {
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one typed event to the log and synchronously notify observers via
|
||||
* `onAppend`. The hot path never blocks on I/O — persistence plugins buffer
|
||||
* asynchronously.
|
||||
* Append one typed event to the log and synchronously notify observers via `onAppend`. The
|
||||
* hot path never blocks on I/O — persistence plugins buffer asynchronously.
|
||||
*
|
||||
* @param type - The event type (key of {@link SessionEventMap}).
|
||||
* @param data - The event payload; must be JSON-serializable.
|
||||
* @param opts - Surface metadata: `surfaceOp` controls how the event enters
|
||||
* the surface linked list; `sourceEventSeqs` records provenance (the seq
|
||||
* numbers of events this one derives from). REQUIRED for
|
||||
* {@link SurfaceEventType} events (every message-producing event must
|
||||
* declare how it joins the surface, the sole source of derived history) and
|
||||
* rejected by the compiler for non-surface types like `turn/start` or
|
||||
* `assistant/chunk`.
|
||||
* @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
|
||||
* `data` that entered the log, so reading `event.data` back sees the logged
|
||||
* value, never the caller's still-mutable input.
|
||||
* @throws if `data` is not losslessly JSON-serializable (BigInt, function,
|
||||
* symbol, undefined, non-finite number, circular ref, or an exotic object
|
||||
* like Map/Set/Date). The event log is the durable source of truth, so this
|
||||
* invariant is enforced at the source — a bad event never enters the log,
|
||||
* keeping `session.events` always equal to what a backend can persist. The
|
||||
* throw surfaces at the buggy caller's append site, not asynchronously in a
|
||||
* backend flush.
|
||||
* @param opts - required surface placement and optional provenance for message-producing events.
|
||||
* @returns the event with assigned sequence, time, and snapshotted data.
|
||||
* @throws if data is not losslessly JSON-serializable or surface placement is missing.
|
||||
*/
|
||||
append<T extends SessionEventType>(
|
||||
type: T,
|
||||
@@ -222,36 +170,12 @@ export class Session {
|
||||
throw new Error(`session event "${type}" carries non-JSON-serializable data`)
|
||||
}
|
||||
const surfaceOpts: SurfaceIntent | undefined = opts[0]
|
||||
// Surface-eligible events MUST carry a surfaceOp marker — the surface is the
|
||||
// sole source of derived history, so a marker-less message event would be
|
||||
// logged yet vanish from deriveMessages(). The typed `opts` overload makes
|
||||
// the marker mandatory only when `T` is a SPECIFIC SurfaceEventType literal;
|
||||
// when `T` widens to the SessionEventType union (a caller iterating raw
|
||||
// events: `for (const e of log) append(e.type, e.data)`), the conditional
|
||||
// rest collapses to optional and the compiler stops enforcing it. Re-check
|
||||
// at runtime so that loophole can't silently drop history.
|
||||
// Recheck the conditional overload when `T` has widened to the full union.
|
||||
if (isSurfaceEligibleType(type) && surfaceOpts?.surfaceOp === undefined) {
|
||||
throw new Error(`session event "${type}" is surface-eligible and requires a surfaceOp marker`)
|
||||
}
|
||||
// Snapshot `data` into the log, NOT the caller's reference: the validation
|
||||
// above proves it is JSON-serializable AT THIS MOMENT, but the caller still
|
||||
// owns the object and could mutate it afterwards (before a persistence
|
||||
// flush, or permanently in the in-memory history) — making `session.events`
|
||||
// diverge from the value that passed validation, or reintroducing a
|
||||
// non-serializable value. Cloning here keeps the log equal to what was
|
||||
// validated. structuredClone is safe because serializability was just
|
||||
// checked. The returned event carries the SAME snapshot, so a caller reading
|
||||
// back `event.data` sees the logged value, not its own mutable input.
|
||||
//
|
||||
// Surface metadata is snapshot separately: sourceEventSeqs (number[] —
|
||||
// primitives, so array spread is a complete copy) and surfaceOp (a string
|
||||
// primitive, or cloned if it's a replace object).
|
||||
// Build the event shape with conditional surface fields via spreading.
|
||||
// The result is cast through `unknown` because the conditional spreads
|
||||
// produce an intersection type that the assignability checker can't
|
||||
// narrow to a specific discriminated-union member when T is generic.
|
||||
// This is a safe internal boundary: data was validated above, and
|
||||
// surface metadata was snapshot from primitive/clone-safe values.
|
||||
// Snapshot caller-owned data and metadata before they enter durable history.
|
||||
// The generic conditional spreads require an internal union-boundary cast.
|
||||
const event = {
|
||||
type,
|
||||
seq: this.log.length,
|
||||
@@ -300,22 +224,9 @@ export class Session {
|
||||
private derivedGeneration = 0
|
||||
|
||||
/**
|
||||
* Derive the LLM message history by walking the session surface — the linked
|
||||
* list of message-producing events maintained by `surfaceOp` markers. The
|
||||
* surface is the single source of derived history: every message-producing
|
||||
* append records its `surfaceOp`, so a raw event with no marker (a chunk, a
|
||||
* turn boundary) is correctly absent, and a compaction `replace` deletes the
|
||||
* shadowed nodes from the derivation. The projection rules are
|
||||
* {@link deriveEventMessage}, folded per node.
|
||||
* Derive the LLM message history by walking the session surface — the linked list of
|
||||
* message-producing events maintained by `surfaceOp` markers.
|
||||
*
|
||||
* CACHED: each surface node is projected exactly once, when first seen — a
|
||||
* call costs O(new nodes), and a surface rewrite (a `replace`;
|
||||
* {@link SurfaceManager.replaceGeneration}) rebuilds. The returned array is
|
||||
* a fresh snapshot per call (later appends never grow an array a caller
|
||||
* already holds); the `Message` objects in it are SHARED and **deep-frozen**
|
||||
* — cloned once off the log at projection time, so consumers can never
|
||||
* mutate logged data, and mutation attempts throw instead of silently
|
||||
* diverging replay from history.
|
||||
* @returns a fresh array of the shared, frozen derived history.
|
||||
*/
|
||||
deriveMessages(): Message[] {
|
||||
@@ -341,15 +252,10 @@ export class Session {
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a single event into the LLM message it derives to, or null when
|
||||
* it produces none — a non-surface event (chunk, boundary, log-only record)
|
||||
* or an empty-content assistant/message (which exists only to host usage).
|
||||
* The per-node pure function {@link deriveMessages} folds over the surface;
|
||||
* an external reconstructor (or the dev invariant) folds the same function
|
||||
* over a log prefix's surface to rebuild the exact messages any request was
|
||||
* built from (the reconstructability RFC). The returned `content` is
|
||||
* deep-cloned off the logged event: the log is append-only by contract, so
|
||||
* no live reference to logged data leaves this boundary.
|
||||
* Project a single event into the LLM message it derives to, or null when it produces none —
|
||||
* a non-surface event (chunk, boundary, log-only record) or an empty-content
|
||||
* assistant/message (which exists only to host usage).
|
||||
*
|
||||
* @param event - the event to project.
|
||||
* @returns the derived message, or null when the event produces none.
|
||||
*/
|
||||
@@ -441,31 +347,15 @@ export class SessionStore extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session owned by the calling fiber: disposing that fiber stops
|
||||
* event notification and removes the session from the store. `options.seed`
|
||||
* populates the session with a copy of those events (replay/fork);
|
||||
* `options.meta` attaches creation metadata (validated absolute `cwd`,
|
||||
* `parentSession` lineage) as the immutable {@link SessionHeader} (the store
|
||||
* fills `version`/`id`/`createdAt`).
|
||||
*
|
||||
* For an agent whose session must be torn down IN ORDER with its loop (so the
|
||||
* loop's final flush is captured before `onAppend` detaches), do NOT use this
|
||||
* — fold the session lifecycle into the agent's own effect via
|
||||
* {@link prepare} + {@link enter} + {@link announce} (see `dsh-agent-loop`'s
|
||||
* `startOwned`).
|
||||
*
|
||||
* Create, enter, and announce a session owned by the calling fiber.
|
||||
* @param id - the session id; omitted, the store mints `session-<n>`.
|
||||
* @param options - seed events and/or creation metadata for the header.
|
||||
* @param options - optional seed and header metadata.
|
||||
* @returns the live session, already entered and announced.
|
||||
* @throws if a session with `id` already exists, or if `meta.cwd` is a
|
||||
* non-absolute path (storage backends key directories off it).
|
||||
* @throws if the id exists or cwd is not absolute.
|
||||
*/
|
||||
create(id?: SessionId, options?: CreateSessionOptions): Session {
|
||||
const session = this.prepare(id, options)
|
||||
// Single effect owned by the calling fiber. Yield the detach BEFORE
|
||||
// announcing so a throwing `session/created` listener rolls the attach back
|
||||
// (the generator effect disposes already-yielded disposers on a throw)
|
||||
// instead of leaking the store entry + onAppend.
|
||||
// Yield detach before announcement so listener failure rolls back entry.
|
||||
this.ctx.effect(function* (this: SessionStore) {
|
||||
yield this.enter(session)
|
||||
this.announce(session)
|
||||
@@ -474,13 +364,8 @@ export class SessionStore extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a session WITHOUT entering it into the store — validate the id/cwd and
|
||||
* construct the {@link Session} (with its immutable {@link SessionHeader}).
|
||||
* Pairs with {@link enter} + {@link announce}: a caller that owns a composite
|
||||
* `ctx.effect` (the agent factory) folds the session lifecycle into that ONE
|
||||
* effect so a fiber unload tears the session + agent down as a single ORDERED
|
||||
* chain rather than as racing sibling effects — which would detach `onAppend`
|
||||
* before the loop's closing `session/flush`, dropping the closing events.
|
||||
* Build a session WITHOUT entering it into the store — validate the id/cwd and construct the
|
||||
* {@link Session} (with its immutable {@link SessionHeader}).
|
||||
*
|
||||
* @param id - the session id; omitted, the store mints `session-<n>`.
|
||||
* @param options - seed events and/or creation metadata for the header.
|
||||
@@ -507,20 +392,8 @@ export class SessionStore extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter a {@link prepare}d session into the store: wire `onAppend` →
|
||||
* `session/event` and add it to the store. Returns the DETACH disposer
|
||||
* (`onAppend = undefined` + store removal). Does NOT emit `session/created` —
|
||||
* the caller yields this disposer inside its effect and THEN calls
|
||||
* {@link announce}, so a throwing `session/created` listener rolls the attach
|
||||
* back instead of leaking it.
|
||||
*
|
||||
* Re-checks the id for a duplicate: `prepare` and `enter` are public
|
||||
* cross-package primitives and a caller may interleave arbitrary work (or
|
||||
* another create) between them, so a stale prepared session must NOT overwrite
|
||||
* a live store entry of the same id — its detach disposer would later delete
|
||||
* the REAL session. The {@link create} convenience and the agent factory call
|
||||
* the two back-to-back so they never trip this, but the public seam cannot
|
||||
* assume that.
|
||||
* Enter a {@link prepare}d session into the store: wire `onAppend` → `session/event` and
|
||||
* add it to the store.
|
||||
*
|
||||
* @param session - a {@link prepare}d session not yet in the store.
|
||||
* @returns the detach disposer (`onAppend = undefined` + store removal).
|
||||
@@ -528,11 +401,10 @@ export class SessionStore extends Service {
|
||||
*/
|
||||
enter(session: Session): () => void {
|
||||
if (this.store.has(session.id)) throw new Error(`session "${session.id}" already exists`)
|
||||
// The carrier is decided HERE, once, from the ENTERING context's scope tag
|
||||
// (`this.ctx` is the caller's context — the tracker mechanism): every
|
||||
// session/created|event|flush dispatch for this session uses it, so the
|
||||
// session's whole event feed is scope-filtered consistently. The base is
|
||||
// the session itself (scoped listeners' `this` is the session).
|
||||
// The carrier is decided HERE, once, from the ENTERING context's scope tag (`this.ctx` is
|
||||
// the caller's context — the tracker mechanism): every session/created|event|flush dispatch
|
||||
// for this session uses it, so the session's whole event feed is scope-filtered
|
||||
// consistently.
|
||||
const carrier = scopeTarget(session, scopeOf(this.ctx))
|
||||
this.carriers.set(session, carrier)
|
||||
const emitCtx = this.ctx
|
||||
@@ -604,14 +476,12 @@ export class SessionStore extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a live child session from a turn-enclosed prefix of a live source.
|
||||
* `boundary` is an inclusive source event seq; omitted means the source's
|
||||
* current last event. A non-empty selected slice must end at `turn/end`.
|
||||
* Create a live child session from a turn-enclosed prefix of a live source. `boundary` is
|
||||
* an inclusive source event seq; omitted means the source's current last event.
|
||||
*
|
||||
* @param source - Live source session object or id.
|
||||
* @param boundary - Inclusive source event seq to fork through; omitted means
|
||||
* the source's current last event, and omitted on an empty source forks an
|
||||
* empty child.
|
||||
* @param boundary - Inclusive source event seq to fork through; omitted means the
|
||||
* source's current last event, and omitted on an empty source forks an empty child.
|
||||
* @param childSessionId - Optional child session id; omitted delegates to
|
||||
* `SessionStore`'s id policy.
|
||||
* @returns The created live child session.
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
/**
|
||||
* JSON-serializability validation for session event 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. Backends re-use the same
|
||||
* predicate to validate their own `append(events)` entry point (replay/fork
|
||||
* paths that do not go through a live `Session`).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session/json
|
||||
*/
|
||||
|
||||
@@ -24,22 +14,9 @@
|
||||
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
||||
|
||||
/**
|
||||
* Whether `value` is losslessly JSON-serializable: only `null`, finite numbers,
|
||||
* booleans, strings, plain arrays, and plain objects of such values. Rejects
|
||||
* `BigInt`, function, symbol, `undefined`, non-finite numbers (`NaN`/`Infinity`,
|
||||
* which `JSON.stringify` 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.
|
||||
* Whether `value` is losslessly JSON-serializable: only `null`, finite numbers, booleans,
|
||||
* strings, plain arrays, and plain objects of such values.
|
||||
*
|
||||
* Scope — matches `JSON.stringify` exactly: only an object's OWN ENUMERABLE
|
||||
* STRING-keyed properties are inspected (`Object.values`). Symbol-keyed and
|
||||
* non-enumerable properties are NOT examined, because `JSON.stringify` likewise
|
||||
* drops them — they never reach the durable form, so a non-serializable value
|
||||
* hiding under a symbol/non-enumerable key cannot make the round-trip lossy.
|
||||
* Getters are invoked during the check (again as `JSON.stringify` would), so the
|
||||
* contract is for plain data records, not objects with side-effecting accessors.
|
||||
* @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.
|
||||
|
||||
@@ -1,37 +1,5 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session/repair
|
||||
*/
|
||||
|
||||
@@ -39,36 +7,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.
|
||||
* 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).
|
||||
*
|
||||
* 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.
|
||||
// Track tool calls vs. their results WITHIN the currently-open turn only: a call is "pending"
|
||||
// until its matching tool/result arrives.
|
||||
const pendingCalls = new Map<CallId, { step: number; callSeq?: number }>()
|
||||
for (const event of events) {
|
||||
switch (event.type) {
|
||||
@@ -97,10 +48,8 @@ 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).
|
||||
// Capture the tool/call event seq for surface provenance on the synthesized
|
||||
// tool/result.
|
||||
{
|
||||
const entry = pendingCalls.get(event.data.callId)
|
||||
if (entry) {
|
||||
@@ -129,10 +78,9 @@ 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).
|
||||
// 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).
|
||||
for (const [callId, { step, callSeq }] of pendingCalls) {
|
||||
closers.push({
|
||||
type: 'tool/result',
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
/**
|
||||
* 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: the pure fold/diff/apply trio over the
|
||||
* `request/header` / `request/header-delta` session events.
|
||||
* @module dsh-session/request-header
|
||||
*/
|
||||
|
||||
@@ -114,13 +106,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 +128,9 @@ 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.
|
||||
*
|
||||
* @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 +167,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 {
|
||||
|
||||
@@ -1,36 +1,6 @@
|
||||
/**
|
||||
* 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's surface: is a given cut point in the surface a safe
|
||||
* edge for a collapsed region (e.g. compaction)?
|
||||
* @module @deepseek-ai/dsh-session/tool-pairing
|
||||
*/
|
||||
|
||||
@@ -57,33 +27,12 @@ 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.
|
||||
* @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; 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 +42,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,19 +14,9 @@ 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.
|
||||
*/
|
||||
export const SESSION_FORMAT_VERSION = 0
|
||||
|
||||
@@ -55,13 +45,8 @@ export interface SessionHeader {
|
||||
/** The session this one was forked from (seed lineage), if any. */
|
||||
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 via a seed rather than produced by this session —
|
||||
* the seed boundary.
|
||||
*/
|
||||
seedLength?: number
|
||||
}
|
||||
@@ -110,21 +95,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' }
|
||||
@@ -149,14 +120,8 @@ export interface TurnEndReasonMap {
|
||||
*/
|
||||
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.
|
||||
* 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.
|
||||
*/
|
||||
interrupted: { kind: 'interrupted' }
|
||||
}
|
||||
@@ -253,24 +218,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 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.
|
||||
*/
|
||||
export interface SessionEventMap {
|
||||
/**
|
||||
@@ -293,14 +244,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()`.
|
||||
* A queued prompt an `agent/prompt-submit` listener VETOED — the durable record of a blocked
|
||||
* prompt and why.
|
||||
*/
|
||||
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
|
||||
/**
|
||||
@@ -337,47 +282,24 @@ 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.
|
||||
* 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 })`.
|
||||
*/
|
||||
'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 snapshot of the {@link EpochHeader} the NEXT request is built under, with the {@link
|
||||
* RequestHeaderReason} it was recorded whole.
|
||||
*/
|
||||
'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}.
|
||||
* 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).
|
||||
*/
|
||||
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user