From a7b569850b84a6a805cdb479a970bb85a28ae3b6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:42:51 +0800 Subject: [PATCH] =?UTF-8?q?session:=20the=20request=20header=20becomes=20l?= =?UTF-8?q?ogged=20state=20=E2=80=94=20request/header=20events=20+=20fold/?= =?UTF-8?q?diff/apply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every conversation request's non-content half (system prompt, tool schemas, call config — the EpochHeader) is now recorded in the session log: a 'request/header' full snapshot (reason 'initial' | 'resume' | 'fallback') anchors the fold at conversation birth and process boundaries, and 'request/header-delta' events (system line-trim, name-keyed tools delta, whole config) encode mid-run changes. The pure trio — foldRequestHeader / diffHeader / applyHeaderDelta — reconstructs the header any request was built under from the log alone; the writer contract round-trip-verifies every delta with a 'fallback' snapshot when the encoding cannot express a change (pure tool reordering), so a well-formed log always folds cleanly. Canonical absence: empty system and empty tools normalize to absent fields, matching request builds. Persistence and cordis catalogs regenerated; SessionEventMap paste and EpochHeader added to the core-data-structures session page. --- docs/cordis-catalog/events.md | 8 +- docs/cordis-catalog/services.md | 4 +- docs/core-data-structures/core.md | 2 +- docs/core-data-structures/session.md | 41 +++++ docs/event-producer-consumer.md | 8 +- docs/persistence-catalog/log-events.md | 48 +++-- packages/core/session/README.md | 4 + packages/core/session/src/index.ts | 1 + packages/core/session/src/request-header.ts | 172 ++++++++++++++++++ packages/core/session/src/types.ts | 87 ++++++++- .../core/session/tests/request-header.spec.ts | 140 ++++++++++++++ scripts/type-equiv.manifest.json | 1 + 12 files changed, 491 insertions(+), 25 deletions(-) create mode 100644 packages/core/session/src/request-header.ts create mode 100644 packages/core/session/tests/request-header.spec.ts diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index d10a7eeabd..f8b7e9c135 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -197,7 +197,7 @@ Waterfall around every streaming model call (retry, caching, routing). Bound to Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:33`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:35`](../../packages/llm/llm/src/index.ts) ## `session/*` @@ -209,7 +209,7 @@ A session was created in the store. 'session/created'(session: Session): void ``` -Source: [`packages/core/session/src/index.ts:36`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:37`](../../packages/core/session/src/index.ts) ### `session/event` — emit @@ -221,7 +221,7 @@ An event was appended to a session log (sync, fire-and-forget). This is the per- Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:44`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:45`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -231,7 +231,7 @@ Awaited durability checkpoint. The agent loop awaits `ctx.parallel('session/flus 'session/flush'(session: Session): Promise | void ``` -Source: [`packages/core/session/src/index.ts:54`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:55`](../../packages/core/session/src/index.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6e09a5e3ab..71b4d83fd8 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -124,7 +124,7 @@ stream(options: GenerateOptions): AsyncIterable Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:78`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:80`](../../packages/llm/llm/src/index.ts) ## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) @@ -163,7 +163,7 @@ get(id: SessionId): Session | undefined list(): Session[] ``` -Source: [`packages/core/session/src/index.ts:327`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:328`](../../packages/core/session/src/index.ts) ## `ctx.subagents` — `SubagentService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 61f980ff73..dc3bab7437 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -211,7 +211,7 @@ type SessionEvent = { }[T] ``` -The thirteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `prompt/blocked`, `context/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. +The fifteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `prompt/blocked`, `context/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`, `request/header-delta`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. ## The agent handle diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 92a9d3421f..2e235c4b3b 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -60,6 +60,30 @@ interface SessionEventMap { * cordis-catalog row. */ '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). + */ + 'request/header': { header: EpochHeader; reason: RequestHeaderReason } + /** + * Amendment to the folded {@link EpochHeader}: at least one of a + * {@link SystemDelta}, a {@link ToolsDelta}, or a whole replacement + * {@link LlmCallConfig} (four scalars — not worth diffing). 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}. + */ + 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } } ``` @@ -74,6 +98,23 @@ export interface TodoItem { } ``` +### The request header events: `request/header` and `request/header-delta` + +The request envelope — the `EpochHeader` (call config + rendered system prompt + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability RFC). A `request/header` snapshot (reason `'initial' | 'resume' | 'fallback'`) anchors the fold at conversation birth, process boundaries, and delta-encoding fallbacks; `request/header-delta` events amend it mid-run. `foldRequestHeader(events)` reconstructs the header any request was built under; the writer round-trip-verifies every delta before logging it, so a well-formed log always folds. Neither is a `SurfaceEventType` — they produce no LLM message. + +```ts type-equiv +export interface EpochHeader { + /** The conversation's call configuration (model + sampling scalars). */ + config: LlmCallConfig + /** Rendered system prompt text; absent for a system-less request. */ + system?: string + /** Assembled tool schemas; absent for a tool-less request. */ + tools?: ToolSchema[] +} +``` + +Canonical form: an empty system prompt and an empty tool list are ABSENT fields, matching how requests are built. The delta payloads (`SystemDelta` — a common-prefix/suffix line trim; `ToolsDelta` — name-keyed added/removed/changed) live beside the events in [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts). + ## `SessionEvent` — one log entry A proper discriminated union over `type` (not independent `type`/`data` unions), so `switch (event.type)` narrows `event.data` without casts. `seq` is the monotonic position in the log (`seq = log.length`); `time` is epoch ms. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 25b8b8b66a..913a28d76e 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,10 +21,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:33`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/support/llm-replay) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:36`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:44`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:35`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/support/llm-replay) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:37`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:45`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:98`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/persistence-catalog/log-events.md b/docs/persistence-catalog/log-events.md index bce865dedd..0b8d4837da 100644 --- a/docs/persistence-catalog/log-events.md +++ b/docs/persistence-catalog/log-events.md @@ -23,7 +23,7 @@ Raw stream chunk — token-level replay fidelity. Types: [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:237`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:298`](../../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -35,7 +35,7 @@ Assembled assistant message for one step (derived history uses this). Carries th Types: [ContentBlock](../core-data-structures/core.md) · [TokenUsage](../core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:244`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:305`](../../packages/core/session/src/types.ts) ### `compact/*` @@ -83,7 +83,7 @@ In-session context injection (file-change notices, subdir AGENTS.md, skill conte Types: [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:235`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:296`](../../packages/core/session/src/types.ts) ### `hook/*` @@ -119,7 +119,29 @@ A queued prompt an `agent/prompt-submit` listener VETOED — the durable record Types: [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:229`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:290`](../../packages/core/session/src/types.ts) + +### `request/*` + +#### `request/header` — log-only + +Full snapshot of the EpochHeader the NEXT request is built under, with the 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 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). + +```ts persistence-catalog +'request/header': { header: EpochHeader; reason: RequestHeaderReason } +``` + +Source: [`packages/core/session/src/types.ts:350`](../../packages/core/session/src/types.ts) + +#### `request/header-delta` — log-only + +Amendment to the folded EpochHeader: at least one of a SystemDelta, a ToolsDelta, or a whole replacement LlmCallConfig (four scalars — not worth diffing). 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 SurfaceEventType. + +```ts persistence-catalog +'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } +``` + +Source: [`packages/core/session/src/types.ts:361`](../../packages/core/session/src/types.ts) ### `steering/*` @@ -133,7 +155,7 @@ Steering content injected between steps of a running turn. Types: [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:262`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:323`](../../packages/core/session/src/types.ts) ### `step/*` @@ -145,7 +167,7 @@ Closes step `step` of turn `turn`. 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:216`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:277`](../../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -155,7 +177,7 @@ Opens step `step` of turn `turn` — one model call plus the tool executions it 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:214`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:275`](../../packages/core/session/src/types.ts) ### `todo/*` @@ -171,7 +193,7 @@ NOT a SurfaceEventType: it produces no LLM message and never reaches `deriveMess Types: [TodoItem](../core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:276`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:337`](../../packages/core/session/src/types.ts) ### `tool/*` @@ -185,7 +207,7 @@ The model requested one tool invocation: `name` with the raw `arguments` JSON st Types: [CallId](../core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:250`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:311`](../../packages/core/session/src/types.ts) #### `tool/result` — surface @@ -197,7 +219,7 @@ A completed tool call's model-facing result, plus an optional tool-private `meta Types: [CallId](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:260`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:321`](../../packages/core/session/src/types.ts) ### `turn/*` @@ -211,7 +233,7 @@ Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awai Types: [TurnEndReason](../core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:212`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:273`](../../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -223,7 +245,7 @@ Opens turn `turn`. `trigger` records what started it — a drained message batch Types: [TurnTrigger](../core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:206`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:267`](../../packages/core/session/src/types.ts) ### `user/*` @@ -237,4 +259,4 @@ A user-visible prompt (queued message drained at turn start). Types: [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:218`](../../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:279`](../../packages/core/session/src/types.ts) diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 72ca52bfef..ffa57ab6a5 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -47,6 +47,10 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. - `SurfaceNode` — `{ seq: number; prev: number | null; next: number | null }`, one node in the surface linked list. - `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully-formed surface node (type is surface-eligible AND `surfaceOp` present); the second is the type-only check (is this one of the five `SurfaceEventType` values?), used to detect a surface-eligible event MISSING its marker — e.g. when validating a seed/load log. +### 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) 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 ≡ absent fields). + ### Session event vocabulary (`types.ts`) The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog/log-events.md). Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`. diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index fa9bbb5ba4..392998b08e 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -21,6 +21,7 @@ export { interruptedTurnClosers } from './repair.ts' export type { SurfaceNode } from './surface.ts' export { isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' export { isToolPairingBalanced } from './tool-pairing.ts' +export { applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader } from './request-header.ts' declare module 'cordis' { interface Context { diff --git a/packages/core/session/src/request-header.ts b/packages/core/session/src/request-header.ts new file mode 100644 index 0000000000..e8f47697c2 --- /dev/null +++ b/packages/core/session/src/request-header.ts @@ -0,0 +1,172 @@ +/** + * 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. + * + * @module dsh-session/request-header + */ + +import { callConfigEquals } from '@deepseek-ai/dsh-llm' +import type { LlmCallConfig, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { EpochHeader, SessionEvent, SystemDelta, ToolsDelta } from './types.ts' + +/** + * Normalize a header to canonical form: an empty system prompt and an empty + * tool list become ABSENT fields, matching how requests are built (both + * request-build spreads skip empty values). Diff, fold, and comparison all + * operate on canonical headers, so "no system prompt" has exactly one + * representation. + * @param header - the header to normalize (not mutated). + * @returns the canonical header. + */ +export function canonicalHeader(header: EpochHeader): EpochHeader { + return { + config: header.config, + ...header.system !== undefined && header.system.length > 0 ? { system: header.system } : {}, + ...header.tools !== undefined && header.tools.length > 0 ? { tools: header.tools } : {}, + } +} + +/** Split a canonical (possibly absent) system prompt into lines; absence is zero lines. */ +function systemLines(system: string | undefined): string[] { + return system === undefined ? [] : system.split('\n') +} + +/** Join lines back into a canonical system value; zero lines is absence. */ +function joinSystem(lines: string[]): string | undefined { + return lines.length === 0 ? undefined : lines.join('\n') +} + +/** + * Compute the line-level {@link SystemDelta} between two canonical system + * prompts: trim the common prefix and (non-overlapping) common suffix, and + * carry the replacement lines between them. Deterministic and library-free; + * with nothing shared it degenerates to a full replacement. + */ +function diffSystem(prev: string | undefined, next: string | undefined): SystemDelta { + const a = systemLines(prev) + const b = systemLines(next) + let keepStart = 0 + while (keepStart < a.length && keepStart < b.length && a[keepStart] === b[keepStart]) keepStart += 1 + let keepEnd = 0 + while ( + keepEnd < a.length - keepStart && + keepEnd < b.length - keepStart && + a[a.length - 1 - keepEnd] === b[b.length - 1 - keepEnd] + ) keepEnd += 1 + return { keepStart, keepEnd, insert: b.slice(keepStart, b.length - keepEnd) } +} + +/** Apply a {@link SystemDelta} to a canonical system prompt. */ +function applySystem(prev: string | undefined, delta: SystemDelta): string | undefined { + const a = systemLines(prev) + return joinSystem([...a.slice(0, delta.keepStart), ...delta.insert, ...a.slice(a.length - delta.keepEnd)]) +} + +/** Canonical JSON equality for tool schemas — sound because schemas are + * JSON-serializable by construction and both sides come from the same + * assembly path, so key insertion order matches when the values do. */ +function sameSchema(a: ToolSchema, b: ToolSchema): boolean { + return JSON.stringify(a) === JSON.stringify(b) +} + +/** + * Compute the name-keyed {@link ToolsDelta} between two canonical tool lists. + * A pure reordering produces an empty delta — the writer's round-trip guard + * catches that case and records a snapshot instead. + */ +function diffTools(prev: readonly ToolSchema[], next: readonly ToolSchema[]): ToolsDelta { + const prevByName = new Map(prev.map(tool => [tool.name, tool])) + const nextNames = new Set(next.map(tool => tool.name)) + return { + added: next.filter(tool => !prevByName.has(tool.name)), + removed: prev.filter(tool => !nextNames.has(tool.name)).map(tool => tool.name), + changed: next.filter((tool) => { + const before = prevByName.get(tool.name) + return before !== undefined && !sameSchema(before, tool) + }), + } +} + +/** Apply a {@link ToolsDelta} to a canonical tool list: drop removed, replace changed in place, append added. */ +function applyTools(prev: readonly ToolSchema[], delta: ToolsDelta): ToolSchema[] { + const removed = new Set(delta.removed) + const changedByName = new Map(delta.changed.map(tool => [tool.name, tool])) + const kept = prev + .filter(tool => !removed.has(tool.name)) + .map(tool => changedByName.get(tool.name) ?? tool) + return [...kept, ...delta.added] +} + +/** + * 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. + * @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. + */ +export function diffHeader( + prev: EpochHeader, next: EpochHeader, +): { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } | undefined { + const delta: { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } = {} + if (prev.system !== next.system) delta.system = diffSystem(prev.system, next.system) + const prevTools = prev.tools ?? [] + const nextTools = next.tools ?? [] + if (JSON.stringify(prevTools) !== JSON.stringify(nextTools)) delta.tools = diffTools(prevTools, nextTools) + if (!callConfigEquals(prev.config, next.config)) delta.config = next.config + return Object.keys(delta).length > 0 ? delta : undefined +} + +/** + * Apply a `request/header-delta` payload to a canonical header, producing the + * canonical header it encodes. Total for well-formed logs (the writer only + * appends round-trip-verified deltas). + * @param prev - the folded header before the delta. + * @param delta - the logged delta payload. + * @returns the canonical header after the delta. + */ +export function applyHeaderDelta( + prev: EpochHeader, delta: { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig }, +): EpochHeader { + const system = delta.system !== undefined ? applySystem(prev.system, delta.system) : prev.system + const tools = delta.tools !== undefined ? applyTools(prev.tools ?? [], delta.tools) : prev.tools + return canonicalHeader({ + config: delta.config ?? prev.config, + ...system !== undefined ? { system } : {}, + ...tools !== undefined ? { tools } : {}, + }) +} + +/** + * 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. + * @param events - session events in log order (non-header events are skipped). + * @returns the folded header, or undefined when no header event exists yet. + */ +export function foldRequestHeader(events: readonly SessionEvent[]): EpochHeader | undefined { + let state: EpochHeader | undefined + for (const event of events) { + if (event.type === 'request/header') { + state = canonicalHeader(event.data.header) + } else if (event.type === 'request/header-delta') { + if (state === undefined) { + throw new Error(`request/header-delta at seq ${event.seq} before any request/header snapshot: corrupt log`) + } + state = applyHeaderDelta(state, event.data) + } + } + return state +} diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 82e080aa1a..ec227e9884 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -1,5 +1,5 @@ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { CallId, ContentBlock, MessageSource, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm' +import type { CallId, ContentBlock, LlmCallConfig, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm' /** Identifies one session in the store (and its persistence artifacts). */ export type SessionId = Branded<'SessionId'> @@ -176,6 +176,67 @@ export interface TodoItem { status: 'pending' | 'in_progress' | 'completed' } +/** + * The request header: everything about an LLM request besides its message + * content — the call configuration plus the rendered system prompt and tool + * schemas. 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 and an empty tool list are ABSENT + * fields, matching how requests are built. + */ +export interface EpochHeader { + /** The conversation's call configuration (model + sampling scalars). */ + config: LlmCallConfig + /** Rendered system prompt text; absent for a system-less request. */ + system?: string + /** Assembled tool schemas; absent for a tool-less request. */ + tools?: ToolSchema[] +} + +/** + * Why a `request/header` snapshot was appended: `'initial'` — the log's first + * header (a new conversation); `'resume'` — a loop instance's first request + * over a log that already has header events (process restart, fork seed); + * `'fallback'` — a mid-run change the delta encoding could not round-trip + * (e.g. a pure tool reordering), recorded whole instead. + */ +export type RequestHeaderReason = 'initial' | 'resume' | 'fallback' + +/** + * Line-level edit of the system prompt: keep the first `keepStart` and last + * `keepEnd` lines of the previous text, with `insert` replacing everything + * between. Computed as a common-prefix/common-suffix trim — deterministic, + * library-free, degenerating to a full replacement when nothing is shared. + * Absence is encoded as zero lines (the canonical form has no empty-string + * system), so a transition to or from "no system prompt" round-trips. + */ +export interface SystemDelta { + /** Lines kept from the start of the previous system prompt. */ + keepStart: number + /** Lines kept from the end of the previous system prompt. */ + keepEnd: number + /** Lines replacing everything between the kept edges. */ + insert: string[] +} + +/** + * Tool-set edit keyed by tool name (names are unique — the registry rejects + * duplicates): `removed` names drop, `changed` schemas replace their + * predecessor in place, `added` schemas append at the end. A change this + * encoding cannot express (a pure reordering) fails the writer's round-trip + * guard and is recorded as a `'fallback'` snapshot instead. + */ +export interface ToolsDelta { + /** Schemas appended to the end of the tool list. */ + added: ToolSchema[] + /** Names of schemas dropped from the tool list. */ + removed: string[] + /** Schemas replacing the same-named predecessor in place. */ + changed: ToolSchema[] +} + /** * The session event vocabulary — the append-only source of truth for an * agent's whole interaction history. The LLM message history is *derived* @@ -274,6 +335,30 @@ export interface SessionEventMap { * cordis-catalog row. */ '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). + */ + 'request/header': { header: EpochHeader; reason: RequestHeaderReason } + /** + * Amendment to the folded {@link EpochHeader}: at least one of a + * {@link SystemDelta}, a {@link ToolsDelta}, or a whole replacement + * {@link LlmCallConfig} (four scalars — not worth diffing). 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}. + */ + 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } } export type SessionEventType = keyof SessionEventMap diff --git a/packages/core/session/tests/request-header.spec.ts b/packages/core/session/tests/request-header.spec.ts new file mode 100644 index 0000000000..c2368c46fe --- /dev/null +++ b/packages/core/session/tests/request-header.spec.ts @@ -0,0 +1,140 @@ +/** + * Request-header utility tests: canonical form, the system line-diff + * (prefix/suffix trim), the name-keyed tools delta, config replacement, the + * round-trip contract (including the reorder case the encoding cannot + * express), and the log fold. These pin the reconstruction algebra: for every + * logged delta, apply(prev, delta) === next, and folding a log prefix yields + * the header its next request was built under. + */ + +import { describe, expect, it } from 'vitest' +import { Session, SessionId, applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader } from '@deepseek-ai/dsh-session' +import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session' +import type { ToolSchema } from '@deepseek-ai/dsh-llm' + +const CONFIG = { model: 'm' } + +function tool(name: string, description = 'd'): ToolSchema { + return { name, description, parameters: { type: 'object' } } +} + +/** Round-trip helper: diff must reproduce `next` from `prev` exactly. */ +function roundTrip(prev: EpochHeader, next: EpochHeader): ReturnType { + const delta = diffHeader(prev, next) + if (delta !== undefined) { + expect(applyHeaderDelta(prev, delta)).toEqual(canonicalHeader(next)) + } + return delta +} + +describe('canonicalHeader', () => { + it('normalizes empty system and empty tools to absent fields', () => { + expect(canonicalHeader({ config: CONFIG, system: '', tools: [] })).toEqual({ config: CONFIG }) + const full = canonicalHeader({ config: CONFIG, system: 's', tools: [tool('a')] }) + expect(full.system).toBe('s') + expect(full.tools).toHaveLength(1) + }) +}) + +describe('diffHeader / applyHeaderDelta', () => { + it('returns undefined for equal headers', () => { + const header = canonicalHeader({ config: CONFIG, system: 'a\nb', tools: [tool('t')] }) + expect(diffHeader(header, header)).toBeUndefined() + }) + + it('encodes a mid-prompt line change as a prefix/suffix trim', () => { + const prev = canonicalHeader({ config: CONFIG, system: 'keep1\nold\nkeep2\nkeep3' }) + const next = canonicalHeader({ config: CONFIG, system: 'keep1\nnew A\nnew B\nkeep2\nkeep3' }) + const delta = roundTrip(prev, next) + expect(delta?.system).toEqual({ keepStart: 1, keepEnd: 2, insert: ['new A', 'new B'] }) + expect(delta?.tools).toBeUndefined() + expect(delta?.config).toBeUndefined() + }) + + it('degenerates to a full replacement when nothing is shared, and round-trips absence transitions', () => { + const none = canonicalHeader({ config: CONFIG }) + const some = canonicalHeader({ config: CONFIG, system: 'x\ny' }) + const gained = roundTrip(none, some) + expect(gained?.system).toEqual({ keepStart: 0, keepEnd: 0, insert: ['x', 'y'] }) + const lost = roundTrip(some, none) + expect(lost?.system).toEqual({ keepStart: 0, keepEnd: 0, insert: [] }) + }) + + it('does not double-count overlapping prefix and suffix (repeated lines)', () => { + const prev = canonicalHeader({ config: CONFIG, system: 'a\na' }) + const next = canonicalHeader({ config: CONFIG, system: 'a\na\na' }) + roundTrip(prev, next) + }) + + it('encodes tool addition, removal, and in-place schema change by name', () => { + const prev = canonicalHeader({ config: CONFIG, tools: [tool('keep'), tool('drop'), tool('edit', 'before')] }) + const next = canonicalHeader({ config: CONFIG, tools: [tool('keep'), tool('edit', 'after'), tool('new')] }) + const delta = roundTrip(prev, next) + expect(delta?.tools?.added.map(t => t.name)).toEqual(['new']) + expect(delta?.tools?.removed).toEqual(['drop']) + expect(delta?.tools?.changed.map(t => t.name)).toEqual(['edit']) + }) + + it('round-trips a tool set gained from a tool-less header and lost back to one', () => { + const none = canonicalHeader({ config: CONFIG }) + const some = canonicalHeader({ config: CONFIG, tools: [tool('t')] }) + const gained = roundTrip(none, some) + expect(gained?.tools?.added.map(t => t.name)).toEqual(['t']) + const lost = roundTrip(some, none) + expect(lost?.tools?.removed).toEqual(['t']) + }) + + it('cannot express a pure reordering — the writer detects it via the round-trip check', () => { + const prev = canonicalHeader({ config: CONFIG, tools: [tool('a'), tool('b')] }) + const next = canonicalHeader({ config: CONFIG, tools: [tool('b'), tool('a')] }) + const delta = diffHeader(prev, next) + // A delta IS produced (the lists differ)… + expect(delta).toBeDefined() + // …but applying it cannot reproduce the new order — exactly the case the + // writer's guard turns into a 'fallback' snapshot. + expect(applyHeaderDelta(prev, delta!)).not.toEqual(next) + }) + + it('replaces the config whole and leaves untouched parts alone', () => { + const prev = canonicalHeader({ config: { model: 'm' }, system: 's', tools: [tool('t')] }) + const next = canonicalHeader({ config: { model: 'm2', temperature: 0.1 }, system: 's', tools: [tool('t')] }) + const delta = roundTrip(prev, next) + expect(delta).toEqual({ config: { model: 'm2', temperature: 0.1 } }) + }) +}) + +describe('foldRequestHeader', () => { + function headerEvents(session: Session): readonly SessionEvent[] { + return session.events + } + + it('returns undefined on a log with no header events', () => { + const session = new Session(SessionId('fold-none')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(foldRequestHeader(headerEvents(session))).toBeUndefined() + }) + + it('folds snapshot then deltas into the header in force, skipping unrelated events', () => { + const session = new Session(SessionId('fold')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const first = canonicalHeader({ config: { model: 'm' }, system: 'a\nb', tools: [tool('t')] }) + session.append('request/header', { header: first, reason: 'initial' }) + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + + const second = canonicalHeader({ config: { model: 'm' }, system: 'a\nc', tools: [tool('t')] }) + session.append('request/header-delta', diffHeader(first, second)!) + expect(foldRequestHeader(headerEvents(session))).toEqual(second) + + // A later snapshot replaces the state wholesale (the 'resume'/'fallback' anchor). + const third = canonicalHeader({ config: { model: 'other' } }) + session.append('request/header', { header: third, reason: 'resume' }) + expect(foldRequestHeader(headerEvents(session))).toEqual(third) + }) + + it('throws on a delta before any snapshot (corrupt log)', () => { + const session = new Session(SessionId('fold-corrupt')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('request/header-delta', { config: { model: 'x' } }) + expect(() => foldRequestHeader(headerEvents(session))).toThrow(/before any request\/header snapshot/) + }) +}) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index b1c3163782..46390a6567 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -21,6 +21,7 @@ { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "AppIdentity", "source": "packages/llm/llm/src/attribution.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEventMap", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "EpochHeader", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "TodoItem", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "TurnTriggerMap", "source": "packages/core/session/src/types.ts" },