From 1123e946c0f5f1966c995debd83adf9c42b50f82 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:56:10 +0800 Subject: [PATCH] refactor: simplify session log representation --- docs/cordis-catalog/events.md | 10 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/core.md | 4 +- docs/core-data-structures/session.md | 37 +--- docs/event-producer-consumer.md | 8 +- docs/persistence-catalog.md | 44 ++-- docs/rfc/INDEX.md | 4 +- .../2026-06-18-session-surface.md | 17 +- .../2026-07-05-reconstructable-requests.md | 15 +- .../2026-06-18-compaction-capability-seam.md | 4 +- .../feature/2026-06-29-todo-write-tool.md | 2 +- .../feature/2026-07-06-explicit-tool-order.md | 2 +- .../implemented/feature/2026-07-06-sandbox.md | 6 +- .../feature/2026-07-07-session-prefix.md | 11 +- ...6-07-08-self-referential-cordis-toolset.md | 4 +- ...-12-simplify-session-log-representation.md | 33 +++ ...-request-header-content-in-one-scenario.md | 6 +- .../2026-07-08-shared-acp-snapshot-package.md | 2 +- ...-12-simplify-session-log-representation.md | 36 ---- docs/tool-catalog.md | 4 +- .../sandbox-acp-agent/tests/acp.snapshot.ts | 6 +- .../snapshots/mode-switching/session.jsonl | 2 +- .../mode-switching/system-prompt.golden.md | 11 +- packages/bash/bash/src/session-mode.ts | 2 +- packages/compact/compact-basic/src/index.ts | 23 +-- .../compact-basic/tests/compact-basic.spec.ts | 112 +++++----- .../tests/compact-loop-repro.spec.ts | 12 +- packages/core/agent-loop/src/loop.ts | 6 +- packages/core/agent-loop/src/request-log.ts | 21 +- .../agent-loop/tests/interception.spec.ts | 6 +- .../core/agent-loop/tests/request-log.spec.ts | 23 +-- .../tests/request-reconstruction.spec.ts | 23 ++- packages/core/agent/src/types.ts | 2 +- packages/core/session/README.md | 11 +- packages/core/session/src/index.ts | 22 +- packages/core/session/src/request-header.ts | 189 +++-------------- packages/core/session/src/surface.ts | 63 ++---- packages/core/session/src/tool-pairing.ts | 23 +-- packages/core/session/src/types.ts | 77 ++----- .../core/session/tests/derived-cache.spec.ts | 2 +- .../core/session/tests/request-header.spec.ts | 193 ++++-------------- packages/core/session/tests/session.spec.ts | 4 +- packages/core/session/tests/surface.spec.ts | 45 +--- .../core/session/tests/tool-pairing.spec.ts | 24 +-- packages/llm/llm/src/call-config.ts | 4 +- packages/llm/llm/tests/call-config.spec.ts | 2 +- .../tests/jsonl.spec.ts | 17 +- .../tests/sqlite.spec.ts | 17 ++ .../session-persistence/src/coordinator.ts | 11 + packages/support/acp-snapshot/README.md | 2 +- .../support/acp-snapshot/src/normalize.ts | 36 +--- packages/support/acp-snapshot/src/suite.ts | 132 ++++-------- .../fixtures/suite/pin-turn/behavior.json | 2 +- .../fixtures/suite/pin-turn/session.jsonl | 2 +- .../suite/pin-turn/system-prompt.golden.md | 4 +- .../acp-snapshot/tests/normalize.spec.ts | 96 ++------- .../support/acp-snapshot/tests/suite.spec.ts | 51 ++--- packages/support/invariants/README.md | 2 +- packages/support/invariants/src/index.ts | 8 +- .../invariants/tests/invariants.spec.ts | 4 +- packages/ui/user-approval/README.md | 2 +- packages/ui/user-approval/src/index.ts | 6 +- scripts/gen-tool-catalog.ts | 2 +- scripts/type-equiv.manifest.json | 1 - 64 files changed, 522 insertions(+), 1032 deletions(-) create mode 100644 docs/rfc/implemented/simplification/2026-07-12-simplify-session-log-representation.md delete mode 100644 docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index acbd39b8fa..5a3398f9a8 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -89,7 +89,7 @@ Source: [`packages/core/agent/src/types.ts:360`](../../packages/core/agent/src/t ### `agent/request` — waterfall -Waterfall: shape the step's call configuration — model switching, sampling overrides — by returning a replacement LlmCallConfig (the frozen seed is the config the loop would otherwise use). Config is ALL a listener shapes here: every request is a pure function of the session log (the reconstructability RFC), so model-visible content flows through the log channels — `inject()`, steering, prompt-submit `additionalContext`, prompt sections via `system-prompt/assemble`, or the header-logged session prefix via agent/session-prefix — never through request mutation, and the loop records whatever config the request actually uses as a `request/header*` event before dispatch. The step's messages are already snapshotted when this fires (the `step/start` boundary): an `inject()` from a listener here lands in the log but joins the NEXT request. For surface mutation that must precede the snapshot (compaction), use agent/pre-step. Call `next()` to delegate, or return an LlmCallConfig without it to short-circuit. +Waterfall: shape the step's call configuration — model switching, sampling overrides — by returning a replacement LlmCallConfig (the frozen seed is the config the loop would otherwise use). Config is ALL a listener shapes here: every request is a pure function of the session log (the reconstructability RFC), so model-visible content flows through the log channels — `inject()`, steering, prompt-submit `additionalContext`, prompt sections via `system-prompt/assemble`, or the header-logged session prefix via agent/session-prefix — never through request mutation, and the loop records whatever config the request actually uses as a `request/header` event before dispatch. The step's messages are already snapshotted when this fires (the `step/start` boundary): an `inject()` from a listener here lands in the log but joins the NEXT request. For surface mutation that must precede the snapshot (compaction), use agent/pre-step. Call `next()` to delegate, or return an LlmCallConfig without it to short-circuit. ```ts cordis-catalog 'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise @@ -251,7 +251,7 @@ A session was created in the store. A synchronous listener throw vetoes publicat 'session/created'(this: Scoped, session: Session): void ``` -Source: [`packages/core/session/src/index.ts:52`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:51`](../../packages/core/session/src/index.ts) ### `session/disposed` — emit @@ -261,7 +261,7 @@ A previously announced session left the store. Emitted exactly once on normal de 'session/disposed'(this: Scoped, session: Session): void ``` -Source: [`packages/core/session/src/index.ts:64`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:63`](../../packages/core/session/src/index.ts) ### `session/event` — emit @@ -273,7 +273,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:83`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:82`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -283,7 +283,7 @@ Awaited durability checkpoint. The agent loop awaits `ctx.sessions.flush(session 'session/flush'(this: Scoped, session: Session): Promise | void ``` -Source: [`packages/core/session/src/index.ts:101`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:100`](../../packages/core/session/src/index.ts) ## `skill/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 13de873d0c..ecb68913df 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -218,7 +218,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:590`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:592`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 757c74f399..168d727c8d 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -201,7 +201,7 @@ The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition` ### The request envelope: `LlmCallConfig` and the logged header -Requests are built by the loop, not shaped per call: the non-history half of a request — the `EpochHeader`: this call configuration plus the rendered system prompt, the tool schemas in the authoritative returned assembly order (initially canonicalized by dsh-system-prompt's `toolOrder` config, or lexicographically when unset), and the session prefix — is logged session state (`request/header` snapshot and delta events, [session.md](session.md#the-request-header-events-requestheader-and-requestheader-delta)), so every conversation request is a pure function of the session log ([reconstructability RFC](../rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). The `agent/request` waterfall receives a frozen `LlmCallConfig` seed and a listener returns a replacement to switch model or sampling; the `agent/session-prefix` waterfall — fired once per loop instance — composes the request-only messages fronting the derived history (recorded as the header's `messagePrefix`) — the loop logs whatever the request actually uses. Loop-built requests arrive at `llm/stream` deep-frozen; mutation throws. +Requests are built by the loop, not shaped per call: the non-history half of a request — the `EpochHeader`: this call configuration plus the rendered system prompt, the tool schemas in the authoritative returned assembly order (initially canonicalized by dsh-system-prompt's `toolOrder` config, or lexicographically when unset), and the session prefix — is logged as full `request/header` snapshots ([session.md](session.md#the-request-header-event-requestheader)), so every conversation request is a pure function of the session log ([reconstructability RFC](../rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). The `agent/request` waterfall receives a frozen `LlmCallConfig` seed and a listener returns a replacement to switch model or sampling; the `agent/session-prefix` waterfall — fired once per loop instance — composes the request-only messages fronting the derived history (recorded as the header's `messagePrefix`) — the loop logs whatever the request actually uses. Loop-built requests arrive at `llm/stream` deep-frozen; mutation throws. On the wire, a loop-built request reads in this order: the `system` slot (the rendered prompt assembly) → `messagePrefix` (the frozen session prefix) → the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The prefix never enters the derived history; its durable record is the header events, and the dev invariant recomputes exactly this equation against every loop-built request. @@ -244,7 +244,7 @@ type SessionEvent = { }[T] ``` -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 fourteen 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`), 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 1b12c3fc48..47517f53c3 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -64,25 +64,14 @@ interface SessionEventMap { * 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 + * request-building step (`'initial'`/`'resume'`) or when a later request's + * header changes (`'change'`); always records what the request actually + * used, post-`agent/request`. Reconstruction reads the latest snapshot. 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}: system line-trim, name-keyed - * tools delta, whole replacement config, or whole replacement session - * prefix (an EMPTY array encodes the transition to "none"). 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; messagePrefix?: Message[] } } ``` @@ -97,9 +86,9 @@ export interface TodoItem { } ``` -### The request header events: `request/header` and `request/header-delta` +### The request header event: `request/header` -The request envelope — the `EpochHeader` (call config + rendered system prompt + assembled tool schemas + the session prefix) — 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. +The request envelope — the `EpochHeader` (call config + rendered system prompt + assembled tool schemas + the session prefix) — is logged session state, so every conversation request is a pure function of the log (the reconstructability RFC). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a later changed request records another full snapshot with reason `'change'`. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message. ```ts type-equiv export interface EpochHeader { @@ -120,7 +109,7 @@ export interface EpochHeader { } ``` -Canonical form: an empty system prompt, an empty tool list, and an empty session prefix are ABSENT fields, matching how requests are built. `messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product (the request is `messagePrefix + derived history`); composed once per loop instance and anchored by that instance's snapshot, so the loop never produces a prefix delta in practice — the delta arm (whole-array replacement, an empty array encoding the transition back to absence) exists for codec totality. The other 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). +Canonical form: an empty system prompt, an empty tool list, and an empty session prefix are absent fields, matching how requests are built. `messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product (the request is `messagePrefix + derived history`); it is composed once per loop instance and included in every full snapshot that instance records. Legacy v0 logs containing the removed `request/header-delta` format are rejected at seed and persistence-load boundaries rather than replayed incompletely. ## `SessionEvent` — one log entry @@ -152,7 +141,7 @@ type SessionEvent = { ## Surface types -The five message-producing types (`SurfaceEventType` — `user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`) carry surface metadata declaring how they join the derived surface linked list. See the [session surface RFC](../rfc/implemented/architecture/2026-06-18-session-surface.md). +The five message-producing types (`SurfaceEventType` — `user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`) carry surface metadata declaring how they join the ordered derived surface. See the [session surface RFC](../rfc/implemented/architecture/2026-06-18-session-surface.md). ### `SurfaceEventType` — the message-producing subset of event types @@ -173,7 +162,7 @@ export type SurfaceOp = | { op: 'replace'; start: number; end: number } ``` -`'append'` is the normal tail-append path. `replace` shadows surface nodes from `start` through `end` inclusive (both must be valid surface node seqs; `start === end` replaces a single node) and inserts the new node in their place. +`'append'` is the normal tail-append path. `replace` shadows surface entries from `start` through `end` inclusive (both must be valid surface seqs; `start === end` replaces a single entry) and inserts the new event in their place. ### `SurfaceIntent` — the parameter to `session.append()` @@ -186,16 +175,6 @@ export interface SurfaceIntent { Required for `SurfaceEventType` events — every message-producing event must declare how it joins the surface, the sole source of derived history. Non-surface types reject it at compile time. -### `SurfaceNode` — a node in the surface linked list - -```ts type-equiv -export interface SurfaceNode { - seq: number - prev: number | null - next: number | null -} -``` - ## Derived history: `deriveMessages()` and `deriveEventMessage()` `Session.deriveMessages()` projects the event log into the `Message[]` the model sees — cached (each surface node projected once, when first seen; a surface rewrite rebuilds) and frozen (a fresh array per call over shared, deep-frozen messages, so mutating logged history through a projection is unrepresentable). `deriveEventMessage(event)` is the per-node pure function the fold applies — public so external reconstructors and the dev invariant project a log prefix with exactly the same rules and cannot disagree with the cache. The projection rules: diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index a8810a01fe..10720c8abc 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -25,10 +25,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`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:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:51`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`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:100`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:131`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:137`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:90`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 245d25fa5f..7971f84c71 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -37,7 +37,7 @@ Source: [`packages/ui/user-approval/src/index.ts:95`](../packages/ui/user-approv #### `approval/policy` — log-only -The session's approval policy was switched — log-only, durable, replayable, never in the model transcript (the model learns the policy from the prompt section and the narrator's notices). The LAST such event is the session's override (effectiveApprovalPolicy); who asked for it is derivable from position (an event after the log's last `request/header*` was a runtime switch by the user). +The session's approval policy was switched — log-only, durable, replayable, never in the model transcript (the model learns the policy from the prompt section and the narrator's notices). The LAST such event is the session's override (effectiveApprovalPolicy); who asked for it is derivable from position (an event after the log's last `request/header` was a runtime switch by the user). ```ts persistence-catalog 'approval/policy': { policy: ApprovalPolicy } @@ -57,7 +57,7 @@ Raw stream chunk — token-level replay fidelity. Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:322`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -69,13 +69,13 @@ 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:329`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) ### `bash/*` #### `bash/sandbox-mode` — log-only -The session's sandbox mode was switched — log-only (like `approval/*`; NOT a surface event, carries no `surfaceOp`): durable and replayable, never in the model transcript. The LAST such event is the session's override (effectiveSandboxMode); who asked for it is derivable from position (an event after the log's last `request/header*` was a runtime switch by the user; see the tool layer's narrator). +The session's sandbox mode was switched — log-only (like `approval/*`; NOT a surface event, carries no `surfaceOp`): durable and replayable, never in the model transcript. The LAST such event is the session's override (effectiveSandboxMode); who asked for it is derivable from position (an event after the log's last `request/header` was a runtime switch by the user; see the tool layer's narrator). ```ts persistence-catalog 'bash/sandbox-mode': { mode: SandboxMode } @@ -129,7 +129,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:320`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts) ### `hook/*` @@ -165,29 +165,19 @@ 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:314`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:279`](../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). +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 later request's header changes (`'change'`); always records what the request actually used, post-`agent/request`. Reconstruction reads the latest snapshot. 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:374`](../packages/core/session/src/types.ts) - -#### `request/header-delta` — log-only - -Amendment to the folded EpochHeader: at least one of a SystemDelta, a ToolsDelta, a whole replacement 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 SurfaceEventType. - -```ts persistence-catalog -'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] } -``` - -Source: [`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts) ### `steering/*` @@ -201,7 +191,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:347`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) ### `step/*` @@ -213,7 +203,7 @@ Closes step `step` of turn `turn`. 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -223,7 +213,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:299`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:264`](../packages/core/session/src/types.ts) ### `todo/*` @@ -239,7 +229,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:361`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:326`](../packages/core/session/src/types.ts) ### `tool/*` @@ -253,7 +243,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:335`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -277,7 +267,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:345`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts) ### `turn/*` @@ -291,7 +281,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:297`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -303,7 +293,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:291`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts) ### `user/*` @@ -317,4 +307,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:303`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts) diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index ec91aee492..5f534ae2f2 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -21,7 +21,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Prune dead public and result surface](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | | [Drop unconsumed skill provider events](proposed/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md) | 2026-07-12 | | [Prune unused web seam fields](proposed/simplification/2026-07-12-prune-unused-web-seam-fields.md) | 2026-07-12 | -| [Simplify session-log representation](proposed/simplification/2026-07-12-simplify-session-log-representation.md) | 2026-07-12 | ### Architecture @@ -100,6 +99,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Share the app bins' boot glue instead of maintaining twin copies](implemented/simplification/2026-07-04-share-app-bin-boot-glue.md) | 2026-07-04 | | [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 | | [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 | +| [Simplify session-log representation](implemented/simplification/2026-07-12-simplify-session-log-representation.md) | 2026-07-12 | ### Architecture @@ -119,7 +119,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Every session event is enclosed in a turn](implemented/architecture/2026-06-15-turn-enclosure-invariant.md) | 2026-06-15 | | [Filesystem capability seam — ctx.fs, local backend, and model-facing filesystem tools](implemented/architecture/2026-06-17-filesystem-capability-seam.md) | 2026-06-17 | | [Agent lifecycle and ownership seams](implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md) | 2026-06-18 | -| [Session surface — a linked list over the event log for LLM message derivation](implemented/architecture/2026-06-18-session-surface.md) | 2026-06-18 | +| [Session surface — an ordered projection over the event log](implemented/architecture/2026-06-18-session-surface.md) | 2026-06-18 | | [Shared persistence write coordinator](implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 | | [Branded IDs everywhere they belong](implemented/architecture/2026-06-20-branded-ids.md) | 2026-06-20 | | [Extract example apps into packages](implemented/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 | diff --git a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md index d7a15a22af..61e292aaeb 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md @@ -1,4 +1,4 @@ -# RFC: Session surface — a linked list over the event log for LLM message derivation +# RFC: Session surface — an ordered projection over the event log Status: implemented @@ -8,7 +8,7 @@ The `Session` event log is the single source of truth ([event-sourced sessions]( ## Decision -Add a **surface** — a derived, cached linked list of "surface nodes" (the subset of events that produce LLM messages) — maintained by `surfaceOp` markers in the event log. +Add a **surface** — a derived, cached order of event sequences (the subset of events that produce LLM messages) — maintained by `surfaceOp` markers in the event log. ### Two new top-level fields on `SessionEvent` @@ -25,13 +25,13 @@ export type SurfaceOp = | { op: 'replace'; start: number; end: number } // shadow [start, end] inclusive ``` -1. **Append** — add a new node to the tail. Used by `user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`. The loop passes `surfaceOp: 'append'` on all such appends, and `sourceEventSeqs` where applicable (e.g., `assistant/message` records its `assistant/chunk` sources; `tool/result` records its `tool/call` source). +1. **Append** — add the new event seq to the tail. Used by `user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`. The loop passes `surfaceOp: 'append'` on all such appends, and `sourceEventSeqs` where applicable (e.g., `assistant/message` records its `assistant/chunk` sources; `tool/result` records its `tool/call` source). -2. **Replace** — remove nodes from `start` through `end` (both inclusive) and insert a new node in their place. Both `start` and `end` must be valid surface node seqs in the current surface; `start === end` replaces a single node. The node's `sourceEventSeqs` must contain every shadowed surface node. The shadowed events remain in the log but are no longer on the surface. +2. **Replace** — remove entries from `start` through `end` (both inclusive) and insert the new event seq in their place. Both `start` and `end` must be present in the current surface; `start === end` replaces one entry. The event's `sourceEventSeqs` must contain every shadowed surface seq. The shadowed events remain in the log but are no longer on the surface. ### SurfaceManager: delta-based, not full rebuild -A `SurfaceManager` class (private to `Session`) maintains the cached linked list. It tracks `_lastProcessedSeq` and processes only the **delta** (new events since the last access) rather than rescanning the entire log. Because the log is append-only, prior events never change; a seeded log is simply the initial delta folded on first access. +A `SurfaceManager` class (private to `Session`) maintains one ordered `number[]` of event seqs. It tracks `_lastProcessedSeq` and processes only the new events since the last access rather than rescanning the entire log. Because the log is append-only, prior events never change; a seeded log is simply the initial suffix folded on first access. Replace locates its inclusive endpoints by array position and splices the replacement seq into that range; no link objects or seq-to-node map duplicate the order. Delta processing is O(1) when no new events and O(new events) when new events arrive. @@ -54,16 +54,17 @@ Because the surface is the SOLE derivation path, a surface-eligible event that c ## Alternatives considered - **Per-plugin `agent/request` wrapping** (the pre-surface pattern for history manipulation) — listener-ordering fragility, no durable record of what was changed, and every new manipulation forces another change to core `deriveMessages()`. -- **Half-open `[start, endExclusive)` replace ranges** — rejected: the surface is a doubly-linked list whose ends are naturally named by node seqs, and single-node replacement (`start === end`) reads naturally with inclusive semantics. +- **Half-open `[start, endExclusive)` replace ranges** — rejected: endpoints are named by surface event seqs, and single-entry replacement (`start === end`) reads naturally with inclusive semantics. +- **Linked node objects plus a seq map** — rejected: production did not read predecessor links, the only successor use was the next array position, and replacement already required linear `indexOf` lookup. A single seq array preserves the same asymptotic behavior with one representation to validate. - **Full rebuild behind a dirty flag** instead of delta processing — O(N²) over a session's lifetime: every single-event append would rescan all prior events. ## Consequences -- **`packages/core/session`**: New `surface.ts` (`SurfaceManager`), new types (`SurfaceOp`, `SurfaceIntent`), new fields on `SessionEvent`, modified `append()` (third required `SurfaceIntent` param), refactored `deriveMessages()` (walks the surface as the sole derivation path), surface-aware `repair.ts`. The seed constructor rejects a surface-eligible seed event missing its `surfaceOp` marker (see § Invariants). +- **`packages/core/session`**: `surface.ts` (`SurfaceManager`) maintains one ordered seq array; `SurfaceOp`/`SurfaceIntent` and the top-level session-event fields record how entries join it. `append()` requires a `SurfaceIntent` for surface events, `deriveMessages()` walks the surface as the sole derivation path, and `repair.ts` emits surface-aware closers. The seed constructor rejects a surface-eligible seed event missing its `surfaceOp` marker (see § Invariants). - **`packages/core/agent-loop`**: All surface-capable appends pass surface opts. Chunk seqs are collected for `assistant/message` provenance; `tool/call` seqs are captured for `tool/result` provenance. - **`packages/session-persistence/session-persistence-sqlite`**: Two new nullable TEXT columns (`source_event_seqs`, `surface_op`) on the `events` table; `SCHEMA_VERSION` bumped (bump-and-reject, no migration). - **`packages/support/invariants`**: Surface-related validation rules. - **`packages/session-persistence/session-persistence-jsonl`**: No changes required. - **`packages/session-persistence/session-persistence`**: Abstract interface unchanged. -The surface is the foundation for future history manipulation. A compaction or tool-result-prune plugin appends one of the existing message-producing event types (a `user/message` carrying the summary, say) with `surfaceOp: { op: 'replace', start, end }` and `sourceEventSeqs` covering the shadowed nodes — the new node takes the range's place on the surface while the plugin's own trace events (e.g. `compaction/start`, `compaction/end`) stay off it. Replay preserves the decision deterministically. +The surface is the foundation for future history manipulation. A compaction or tool-result-prune plugin appends one of the existing message-producing event types (a `user/message` carrying the summary, say) with `surfaceOp: { op: 'replace', start, end }` and `sourceEventSeqs` covering the shadowed entries — the new event takes the range's place on the surface while the plugin's own trace events (e.g. `compaction/start`, `compaction/end`) stay off it. Replay preserves the decision deterministically. diff --git a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md index 0b82e52581..38f5a690c6 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -18,13 +18,13 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro ### The mechanism -**Messages.** `Session.deriveMessages()` is cached: each surface node is projected exactly once, when first seen, through the public per-node function `deriveEventMessage(event)`; a surface rewrite (a compaction `replace` — `SurfaceManager.replaceGeneration`) rebuilds. Callers get a fresh array per call over shared, deep-frozen messages: mutating logged history through a projection is unrepresentable (it throws), replacing the old clone-per-call isolation. External reconstructors fold the same public function over a log prefix, so no two paths can disagree. +**Messages.** `Session.deriveMessages()` is cached: each surface entry is projected exactly once, when first seen, through the public per-event function `deriveEventMessage(event)`; a surface rewrite (a compaction `replace` — `SurfaceManager.replaceGeneration`) rebuilds. Callers get a fresh array per call over shared, deep-frozen messages: mutating logged history through a projection is unrepresentable (it throws), replacing the old clone-per-call isolation. External reconstructors fold the same public function over a log prefix, so no two paths can disagree. -**The header.** The request's non-history half — `EpochHeader`: call config (`LlmCallConfig`: model + sampling scalars), rendered system prompt, assembled tool schemas, and the session prefix (`messagePrefix`, below) — is logged session state, in canonical form (empty system/tools/prefix ≡ absent). Two log-only, turn-enclosed events in dsh-session carry it: `request/header`, a full snapshot with reason `'initial' | 'resume' | 'fallback'`, and `request/header-delta`, an amendment (`SystemDelta`: a common-prefix/suffix line trim; `ToolsDelta`: name-keyed added/removed/changed; `config`: replaced whole; `messagePrefix`: replaced whole, an empty array encoding the transition to absence — an arm the loop never exercises in practice, kept for codec totality). The pure trio `foldRequestHeader` / `diffHeader` / `applyHeaderDelta` reconstructs; the live session tracks the fold with the same lazy cursor as the message cache. Snapshots anchor the fold where a fold needs anchors — conversation birth and process boundaries — and each loop instance appends one on its first request (`'initial'` when the log has none, `'resume'` otherwise, even when nothing changed: the boundary itself is a recorded fact, and cross-restart drift becomes attributable while an unchanged header resumes byte-identical). Deltas are an encoding optimization with a safety valve, never a correctness dependency: the writer verifies `applyHeaderDelta(prev, delta)` reproduces the new header exactly and records a `'fallback'` snapshot when the encoding cannot express a change (a pure tool reordering), so a well-formed log always folds. +**The header.** The request's non-history half — `EpochHeader`: call config (`LlmCallConfig`: model + sampling scalars), rendered system prompt, assembled tool schemas, and the session prefix (`messagePrefix`, below) — is logged session state in canonical form (empty system/tools/prefix ≡ absent). One log-only, turn-enclosed event carries it: `request/header`, always a full snapshot. Each loop instance appends one on its first request (`'initial'` when the log has none, `'resume'` otherwise, even when nothing changed: the boundary itself is a recorded fact and cross-restart drift becomes attributable); a later request whose canonical header differs appends another with reason `'change'`. `foldRequestHeader` reconstructs by selecting the latest snapshot, and the live session tracks that fold with the same lazy cursor as the message cache. Legacy v0 logs containing the removed delta representation are rejected at seed and persistence-load boundaries rather than partially replayed. **The loop, transmission-stateless.** Per step: render assembly (every step — value comparison needs no change-signal discipline, and a section that varies per step surfaces as a *logged* header event per step instead of a silent bust) → on the instance's FIRST step only, the `agent/session-prefix` waterfall — request-ONLY messages fronting the entire derived history (a frozen empty seed, contributions returned as an extension of `next()`; the home for session-stable openers that must NOT become history — a skills catalog, an AGENTS.md digest), deep-frozen and cached on the instance so reuse is structural and the prefix cannot drift mid-session — → `agent/pre-step`, carrying the composed prefix (compaction's surface mutations land before derivation, and its pressure gate counts the prefix this instance will actually send — never a previous instance's logged one, which could under-gate a resumed/forked instance whose contributor grew) → **messages snapshot, then `step/start` appended as the next operation in the same synchronous frame** → seed the call config (first request of the instance: from `AgentOptions`, so explicit options always beat the logged baseline — fork model-overrides and resume reconfiguration stay correct; afterwards: from the folded header) → the `agent/request` waterfall, re-typed `(agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig` — a frozen seed and a returned replacement are ALL a listener shapes; durable content flows through the log channels (`inject()`, steering, prompt-submit `additionalContext`, sections via `system-prompt/assemble`) — → the header event the request owes the log, carrying the prefix as `messagePrefix` (no session event carries it, so the header is its only durable record; resume = a new instance = a recompose, anchored by its `'resume'` snapshot) → build `GenerateOptions` from `messagePrefix + snapshot` + header, deep-freeze (`deepFreeze` exempts the `AbortSignal`, the one live control channel — freezing one breaks `AbortController.abort()`), dispatch. The loop's per-instance bookkeeping is one boolean plus the cached prefix: whether this instance has logged its anchoring snapshot, and what it composed. -**The reconstruction boundary is `step/start`, unconditionally.** A step's messages are the derivation over `events[0..stepStartSeq)`. Because the snapshot precedes the `step/start` append in the same synchronous frame, an `agent.inject()` from an `agent/request` listener or any concurrent task lands after the boundary and joins the NEXT request. `session/event` is observe-only during publication: a reentrant append is rejected until the current callback list drains, preventing nested event delivery from overtaking the event being observed. `agent/pre-step` is the sanctioned seam for content that must affect the CURRENT request. A step's header for reconstruction is the fold after its own `request/header*` event (which sits between its `step/start` and first response event) or the fold carried forward. +**The reconstruction boundary is `step/start`, unconditionally.** A step's messages are the derivation over `events[0..stepStartSeq)`. Because the snapshot precedes the `step/start` append in the same synchronous frame, an `agent.inject()` from an `agent/request` listener or any concurrent task lands after the boundary and joins the NEXT request. `session/event` is observe-only during publication: a reentrant append is rejected until the current callback list drains, preventing nested event delivery from overtaking the event being observed. `agent/pre-step` is the sanctioned seam for content that must affect the CURRENT request. A step's header for reconstruction is the latest `request/header` at or after its `step/start` (before the first response event), or the fold carried forward when the request header is unchanged. **Enforcement.** Dev-mode ([dsh-invariants](../../../../packages/support/invariants/src/index.ts)), on `llm/stream`: a frozen request with a live `sessionId` — the loop-built marker; hand-built one-shots are unfrozen and skipped — must carry messages deep-equal to the folded header's `messagePrefix` followed by the boundary derivation — the derivation rebuilt through a FRESH `Session` over `events[0..stepStartSeq)` so the live cache cannot vouch for itself — and header fields equal to `foldRequestHeader` over the log. There is no divergence allowance and nothing to allow: no seam can put unlogged content into a request — the `agent/session-prefix` seam's product enters only because the header event records it first. `prepend: true` only defends against the replay adapter's short-circuit (an append-registered listener); two prepended listeners have no defined mutual order in cordis, so correctness rests on the seq-bounded fold, never on listener timing. Measurement stays lean: the with-key e2e ([request-cache.e2e.ts](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts)) proves `usage.cacheReadTokens > 0` on every request after the first against the live API, and per-step usage in the log is the production observable — a header event or compaction shows up as a cache-read collapse on the next step. @@ -39,15 +39,16 @@ What survives from `LLMClient`: the conversation is maintained, not rebuilt — - **Per-call request scalars** (a freely mutable config handed to each `agent/request` dispatch): a listener flips the model per call with zero accounting, silently abandoning the provider cache this design exists to protect. Config is per-conversation logged state; the waterfall proposes, the log records. - **Detect-and-report** (compare consecutive requests, warn on divergence): catches violations after the fact; a violating request is still constructible and ships. Rejected for interface-level unrepresentability. - **Event-driven assembly** (re-render only on change signals): a missed-signal bug class — a tool registered mid-session emits `tools/change`, not `system-prompt/change`, and a third-party provider may emit nothing. Per-step render + value compare is robust with zero signal discipline. -- **Narrative fields on the header events** (a `reason`/`changed` list on deltas): derivable by diffing consecutive events — one home per fact; snapshots carry a reason because an anchor's cause is NOT derivable from the data. +- **A custom header-delta codec** (system line edits, name-keyed tool edits, whole config/prefix replacements): it reduced repeated header bytes but duplicated state across codec types, diff/apply machinery, and fallback handling. Full changed snapshots preserve reconstructability with one representation; compression remains available if measured logs justify it. +- **Narrative changed-field lists on header snapshots**: derivable by diffing consecutive snapshots — one home per fact. Snapshots keep a reason because an instance boundary versus an in-instance change is not derivable from data alone. ## Consequences - A request that is not explained by the log cannot be constructed by accident — not by the loop, not by a listener; mutating a built request throws; every header change is a durable, diffable log event. - Choosing between the advisory channels is a change-frequency decision, and the design makes the stable one structural: an `agent/session-prefix` contribution is composed once per loop instance and reused verbatim, so it extends the cacheable prefix at zero marginal cost and CANNOT bust the provider cache mid-session; content that changes mid-session flows through the append-only history channels — `agent.inject()`, a `tools/post-execute` decision's `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter, at the price of accumulating in history and the log. Route session-frozen openers to the prefix and change notices to the history channels; a per-step request-only tail slot was deliberately dropped (no consumer, and a durable append covers every current update pattern). -- What still costs full price at the provider is inherent and logged: compaction (its `compact/*` events and replace node), a real prompt/tool change (`request/header-delta`), a config switch (ditto), a process boundary with drift (`'resume'` snapshot differing from its predecessor). The provider's own reasoning-content exclusion is managed server-side. +- What still costs full price at the provider is inherent and logged: compaction (its `compact/*` events and surface replacement), a real prompt/tool/config change (`request/header` with reason `'change'`), or a process boundary with drift (`'resume'` snapshot differing from its predecessor). The provider's own reasoning-content exclusion is managed server-side. - The `step/start`-listener behavior change (above) is the one observable semantics change for plugins; `agent/pre-step` is the current-request seam. -- Tool-result trimming (planned) needs no new mechanism: a logged single-node surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic. -- Session logs grow one `request/header` snapshot per conversation (system + tool schemas: the dominant term), plus deltas on real changes — small next to `assistant/chunk` volume; `SESSION_FORMAT_VERSION` stays `0` (pre-release churn is absorbed, backends reject-not-migrate). +- Tool-result trimming (planned) needs no new mechanism: a logged single-entry surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic. +- Session logs grow one `request/header` snapshot per loop instance plus full snapshots on real changes. This spends more bytes than a custom delta codec but stays small beside chunk-heavy logs and leaves one replay representation. `SESSION_FORMAT_VERSION` stays `0`; a legacy v0 delta event is rejected rather than migrated. - Snapshot goldens changed once (every transcript gains its header events); the fs-writing fixtures are stored in the normalized authored form with cwd-relative tool arguments, because replay only round-trips cwd-independent argument paths. - FIXME(call-config-shape): revisit `LlmCallConfig`'s exact field set — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit there out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them. diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index bba12727f7..e0835dcaa6 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -6,7 +6,7 @@ Status: implemented A long-running agent conversation grows without bound. As the event log accumulates turns, the derived message history eventually approaches the model's context window — the model then truncates mid-response (`max-tokens`) or degrades. **Compaction** is the mitigation: replace a run of older history with a concise summary, keeping recent context intact. -The [session surface](../../implemented/architecture/2026-06-18-session-surface.md) was built as the foundation for exactly this — a linked list over the event log with a `surfaceOp: { op: 'replace', start, end }` operation purpose-built to shadow a range of nodes and insert a replacement, with `sourceEventSeqs` recording provenance so the decision replays deterministically. What remained was the plugin that *decides what to compact and produces the summary*. +The [session surface](../../implemented/architecture/2026-06-18-session-surface.md) was built as the foundation for exactly this — an ordered projection over the event log with a `surfaceOp: { op: 'replace', start, end }` operation purpose-built to shadow a range of entries and insert a replacement, with `sourceEventSeqs` recording provenance so the decision replays deterministically. What remained was the plugin that *decides what to compact and produces the summary*. Two forces shape the design. First, compaction is **swappable**: token counting can be a char/4 heuristic or a real tokenizer, and summarization can be a model call, a template, or a remote service — these vary independently of *when* and *which range* to compact. Second, `SurfaceEventType` is closed to five event types (`user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`); only those may carry `surfaceOp`. A bespoke `compaction/*` event therefore **cannot** itself appear on the surface — the compiler rejects `surfaceOp` on it and the invariants plugin rejects it at runtime. @@ -54,7 +54,7 @@ This **amends** the original RFC's claim of "NO changes to `dsh-agent-loop`; com Auto-compaction fires before **every** step, not once per turn. This is **load-bearing for runaway-turn survival**: a tool-heavy ReAct turn appends an `assistant/message` + a `tool/result` per step, so the surface grows *within* a turn. A single turn can grow past the window on its own (a "runaway turn") — and the only moment to rescue it before the next model call overflows is the next step's `pre-step` checkpoint. Gating compaction to a turn's first step (or, worse, retaining the whole in-flight turn verbatim) re-opens exactly the hole compaction exists to close: the harness would die when compaction is most needed. -So retention does **not** protect the in-flight turn, and turn boundaries play no role in it. `compactIfNeeded` walks the surface nodes tail→head, summing per-node token estimates, and retains the smallest tail-run of **whole units** whose total reaches `retainTokens`; everything older is compacted (head-anchored — see below). A *unit* is either a whole closed step (its `assistant/message` plus its `tool/result`s) or a single no-step node (a pre-step `user/message`, inter-step `steering/message`, or injection `context/message`). The walk rounds toward retaining *more*: when the raw token cutoff lands mid-step, it extends the retained side head-ward until the cut before the retained node is **tool-pairing balanced**. The single structural guard is therefore **tool-pairing balance** — a region's edges are balanced cuts on the *surface* (no unanswered `tool-call` crosses either edge), so a compacted region never splits a step's tool-calls from their `tool/result`s (which would produce a transcript every provider rejects). The check is decided over the surface linked list, **not** the log's `step/*` markers: a compaction lands a replacement node at a high log seq whose surface position is the head, so a log-position scan mis-reads its neighbours — `dsh-session` exports `isToolPairingBalanced(nodes, events, beforeSeq)` for the surface-anchored check. `compactRegion` enforces it strictly, throwing on a boundary that would split a step. +So retention does **not** protect the in-flight turn, and turn boundaries play no role in it. `compactIfNeeded` walks the surface entries tail→head, summing per-entry token estimates, and retains the smallest tail-run of **whole units** whose total reaches `retainTokens`; everything older is compacted (head-anchored — see below). A *unit* is either a whole closed step (its `assistant/message` plus its `tool/result`s) or a single no-step entry (a pre-step `user/message`, inter-step `steering/message`, or injection `context/message`). The walk rounds toward retaining *more*: when the raw token cutoff lands mid-step, it extends the retained side head-ward until the cut before the retained entry is **tool-pairing balanced**. The single structural guard is therefore **tool-pairing balance** — a region's edges are balanced cuts on the *surface* (no unanswered `tool-call` crosses either edge), so a compacted region never splits a step's tool-calls from their `tool/result`s (which would produce a transcript every provider rejects). The check is decided over surface order, **not** the log's `step/*` markers: a compaction lands a replacement at a high log seq whose surface position is the head, so a log-position scan misreads its neighbours — `dsh-session` exports `isToolPairingBalanced(nodes, events, beforeSeq)` for the surface-anchored check. `compactRegion` enforces it strictly, throwing on a boundary that would split a step. A runaway turn thus compacts exactly like any other history: its early *closed* steps get summarized while its recent steps stay verbatim. When the only compactable content left is an un-splittable open tail step (its tool-calls have no results yet), compaction declines (`null`) and retries once that step closes. diff --git a/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md index 90bd274eb3..a708225482 100644 --- a/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md +++ b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md @@ -20,7 +20,7 @@ The list is appended as a `todo/write` event carrying the full `{ todos }` snaps ### NOT a surface event -`todo/write` is deliberately excluded from `SurfaceEventType`. The surface is the projection that produces the LLM message history (`deriveMessages()`); a todo write produces no conversation message. So it carries no `surfaceOp`, never joins the surface linked list, and never reaches `deriveMessages()` — it is durable, replayable *UI* state that travels alongside the conversation without being part of it. (The dev-mode invariants still require it to sit inside an open turn, which it always does: it is appended mid-step during a tool call.) +`todo/write` is deliberately excluded from `SurfaceEventType`. The surface is the projection that produces the LLM message history (`deriveMessages()`); a todo write produces no conversation message. So it carries no `surfaceOp`, never joins the ordered surface, and never reaches `deriveMessages()` — it is durable, replayable *UI* state that travels alongside the conversation without being part of it. (The dev-mode invariants still require it to sit inside an open turn, which it always does: it is appended mid-step during a tool call.) ### Priority synthesized only at the ACP boundary diff --git a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md index 39345c2b53..048c359948 100644 --- a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md +++ b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md @@ -39,7 +39,7 @@ Config plumbing follows the `persona` precedent, and `toolOrder` sits beside it: - Every registry-built assembly starts with a deterministic tool order on every host; absent an expert listener that deliberately changes it, every `request/header` event and model request inherits that order. The CI-vs-local registration-order flip is structurally gone, and the default is lexicographic. - The initial `PromptAssembly.tools` is canonical, so waterfall listeners start from the model-facing order; provider registration order is observable nowhere before that cooperative seam. - The snapshot suite's single pinned request-header fixture (`text-turn`) carries the new canonical tool order; every other ACP snapshot keeps the header bulk scrubbed as `{{system}}`/`{{tools}}`, per the pinned-header design. -- A pure tool reordering between steps is representable only as a `request/header` `'fallback'` snapshot (the name-keyed `ToolsDelta` cannot express it); with a stable canonical order such reorders no longer occur in practice, so the fallback path stays a safety valve. +- A pure tool reordering between steps is logged like any other header change: a full `request/header` snapshot with reason `'change'`. Stable canonical order prevents registration timing from creating such changes in the ordinary path. - The `toolOrder` key rides the app → `agent-core` → `SystemPrompt` forwarding chain, so deployments set it next to `persona` in the app config; `dsh-llm` and the agent loop are untouched. - A misspelled or unloaded tool name in `toolOrder` fails the turn at prompt assembly, not the boot: the loop assembles inside the turn (after `turn/start`, before `step/start`), so the rejection reaches the turn's outer catch — the turn closes balanced with an `error` reason carrying the message, `agent/error` mirrors it, no step opens, no `request/header` is logged, no request reaches the adapter, and the agent returns to idle. Every turn fails identically until the config is fixed; the process itself stays up (matching the repo rule that explicit config references must not be silently ignored — the enforcement point is the assembly because no earlier universal moment exists). - A tool provider that returns the reserved rest-entry name has the same prompt-assembly failure shape as an unknown listed name. This keeps the sentinel from becoming an ambiguous real tool and preserves the "never drops a tool" ordering contract. diff --git a/docs/rfc/implemented/feature/2026-07-06-sandbox.md b/docs/rfc/implemented/feature/2026-07-06-sandbox.md index e3e740b31c..cbfe4feee3 100644 --- a/docs/rfc/implemented/feature/2026-07-06-sandbox.md +++ b/docs/rfc/implemented/feature/2026-07-06-sandbox.md @@ -118,7 +118,7 @@ interface SessionEventMap { Each owner exports the same three-piece kit: the event declaration, a pure fold (`effectiveSandboxMode(events)` / `effectiveApprovalPolicy(events)` — a `findLast`, typed to the domain's closed union), and THE write path (`setSandboxMode(session, mode)` / `setApprovalPolicy(session, policy)` — a switch IS its event; nothing mutates state out of band). No shared owner service, no generic facts map, no registry: a third knob copies the ~40-line pattern into its own package. Execution follows the fold on both sides — the bash tool's per-call stamp reads it as the middle rung of the § Escalation precedence chain, and the approval seam's `'never'` gate is [the approval RFC](2026-07-06-approval-seam.md)'s side of the same pattern. -**Visibility is deliberately asymmetric between the knobs.** The SANDBOX mode is stated nowhere and its switches are not narrated: a standing "you are read-only" declaration teaches the model to refuse preemptively (observed live: sessions where the model would not even attempt a write it could have escalated), while the denial marker already names the mode the command ran under at exactly the moment the boundary matters — behavior, not belief, carries the state, and a switch simply changes what the next command does. The APPROVAL policy keeps both layers, because its failure mode is the opposite: an auto-rejected ask under `'never'` returns "the user rejected …" wording no behavior can disambiguate, so the prompt states `'never'` (and ONLY `'never'` — an `'ask'` promise is unknowable without asking, and absence under a logged header is how the narrator reads `'ask'` back), and an `agent/pre-step` narrator injects at most one coalesced notice per policy switch: idle flip-flops collapse to one notice at the next turn's first step, a net-zero round trip to none, and a mid-turn change is narrated no later than the next step. Its "last told" is in-memory with a log-derived fallback (the folded header's system text parsed against the closed candidate sentence; LAST occurrence wins, so a persona quoting it cannot shadow the real section), so restarts lose nothing; attribution is positional (a knob event after the log's last `request/header*` reads `changed by the user`, a drift with no such event reads `changed by the operator/config`). +**Visibility is deliberately asymmetric between the knobs.** The SANDBOX mode is stated nowhere and its switches are not narrated: a standing "you are read-only" declaration teaches the model to refuse preemptively (observed live: sessions where the model would not even attempt a write it could have escalated), while the denial marker already names the mode the command ran under at exactly the moment the boundary matters — behavior, not belief, carries the state, and a switch simply changes what the next command does. The APPROVAL policy keeps both layers, because its failure mode is the opposite: an auto-rejected ask under `'never'` returns "the user rejected …" wording no behavior can disambiguate, so the prompt states `'never'` (and ONLY `'never'` — an `'ask'` promise is unknowable without asking, and absence under a logged header is how the narrator reads `'ask'` back), and an `agent/pre-step` narrator injects at most one coalesced notice per policy switch: idle flip-flops collapse to one notice at the next turn's first step, a net-zero round trip to none, and a mid-turn change is narrated no later than the next step. Its "last told" is in-memory with a log-derived fallback (the folded header's system text parsed against the closed candidate sentence; LAST occurrence wins, so a persona quoting it cannot shadow the real section), so restarts lose nothing; attribution is positional (a knob event after the log's last `request/header` reads `changed by the user`, a drift with no such event reads `changed by the operator/config`). **The editor surface** is protocol-native [Session Config Options](https://agentclientprotocol.com/protocol/session-config-options) — the spec's replacement for session modes (slated for removal in ACP v2), already SDK-typed. The bridge advertises one independent `select` per composable knob — `sandbox-mode` (category `mode`) iff the mounted executor confines, `approval-policy` iff the approval seam is composed — with `currentValue` folded from each session's own log, in `session/new` and `session/load` responses. `session/set_config_option` validates against the same closed lists, routes to the domain setter, and returns the complete refreshed state (the spec contract). @@ -135,7 +135,7 @@ FIXME: Revisit this tool-local boundary. The follow-up design needs to determine - Unit tier (no real runner anywhere): profile dialects, per-platform chain selection (sole candidate unprobed, no chain fails closed, multi-candidate probe order), verdict caching, the fail-closed end, probe-report parsing, and the launcher/`sandbox-exec` CLI contracts via fake runner scripts in `dsh-sandbox-local`; wrapping, policy hand-off, fact stamping, and runner-failure-outranks-denial classification (foreground throw, background `runnerFailed` fact) against a fake provider in `dsh-bash-sandbox`; the error's structured identity in `dsh-sandbox`. The escalation matrix spans the three bash packages: verbatim carry-through in `dsh-bash-local`, stamp/branch/per-task-facts in `dsh-bash-sandbox`, and the capability gate, `justification` pairing, fail-closed texts (pinned verbatim), and grant stamping in `dsh-tool-bash`. The switching surface pins the folds, the stamping precedence, the `'never'` gate, per-session section rendering, the full narrator matrix (cold start, coalescing, net-zero, resume drift with operator wording, positional attribution, persona-shadow hardening), and the bridge's advertisement gating, validation rejections, idle-vs-mid-turn anchoring (dev invariants mounted), and `session/load` reporting over a real two-process JSONL round trip. - Keyless real-runner e2e, split along the seam and per rung: CI's `sandbox-e2e` matrix runs bwrap and Landlock on Linux (the Landlock leg once per architecture, each confining through the registry-installed launcher) and Seatbelt on macOS against real kernels, failing on a silent all-skip. World-proofs live in `dsh-sandbox-local` (denied writes absent on disk, workspace writes landing, temp-area grants pinned, kernel denial text matching the advertised dialect) and `dsh-bash-sandbox` (the through-`ctx.bash` consumer proofs, including denied-then-overridden-write-lands). This package's own publish path is rehearsed without publishing (`packed-install.e2e.ts`): `pnpm pack`, tarballs installed into a throwaway consumer with the launcher family resolving from the registry, plain `node` confining through the INSTALLED launcher — asserted executable apart, so a mode-stripped binary can never masquerade as a non-enforcing kernel. The switching surface has its own keyless e2e (`examples/sandbox-acp-agent`): the real `cordis.yml` tree advertises both options, honors switches end to end, and rejects out-of-vocabulary values. - With-key e2e (`examples/sandbox-acp-agent/tests/escalation.e2e.ts`): real model + real runner + the REAL bridge answerer, world-verified — denied under `read-only`, escalates with justification, the scripted editor grants and the retried write lands on disk, while a rejected escalation leaves no write. Self-skips without `DEEPSEEK_API_KEY` or a usable runner (e2e.yml installs bubblewrap so it actually executes in CI). -- Snapshot tier (`examples/sandbox-acp-agent/tests/acp.snapshot.ts`): the keyless config-option wire; the recorded mode-switching arc as the suite's pinned header — necessarily, since mid-session switches emit the `request/header-delta`s the uniformity guard licenses only in the pin — committing both switches, the prompt-section delta and one "changed by the user" notice per knob, and a confined write landing under the switched mode; and both recorded escalation branches over scripted `permissionAnswers` (grant runs confined under `workspace-write`; rejection executes nothing and pins the fail-closed text). Replay re-executes every fixture's bash calls under the host's real runner (ci.yml's snapshot lane installs bubblewrap). Deliberately absent: a fixture carrying a real DENIAL — denial stderr is the backend's dialect and would pin a fixture to its recording platform; the escalation prompts assert the prior denial instead, and the denial→marker path stays on the tiers above. +- Snapshot tier (`examples/sandbox-acp-agent/tests/acp.snapshot.ts`): the keyless config-option wire; the recorded mode-switching arc as the suite's pinned header — necessarily, since its mid-session switch emits the changed `request/header` the uniformity guard licenses only in the pin — committing both switches, the full changed prompt and one "changed by the user" notice per knob, and a confined write landing under the switched mode; and both recorded escalation branches over scripted `permissionAnswers` (grant runs confined under `workspace-write`; rejection executes nothing and pins the fail-closed text). Replay re-executes every fixture's bash calls under the host's real runner (ci.yml's snapshot lane installs bubblewrap). Deliberately absent: a fixture carrying a real DENIAL — denial stderr is the backend's dialect and would pin a fixture to its recording platform; the escalation prompts assert the prior denial instead, and the denial→marker path stays on the tiers above. ## Deferred phases @@ -168,7 +168,7 @@ Each phase gets its full design when picked up, validated against the code at th - **A generic `env/state` facts map with an owner service** — rejected: approval and sandbox compose independently, so neither's state may drag in a third package; single-key folds are one `findLast` each, dissolving the owner service; no invariant spans the knobs, so atomic multi-key patches bought nothing. - **Narrate via `agent/user-message` + a bus event** — rejected: it presupposes a turn-entry seam that does not exist (the real seam is `agent/prompt-submit`), and pre-step's position serves both the coalesced turn-entry notice and the mid-turn immediacy bound with one listener. - **A standing prompt statement of the sandbox mode (+ a switch narrator)** — shipped first, then removed on live evidence: with `Bash commands run under the "read-only" file sandbox.` in every request, the model refused to ATTEMPT denied-then-escalatable work (five of twelve turns in the first manual session ended with zero tool calls), turning the sandbox into a soft lockout. The denial marker names the mode at the moment it matters and the escalation fields carry the recovery; the approval knob keeps its statement because an auto-rejection is behaviorally indistinguishable from a human "no". -- **Track "last told" with its own bookkeeping events** — rejected: the `request/header*` fold already records the exact prompt the model saw; parsing the closed candidate sentences back replaces a second bookkeeping stream — events are needed only where they ARE the store. +- **Track "last told" with its own bookkeeping events** — rejected: the latest `request/header` already records the exact prompt the model saw; parsing the closed candidate sentences back replaces a second bookkeeping stream — events are needed only where they ARE the store. - **ACP session modes instead of config options** — rejected: one mode list cannot carry two orthogonal knobs; config options are the spec's designed surface and modes are slated for removal in ACP v2. ## Consequences diff --git a/docs/rfc/implemented/feature/2026-07-07-session-prefix.md b/docs/rfc/implemented/feature/2026-07-07-session-prefix.md index 6f81d12407..153a93f873 100644 --- a/docs/rfc/implemented/feature/2026-07-07-session-prefix.md +++ b/docs/rfc/implemented/feature/2026-07-07-session-prefix.md @@ -22,21 +22,20 @@ Because composition runs before the boundary snapshot, a composing listener's se ## Testing -**Unit** — [interception.spec.ts](../../../../packages/core/agent-loop/tests/interception.spec.ts) pins compose-once across turns and steps (one composition, zero `request/header-delta`s), canonical prepend ordering, empty-prefix omission from the header, the frozen seed (in-place push throws), held-reference mutation immunity, and composition-precedes-pre-step with the seam receiving the composed value; [cancel.spec.ts](../../../../packages/core/agent-loop/tests/cancel.spec.ts) pins cancel/dispose landing inside the composition window and the discard-and-recompose stale-cache guard; dsh-session codec tests cover the `messagePrefix` fold/diff/apply arms (empty ≡ absent); dsh-invariants tests pin the `messagePrefix + derivation` equation; dsh-compact-basic tests pin that the pressure estimate counts the handed prefix. **Snapshot** — the acp-snapshot normalizer scrubs header prefixes to count-preserving `{{messagePrefix}}` tokens (unit-covered in dsh-acp-snapshot); header content itself is pinned per [the pinned-header scenario RFC](../testing/2026-07-06-pin-request-header-content-in-one-scenario.md), and the example tree loads no prefix contributor, so live goldens stay prefix-free. **e2e** — none prefix-specific: the seam is provider-independent and deterministic; the with-key cache measurement in [request-cache.e2e.ts](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts) already proves the cacheable-prefix economics the design rests on. +**Unit** — [interception.spec.ts](../../../../packages/core/agent-loop/tests/interception.spec.ts) pins compose-once across turns and steps (one composition and no changed headers), canonical prepend ordering, empty-prefix omission from the header, the frozen seed (in-place push throws), held-reference mutation immunity, and composition-precedes-pre-step with the seam receiving the composed value; [cancel.spec.ts](../../../../packages/core/agent-loop/tests/cancel.spec.ts) pins cancel/dispose landing inside the composition window and the discard-and-recompose stale-cache guard; dsh-session header tests cover canonical prefix snapshots and latest-snapshot folding; dsh-invariants tests pin the `messagePrefix + derivation` equation; dsh-compact-basic tests pin that the pressure estimate counts the handed prefix. **Snapshot** — the acp-snapshot normalizer scrubs header prefixes to count-preserving `{{messagePrefix}}` tokens (unit-covered in dsh-acp-snapshot); header content itself is pinned per [the pinned-header scenario RFC](../testing/2026-07-06-pin-request-header-content-in-one-scenario.md), and the example tree loads no prefix contributor, so live goldens stay prefix-free. **e2e** — none prefix-specific: the seam is provider-independent and deterministic; the with-key cache measurement in [request-cache.e2e.ts](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts) already proves the cacheable-prefix economics the design rests on. ## Alternatives considered -- **Per-request `before`/`after` slots recomputed every step** (the shape first proposed: a waterfall firing on every request, contributing frozen `before` messages ahead of the history and fresh `after` messages behind it) — rejected. A per-step `before` recompose invites silent drift — nothing anchors it to the log short of logging a header delta per step — and an `after` slot sits behind the growing history, so its tokens re-pay on every request and everything after it is uncacheable. Measured against the alternatives, every current update pattern is served cheaper by a durable append (paid once, cache-read thereafter), and the only content with no home was the session-stable opener — which wants freezing, not recomputation. -- **A system-prompt section** (`system-prompt/assemble`) — rejected for this content: the assembly renders to the single `system` string, so message-shaped openers do not fit, and the system prompt is deliberately re-assembled per step (with header deltas when it changes) while the opener wants instance-frozen semantics. +- **Per-request `before`/`after` slots recomputed every step** (the shape first proposed: a waterfall firing on every request, contributing frozen `before` messages ahead of the history and fresh `after` messages behind it) — rejected. A per-step `before` recompose invites drift that must be logged as a full changed header, and an `after` slot sits behind the growing history, so its tokens re-pay on every request and everything after it is uncacheable. Measured against the alternatives, every current update pattern is served cheaper by a durable append (paid once, cache-read thereafter), and the only content with no home was the session-stable opener — which wants freezing, not recomputation. +- **A system-prompt section** (`system-prompt/assemble`) — rejected for this content: the assembly renders to the single `system` string, so message-shaped openers do not fit, and the system prompt is deliberately re-assembled per step (with a full changed header when it changes) while the opener wants instance-frozen semantics. - **A durable history opener** (`inject()` at session start) — rejected: permanent history is the failure mode in the problem statement — replayed everywhere, compactable, stale across resumes. -- **Compose per turn instead of per instance** — rejected: a turn-boundary recompose either desyncs silently from the log or forces a header delta per change, and it busts the provider cache exactly as often as it fires; the legitimate refresh point is the instance boundary, where the `'resume'` snapshot already records drift attributably. +- **Compose per turn instead of per instance** — rejected: a turn-boundary recompose either desyncs silently from the log or forces a full changed header, and it busts the provider cache exactly as often as it fires; the legitimate refresh point is the instance boundary, where the `'resume'` snapshot already records drift attributably. - **Compose lazily at the first request and let compaction read the folded header** (the shape as first merged) — superseded in review: the fold matches the live prefix only from the instance's second request on, so on a resumed/forked instance's first step the pressure gate read the PREVIOUS instance's prefix and could under-gate. Composing before the first pre-step and handing the live value through the seam makes the estimate exact at every step. -- **A dedicated session event carrying the prefix** — rejected: the header events are the request's non-history record by design; a second event would be a second home for the same fact and another codec to keep total. +- **A dedicated session event carrying the prefix** — rejected: request headers are the request's non-history record by design; a second event would be a second home for the same fact. ## Consequences - `agent/pre-step` and `CompactService.compactIfNeeded` carry a `sessionPrefix` parameter: every pre-step listener and compaction backend sees the real per-instance value (all in-repo implementations updated in the same change, per the pre-release stance). - A contributor whose content changes mid-session is not re-read until the next instance — by design. A deployment needing mid-session catalog updates routes the change notice through the append-only history channels and pays one durable `context/message`. - The dropped `after` slot leaves no request-only channel near the request tail; nothing in the repo needs one, and adding it back would re-open the every-step re-pay cost the design exists to avoid. -- The `request/header-delta` `messagePrefix` arm (whole-array replacement, empty array encoding transition to absence) exists for codec totality; the loop never exercises it, because the cached prefix cannot change within an instance. - An empty composition is canonical absence: no-contributor deployments log no extra header bytes and their requests are the bare derivation. diff --git a/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md b/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md index 3ec6c75cbc..0899065b71 100644 --- a/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md +++ b/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md @@ -52,7 +52,7 @@ Freshness is gated like every generated artifact: `pnpm run verify-cordis-api` ( The plugin exposes one config field, validated by schemastery and documented in [the config catalog](../../../config-catalog.md): `vmTimeoutMs` (default 5000), the millisecond bound on the synchronous portion of mount-code evaluation. Tool names, the `cordis-dynamic` group name, and the `dyn-` id prefix are structural vocabulary and stay fixed. All three tools render as `generic` cards per [the tool cookbook](../../../cookbook/adding-a-tool.md) (`cordis_inspect` a `read`, `cordis_mount` an `execute` carrying the code as `rawInput`, `cordis_unmount` a `delete`), with no `presentResult` overrides. -Model-visible ⟺ logged holds with no new session event type: a mount or unmount is visible only through its own `tool/call` / `tool/result` pair, which the loop logs, and the changed tool set a mount induces is logged by the request-header delta the loop already emits when schemas change between steps. There is deliberately no `cordis/mount` provenance event — it would duplicate what the tool-call pair records. Dynamic mounts are process-lifetime, not session state: resuming a persisted session rehydrates the conversation but does not re-mount plugins. +Model-visible ⟺ logged holds with no new session event type: a mount or unmount is visible only through its own `tool/call` / `tool/result` pair, which the loop logs, and the changed tool set a mount induces is logged by the full changed request header the loop emits when schemas change between steps. There is deliberately no `cordis/mount` provenance event — it would duplicate what the tool-call pair records. Dynamic mounts are process-lifetime, not session state: resuming a persisted session rehydrates the conversation but does not re-mount plugins. ## Alternatives considered @@ -71,7 +71,7 @@ The correctness investment therefore goes where it pays for every capability at **A hand-maintained service/event reference in the tool.** The first cut of the inspect tool carried a hand-written table of service method signatures. It was replaced by the generated `api-catalog.ts` because a hand table drifts from the JSDoc the moment a signature changes and nothing gates the drift, whereas the generated artifact is freshness-checked against the same AST the docs use. -**A new `cordis/mount` session event.** A durable provenance event recording each mount (source, name) has clear precedent (`hook/invoked`, `compact/start`). It was declined for v1: mount and unmount are already visible as `tool/call` / `tool/result` pairs and the tool-set change is already logged as a request-header delta, so a dedicated event would only duplicate the record. It remains addable if an audit use case needs mount provenance separable from the tool call. +**A new `cordis/mount` session event.** A durable provenance event recording each mount (source, name) has clear precedent (`hook/invoked`, `compact/start`). It was declined for v1: mount and unmount are already visible as `tool/call` / `tool/result` pairs and the tool-set change is already logged as a full changed request header, so a dedicated event would only duplicate the record. It remains addable if an audit use case needs mount provenance separable from the tool call. **A hardened / capability-restricted sandbox.** Trapping Node built-ins and handing mount code a whitelist façade rather than the raw context might suggest an intent to sandbox for safety. It is explicitly not that: the traps and the façade narrow the *surface* mount code sees — steering it onto cordis services and away from leak-prone Node built-ins and framework internals — for correctness and to close the unguarded-context escape, but the capabilities the façade exposes (`ctx.bash`, `ctx.fs`, `ctx.web`) reach the real runtime, so it is not a security boundary. A real one (separate process, permission prompts) was out of scope for a dev/opt-in toolset and would fight the entire point — handing the model the live runtime. diff --git a/docs/rfc/implemented/simplification/2026-07-12-simplify-session-log-representation.md b/docs/rfc/implemented/simplification/2026-07-12-simplify-session-log-representation.md new file mode 100644 index 0000000000..dae2ecbec3 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-12-simplify-session-log-representation.md @@ -0,0 +1,33 @@ +# RFC: Simplify session-log representation + +Status: implemented + +## Problem + +The session log maintains two representations that cost more machinery than their consumers require: a pseudo-linked surface and custom request-header deltas. + +`SurfaceManager` stores the same order in an array, a seq map, and mutable `prev`/`next` links. Production never reads `prev`; compact's sole `next` read is the successor of an array position. Replacement already uses `indexOf`, so the links do not make its dominant operation constant-time. A seq array with linear replacement lookup has the same asymptotic replacement cost and one representation to validate. + +The request-header subsystem implements a custom system/tool delta codec and transmission-decision layer even though its contract says deltas are an encoding optimization, not a reconstructability requirement. Retaining the initial/resume full snapshot at each loop-instance boundary, then writing a canonical full `request/header` whenever that instance's assembled header changes, preserves replay while deleting `SystemDelta`, `ToolsDelta`, round-trip fallback, and the durable `request/header-delta` variant. Codec-only vocabulary disappears with the codec, not because its individual arms were invalid. + +This proposal deliberately retains append and replacement `sourceEventSeqs`, crash-repair provenance, and all `SessionStartSource` variants: implemented RFCs give those fields an audit/interception role that zero current readers does not overturn. + +## Decision + +`SurfaceManager.nodes` is a `readonly number[]` of event sequences; the public `SurfaceNode` shape, node links, and seq-to-node map are removed. The internal replace-generation signal remains. Tool-pairing balance and compaction use array values and indices for successor and replacement ranges. + +Request headers use canonical full snapshots only. Initial and resume anchors remain full snapshots even when unchanged; an in-instance change appends another full `request/header` with reason `change`. The delta event, codec types, diff/apply helpers, and codec-only `fallback` reason are removed. Request reconstruction selects the latest snapshot. + +`SESSION_FORMAT_VERSION` remains pinned at `0`, so seed and persistence-load validation explicitly reject an old v0 log containing `request/header-delta`. There is no compatibility fold or migration. JSONL and SQLite tests pin this fail-loud boundary, and the ACP snapshot harness represents legitimate mid-session changes as full pinned headers and full readable prompts. + +## Alternatives considered + +**Keep linked nodes and compact deltas for possible scale.** Links could help a future cursor API, and deltas can reduce logs when large tool schemas change by a small amount. No shipped cursor uses the links, while full snapshots trade disk size for substantially simpler correctness. If header volume proves material, compression or a measured canonical-delta scheme can be designed around real traces. + +## Verification + +Unit coverage pins ordered-surface append/replace behavior, tool pairing, compaction, full-header folding/logging, request reconstruction, and dev invariants. Seed validation plus JSONL and SQLite load tests reject the legacy event before replay. The keyless ACP suite exercises record, refresh, replay, changed-header pinning, and the sandbox mode-switch fixture in the new shape. + +## Consequences + +Full headers increase log volume, and linear replacement lookup could be slower on very large surfaces. Replacements were already linear because the prior implementation called `indexOf`; benchmarks are deferred until real traces show the simpler array is a bottleneck. The format version remains `0`, so explicit legacy-event rejection is a permanent part of the pre-release format boundary. In return, surface order and request-header state each have one representation, deleting link maintenance, maps, codec arms, round-trip fallback, and delta-aware snapshot normalization. diff --git a/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md b/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md index 00a60ad4bc..cdb316b053 100644 --- a/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md +++ b/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md @@ -10,9 +10,9 @@ An ACP snapshot suite needs to prove the exact composed system prompt and tool-s Exactly one scenario per header-composition class is flagged `pinsHeader`. Its directory splits the pin by review format: `system-prompt.golden.md` contains the normalized composed prompt as ordinary Markdown, while `session.jsonl` keeps the full tool-schema list, config, and reason but stores `header.system` as `"{{system}}"`. Every other JSONL stores both the system prompt and tool list as `"{{system}}"` / `"{{tools}}"`. The pin mechanics live in [`dsh-acp-snapshot`](../../../../packages/support/acp-snapshot/README.md), whose suite factory enforces one pin per class. -The pure `scrubSystemPrompts` normalizer applies to every stored session fixture and tokenizes both an initial header's prompt and a header delta's inserted prompt lines. `scrubRequestHeaders` additionally tokenizes tool schemas and session-prefix content for non-pinning scenarios while retaining structural facts: system-delta positions and arity, added/removed/changed tool names, prefix message count, field presence, config, and reason. Record and refresh write-back apply the appropriate scrub before writing JSONL and regenerate the Markdown prompt from the normalized live header, so neither path can reintroduce prompt text into JSONL or leave the readable snapshot stale. +The pure `scrubSystemPrompts` normalizer applies to every stored session fixture and tokenizes every full header's prompt. `scrubRequestHeaders` additionally tokenizes tool schemas and session-prefix content for non-pinning scenarios while retaining prefix message count, field presence, config, and reason. Record and refresh write-back apply the appropriate scrub before writing JSONL and regenerate the Markdown prompt from the normalized live headers, so neither path can reintroduce prompt text into JSONL or leave the readable snapshot stale. A pinning scenario with a legitimate changed header declares its count; the Markdown artifact records each later full prompt under a `request/header change` marker. -Guards make the split self-enforcing. On disk, every `session*.jsonl` is a fixed point of `scrubSystemPrompts`, only non-pinning fixtures are fixed points of the full header scrub, `system-prompt.golden.md` exists exactly beside pinning fixtures, and each class has one pin. Live, every `request/header` produced by a parent, spawn child, fork child, initial request, or resume must match both halves of its class's pin after volatile-value normalization. A header without a string prompt or any `request/header-delta` fails loud because the two static pin artifacts cannot represent it. +Guards make the split self-enforcing. On disk, every `session*.jsonl` is a fixed point of `scrubSystemPrompts`, only non-pinning fixtures are fixed points of the full header scrub, `system-prompt.golden.md` exists exactly beside pinning fixtures, and each class has one pin. Live, every `request/header` produced by a parent, spawn child, fork child, initial request, or resume must match the corresponding class pin after volatile-value normalization. A header without a string prompt or an undeclared changed-header count fails loud because the static pin artifacts cannot represent it. One pin covers the whole suite because every session — parent, spawn child, fork child — composes the identical tool list and the identical prompt modulo cwd, and the uniformity guard fails the suite the moment that stops holding. If header composition ever becomes session-dependent by design (a restricted subagent toolset, say), the divergent shape gets its own pinning scenario. @@ -26,7 +26,7 @@ One pin covers the whole suite because every session — parent, spawn child, fo ## Verification -The suite replays every scenario against the split pins. Unit coverage exercises both scrub levels, Markdown formatting, record/refresh regeneration, normalized prompt extraction, fixed-point enforcement, required-file symmetry, header uniformity, and delta rejection. +The suite replays every scenario against the split pins. Unit coverage exercises both scrub levels, multi-header Markdown formatting, record/refresh regeneration, normalized prompt extraction, fixed-point enforcement, required-file symmetry, header uniformity, and changed-header count rejection. ## Consequences diff --git a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md index 2f631a5d8a..c622a7f1be 100644 --- a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md +++ b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md @@ -16,7 +16,7 @@ The machinery lives in [`packages/support/acp-snapshot`](../../../../packages/su **`src/normalize.ts`** — the pure normalizers, hook-free by policy: when a future event carries a new volatile field (an approval duration, say), the shared normalizer learns it in the same change, keeping one home for what "normalized" means rather than per-suite scrub extensions. -**`src/suite.ts`** — the `Scenario` type and `defineAcpSnapshotSuite(options)`, registering the per-scenario compares, record/refresh fixture write-back, the header pin with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL a `scrubSystemPrompts` fixed point, non-pinning fixtures also `scrubRequestHeaders` fixed points). The pinned-header contract ([pinned-header RFC](2026-07-06-pin-request-header-content-in-one-scenario.md)) is per-suite: each header class flags exactly one `pinsHeader` scenario, whose `system-prompt.golden.md` and JSONL tool list split the composed header into reviewable artifacts; the uniformity guard compares both against every live header in that class. The pure helpers (`childFixturePaths`, `fixtureContext`, `normalizedHeaders`, `normalizedSystemPrompts`, `formatSystemPromptSnapshot`, `headerDeltaCount`) are exported from the module for direct unit coverage. +**`src/suite.ts`** — the `Scenario` type and `defineAcpSnapshotSuite(options)`, registering the per-scenario compares, record/refresh fixture write-back, the header pin with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL a `scrubSystemPrompts` fixed point, non-pinning fixtures also `scrubRequestHeaders` fixed points). The pinned-header contract ([pinned-header RFC](2026-07-06-pin-request-header-content-in-one-scenario.md)) is per-suite: each header class flags exactly one `pinsHeader` scenario, whose `system-prompt.golden.md` and JSONL tool list split the composed header into reviewable artifacts; the uniformity guard compares both against every live header in that class. A pinning scenario declares any legitimate changed-header count, and its Markdown artifact records every full changed prompt. The pure helpers (`childFixturePaths`, `fixtureContext`, `normalizedHeaders`, `normalizedSystemPrompts`, `formatSystemPromptSnapshot`, `headerChangeCount`) are exported from the module for direct unit coverage. ## Alternatives considered diff --git a/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md deleted file mode 100644 index f335afafe1..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md +++ /dev/null @@ -1,36 +0,0 @@ -# RFC: Simplify session-log representation - -Status: proposed - -## Problem - -The session log maintains two representations that cost more machinery than their consumers require: a pseudo-linked surface and custom request-header deltas. - -`SurfaceManager` stores the same order in an array, a seq map, and mutable `prev`/`next` links. Production never reads `prev`; compact's sole `next` read is the successor of an array position. Replacement already uses `indexOf`, so the links do not make its dominant operation constant-time. A seq array with linear replacement lookup has the same asymptotic replacement cost and one representation to validate. - -The request-header subsystem implements a custom system/tool delta codec and transmission-decision layer even though its contract says deltas are an encoding optimization, not a reconstructability requirement. Retaining the initial/resume full snapshot at each loop-instance boundary, then writing a canonical full `request/header` whenever that instance's assembled header changes, preserves replay while deleting `SystemDelta`, `ToolsDelta`, round-trip fallback, and the durable `request/header-delta` variant. Codec-only vocabulary disappears with the codec, not because its individual arms were invalid. - -This proposal deliberately retains append and replacement `sourceEventSeqs`, crash-repair provenance, and all `SessionStartSource` variants: implemented RFCs give those fields an audit/interception role that zero current readers does not overturn. - -## Proposal - -Make `SurfaceManager.nodes` a `readonly number[]` of event sequences and remove the public `SurfaceNode` shape. Keep the internal replace-generation signal; update tool-pairing balance and compaction callers to use array values/indices for predecessor, successor, and replacement ranges, removing node links and the seq-to-node map. Replace post-anchor header deltas with canonical full changed-header snapshots and remove the delta codec/event/tests; initial and resume anchors remain full snapshots even when the folded header is unchanged. - -Amend the session-surface and reconstructable-request RFCs where they describe the removed encoding. Update event types/invariants, request logging/replay, persistence fixtures, generated catalogs, package docs, and snapshots. Replace the codec-only `fallback` reason with an explicit `change` reason for post-anchor full snapshots, distinguishing them from the retained `initial` and `resume` anchors. - -`SESSION_FORMAT_VERSION` is deliberately pinned at `0`, so an old v0 log containing `request/header-delta` would otherwise pass the version check and silently lose header changes after the delta fold is deleted. Seed/load validation must reject that legacy event fail-loud at the format boundary; no compatibility fold or migration is added. - -## Alternatives considered - -**Keep linked nodes and compact deltas for possible scale.** Links could help a future cursor API, and deltas can reduce logs when large tool schemas change by a small amount. No shipped cursor uses the links, while full snapshots trade disk size for substantially simpler correctness. If header volume proves material, compression or a measured canonical-delta scheme can be designed around real traces. - -## Acceptance criteria - -- `SurfaceManager.nodes` is one ordered seq array with no `SurfaceNode`, link fields, or seq-to-node map; incremental append processing and the internal replace-generation signal remain, while the separate public `invalidate()` deletion stays owned by the dead-surface RFC. -- Replaying full changed-header snapshots reconstructs exactly the same requests; no header-delta event/type/codec remains. -- A v0 seed or persisted log containing legacy `request/header-delta` is rejected before replay, with coverage for JSONL and SQLite load paths. -- New-shape v0 JSONL/SQLite replay, provenance, crash repair, compaction, snapshots, invariants, typecheck, coverage, doc-sync, build, and hygiene pass. - -## Risks - -Full headers increase log volume, and linear replacement lookup could be slower on very large surfaces. Replacements are already linear because the implementation calls `indexOf`; benchmarks should be added only if real traces show the simpler array is a bottleneck. Because the format version remains `0`, forgetting the explicit legacy-event rejection would be silent data corruption rather than a type error; the fail-loud load test is therefore part of the proposal, not optional cleanup. diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 9ea71d3005..ccd3cef9fd 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -18,7 +18,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userInteraction` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. | | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | | `@deepseek-ai/dsh-tool-bash` | `bash`, `bash_kill`, `bash_output` | `ctx.tools`, `ctx.bash` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam. | -| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes. | +| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | @@ -275,7 +275,7 @@ Dispose a plugin previously mounted with cordis_mount, by id. All its registrati Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cordis/src/index.ts) -Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes. +Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes. ## `@deepseek-ai/dsh-tool-fs` diff --git a/examples/sandbox-acp-agent/tests/acp.snapshot.ts b/examples/sandbox-acp-agent/tests/acp.snapshot.ts index 418b8068d3..95667d5b6b 100644 --- a/examples/sandbox-acp-agent/tests/acp.snapshot.ts +++ b/examples/sandbox-acp-agent/tests/acp.snapshot.ts @@ -35,7 +35,7 @@ const SCENARIOS: Scenario[] = [ { name: 'config-options', hasModelTurn: false, recorded: false }, // The runtime mode-switching arc, and NECESSARILY the pinned-header // scenario: an approval-policy switch rewrites its prompt section, and the - // resulting request/header-delta is legal only in the pinning scenario + // resulting changed request/header is legal only in the pinning scenario // (the factory's uniformity guard). The pin commits this composition's // full header — persona, tool schemas WITH the escalation fields — plus // the approval delta and its "changed by the user" notice verbatim. The @@ -43,9 +43,9 @@ const SCENARIOS: Scenario[] = [ // sandbox RFC's visibility asymmetry): the recorded arc proves it by // BEHAVIOR, a confined write landing under the switched mode with no // header change. - { name: 'mode-switching', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderDeltas: 1 }, + { name: 'mode-switching', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderChanges: 1 }, // The approval wire end-to-end, under the DEFAULT read-only/ask (a switch - // would emit a header-delta the uniformity guard forbids here): the + // would emit a changed header the uniformity guard forbids here): the // escalating bash call streams, session/request_permission attaches to it // (allow-once / reject-once), and the scripted answer drives each branch — // an approved run executes CONFINED under the granted workspace-write; a diff --git a/examples/sandbox-acp-agent/tests/snapshots/mode-switching/session.jsonl b/examples/sandbox-acp-agent/tests/snapshots/mode-switching/session.jsonl index ef287390aa..e63e644459 100644 --- a/examples/sandbox-acp-agent/tests/snapshots/mode-switching/session.jsonl +++ b/examples/sandbox-acp-agent/tests/snapshots/mode-switching/session.jsonl @@ -174,7 +174,7 @@ {"type":"user/message","seq":172,"time":1783613229056,"data":{"content":[{"type":"text","text":"Without using any tools, state your current approval policy in one short sentence and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"context/message","seq":173,"time":1783613229057,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"user-approval"}},"surfaceOp":"append"} {"type":"step/start","seq":174,"time":1783613229057,"data":{"turn":3,"step":1}} -{"type":"request/header-delta","seq":175,"time":1783613229057,"data":{"system":{"keepStart":9,"keepEnd":0,"insert":["{{system}}","{{system}}"]}}} +{"type":"request/header","seq":175,"time":1783613229057,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}}]},"reason":"change"}} {"type":"assistant/chunk","seq":176,"time":1783613230100,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":177,"time":1783613230100,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":178,"time":1783613230192,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} diff --git a/examples/sandbox-acp-agent/tests/snapshots/mode-switching/system-prompt.golden.md b/examples/sandbox-acp-agent/tests/snapshots/mode-switching/system-prompt.golden.md index 2da5f39805..1e6e8a5ee0 100644 --- a/examples/sandbox-acp-agent/tests/snapshots/mode-switching/system-prompt.golden.md +++ b/examples/sandbox-acp-agent/tests/snapshots/mode-switching/system-prompt.golden.md @@ -9,7 +9,16 @@ Check the [exit code: N] marker on every bash result; investigate failures befor - + + +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/packages/bash/bash/src/session-mode.ts b/packages/bash/bash/src/session-mode.ts index 03ad6e3d7c..a1a16369f6 100644 --- a/packages/bash/bash/src/session-mode.ts +++ b/packages/bash/bash/src/session-mode.ts @@ -25,7 +25,7 @@ declare module '@deepseek-ai/dsh-session' { * NOT a surface event, carries no `surfaceOp`): durable and replayable, * never in the model transcript. The LAST such event is the session's * override ({@link effectiveSandboxMode}); who asked for it is derivable - * from position (an event after the log's last `request/header*` was a + * from position (an event after the log's last `request/header` was a * runtime switch by the user; see the tool layer's narrator). */ 'bash/sandbox-mode': { mode: SandboxMode } diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index c0d4ff483a..0b0300472c 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -455,11 +455,11 @@ export class BasicCompactService extends CompactService { // position, so the surface order (head→tail) no longer tracks seq order — // `[newSummarySeq, olderRetainedSeq, …]` is normal. Indexing into the // ordered node list and slicing it is the only correct way to read a range; - // a `node.seq >= start && node.seq <= end` interval test would mis-collect + // a `seq >= start && seq <= end` interval test would mis-collect // nodes (and `start > end` would falsely reject) once that happens. const nodes = session.surface.nodes - const startIdx = nodes.findIndex(n => n.seq === start) - const endIdx = nodes.findIndex(n => n.seq === end) + const startIdx = nodes.indexOf(start) + const endIdx = nodes.indexOf(end) if (startIdx === -1) throw new Error(`compactRegion: start seq ${start} not found in surface`) if (endIdx === -1) throw new Error(`compactRegion: end seq ${end} not found in surface`) if (startIdx > endIdx) { @@ -480,8 +480,7 @@ export class BasicCompactService extends CompactService { } // The cut after `end` is named by `end`'s surface successor, or `null` when // `end` is the tail. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const afterEnd: number | null = nodes[endIdx]!.next + const afterEnd = nodes[endIdx + 1] ?? null if (!isToolPairingBalanced(nodes, events, afterEnd)) { throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`) } @@ -503,7 +502,7 @@ export class BasicCompactService extends CompactService { } // Slice the ordered surface nodes [startIdx, endIdx] inclusive — the // shadowed range is positional, so this is the set the replace op covers. - const shadowedSeqs = nodes.slice(startIdx, endIdx + 1).map(n => n.seq) + const shadowedSeqs = nodes.slice(startIdx, endIdx + 1) // --- Acquire lock --- const startEvent = session.append('compact/start', { turn: openTurn }) @@ -639,9 +638,9 @@ export class BasicCompactService extends CompactService { let keepFromIdx = nodes.length // nothing retained yet for (let i = nodes.length - 1; i >= 0; i--) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const node = nodes[i]! - const event = events[node.seq] - /* v8 ignore next -- node.seq is a surface-node seq, always a valid log index by construction */ + const seq = nodes[i]! + const event = events[seq] + /* v8 ignore next -- seq is a surface event sequence, always a valid log index by construction */ if (event) accumulated += this.estimateEventTokens(event) keepFromIdx = i if (accumulated >= retainBudget) break @@ -660,16 +659,16 @@ export class BasicCompactService extends CompactService { // step — retry once it closes). while (keepFromIdx > 0) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!.seq)) break + if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!)) break keepFromIdx -= 1 } if (keepFromIdx === 0) return null // The compacted range is [head … keepFromIdx - 1], anchored at the head. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const firstSeq = nodes[0]!.seq + const firstSeq = nodes[0]! // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const cutoffSeq = nodes[keepFromIdx - 1]!.seq + const cutoffSeq = nodes[keepFromIdx - 1]! return { start: firstSeq, end: cutoffSeq } } diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index e47ca434e3..1a30cc9fc9 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -259,8 +259,8 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai const svc = createTestService() const session = toolTurnSession(1) const nodes = session.surface.nodes // [user, asst(tool-call), result] - const userSeq = nodes[0]!.seq - const resultSeq = nodes[2]!.seq + const userSeq = nodes[0]! + const resultSeq = nodes[2]! // start = the tool/result: its issuing assistant precedes it IN THE SAME STEP, // so starting here would orphan that assistant's tool-call. end is fine (user). await expect(compactRegion(svc, session, resultSeq, resultSeq, 'm')) @@ -272,8 +272,8 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai const svc = createTestService() const session = toolTurnSession(1) const nodes = session.surface.nodes - const userSeq = nodes[0]!.seq - const asstSeq = nodes[1]!.seq + const userSeq = nodes[0]! + const asstSeq = nodes[1]! // end = the assistant/message: its tool/result follows IN THE SAME STEP, so // ending here would strand that result. start is fine (the pre-step user). await expect(compactRegion(svc, session, userSeq, asstSeq, 'm')) @@ -291,8 +291,8 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }], }, { surfaceOp: 'append' }) const nodes = s.surface.nodes // [user, asst] - const userSeq = nodes[0]!.seq - const asstSeq = nodes[1]!.seq + const userSeq = nodes[0]! + const asstSeq = nodes[1]! await expect(compactRegion(svc, s, userSeq, asstSeq, 'm')) .rejects.toThrow(/end seq .* is not a balanced boundary/) }) @@ -301,8 +301,8 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai const svc = createTestService() const session = toolTurnSession(2) const nodes = session.surface.nodes // [user1, asst1, res1, user2, asst2, res2] - const startSeq = nodes[0]!.seq // pre-step user1 (free boundary) - const endSeq = nodes[2]!.seq // res1 = last node of turn 1's closed step + const startSeq = nodes[0]! // pre-step user1 (free boundary) + const endSeq = nodes[2]! // res1 = last node of turn 1's closed step const result = await compactRegion(svc, session, startSeq, endSeq, 'm') expect(result.shadowedRange).toEqual({ start: startSeq, end: endSeq }) expectNoOrphanToolResults(session.deriveMessages()) @@ -312,7 +312,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai const svc = createTestService() const session = toolTurnSession(1) const nodes = session.surface.nodes - const userSeq = nodes[0]!.seq // pre-step user: free boundary both ways + const userSeq = nodes[0]! // pre-step user: free boundary both ways const result = await compactRegion(svc, session, userSeq, userSeq, 'm') expect(result.shadowedRange).toEqual({ start: userSeq, end: userSeq }) }) @@ -327,7 +327,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) const nodes = s.surface.nodes - const ctxSeq = nodes[0]!.seq + const ctxSeq = nodes[0]! const result = await compactRegion(svc, s, ctxSeq, ctxSeq, 'm') expect(result.shadowedRange).toEqual({ start: ctxSeq, end: ctxSeq }) }) @@ -386,8 +386,8 @@ describe('BasicCompactService.compactRegion', () => { const nodes = session.surface.nodes expect(nodes.length).toBe(6) - const firstSeq = nodes[0]!.seq - const secondSeq = nodes[1]!.seq + const firstSeq = nodes[0]! + const secondSeq = nodes[1]! const result = await compactRegion(svc, session, firstSeq, secondSeq, 'test-model') expect(result.shadowedSeqs).toEqual([firstSeq, secondSeq]) @@ -427,7 +427,7 @@ describe('BasicCompactService.compactRegion', () => { // Surface now has: summary user/message + retained 4 nodes = 5 nodes. const newNodes = session.surface.nodes expect(newNodes.length).toBe(5) - expect(newNodes[0]!.seq).toBe(userMsg.seq) + expect(newNodes[0]!).toBe(userMsg.seq) // deriveMessages() produces the framed summary as a user-role message: // a checkpoint preamble + tag-wrapped summary blocks. @@ -452,7 +452,7 @@ describe('BasicCompactService.compactRegion', () => { const svc = createTestService() const session = multiTurnSession(2, 1) const nodes = session.surface.nodes - await expect(compactRegion(svc, session, nodes[1]!.seq, nodes[0]!.seq, 'm')) + await expect(compactRegion(svc, session, nodes[1]!, nodes[0]!, 'm')) .rejects.toThrow(/is after end seq .* on the surface/) }) @@ -461,7 +461,7 @@ describe('BasicCompactService.compactRegion', () => { const session = multiTurnSession(2, 1) const nodes = session.surface.nodes session.append('compact/start', { turn: 2 }) - await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')) + await expect(compactRegion(svc, session, nodes[0]!, nodes[1]!, 'm')) .rejects.toThrow(/compaction already in progress/) }) @@ -471,7 +471,7 @@ describe('BasicCompactService.compactRegion', () => { const session = multiTurnSession(2, 1) const nodes = session.surface.nodes - await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')) + await expect(compactRegion(svc, session, nodes[0]!, nodes[1]!, 'm')) .rejects.toThrow('model unavailable') const endEvent = session.events.findLast(e => e.type === 'compact/end') @@ -494,7 +494,7 @@ describe('BasicCompactService.compactRegion', () => { const session = multiTurnSession(1, 2) const nodes = session.surface.nodes - await compactRegion(svc, session, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + await compactRegion(svc, session, nodes[0]!, nodes[nodes.length - 1]!, 'm') expect(svc.summarizeCalls.length).toBe(1) const { text, model } = svc.summarizeCalls[0]! @@ -509,7 +509,7 @@ describe('BasicCompactService.compactRegion', () => { const session = multiTurnSession(3, 1) const nodes = session.surface.nodes - const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm') + const result = await compactRegion(svc, session, nodes[0]!, nodes[1]!, 'm') // Provenance (compact/summary) carries the RAW, unframed summary. expect(result.summary).toEqual([{ type: 'text', text: 'STRUCTURED SUMMARY' }]) @@ -529,8 +529,8 @@ describe('BasicCompactService.compactRegion', () => { const session = sessionWithTools() const nodes = session.surface.nodes - const firstSeq = nodes[0]!.seq - const lastSeq = nodes[nodes.length - 1]!.seq + const firstSeq = nodes[0]! + const lastSeq = nodes[nodes.length - 1]! await compactRegion(svc, session, firstSeq, lastSeq, 'm') expect(svc.summarizeCalls.length).toBe(1) @@ -601,7 +601,7 @@ describe('BasicCompactService.compactIfNeeded', () => { expect(result).not.toBeNull() const nodes = session.surface.nodes expect(result!.shadowedSeqs.length).toBeGreaterThan(0) - expect(result!.shadowedSeqs).not.toContain(nodes[nodes.length - 1]!.seq) + expect(result!.shadowedSeqs).not.toContain(nodes[nodes.length - 1]!) }) it('returns null when the whole surface fits the retain budget (over threshold by role/system overhead)', async () => { @@ -653,7 +653,7 @@ describe('BasicCompactService.compactIfNeeded', () => { // The most-recent step's tool result is retained verbatim (still on surface). const lastResultSeq = s.events.findLast(e => e.type === 'tool/result')!.seq expect(result!.shadowedSeqs).not.toContain(lastResultSeq) - expect(s.surface.nodes.some(n => n.seq === lastResultSeq)).toBe(true) + expect(s.surface.nodes).toContain(lastResultSeq) // No orphaned tool-result survives (whole-step boundaries respected). expectNoOrphanToolResults(s.deriveMessages()) }) @@ -677,7 +677,7 @@ describe('BasicCompactService.compactIfNeeded', () => { const first = await compactIfNeeded(svc, s, '', 'm', SIGNAL) expect(first).not.toBeNull() // The summary node now heads the surface with a fresh high seq. - const summaryHeadSeq = s.surface.nodes[0]!.seq + const summaryHeadSeq = s.surface.nodes[0]! const turn5StartSeq = s.events.filter(e => e.type === 'turn/start').at(-1)!.seq expect(summaryHeadSeq).toBeGreaterThan(turn5StartSeq) @@ -745,7 +745,7 @@ describe('BasicCompactService replay equivalence', () => { const session = multiTurnSession(3, 1) const nodes = session.surface.nodes - await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm') + await compactRegion(svc, session, nodes[0]!, nodes[1]!, 'm') const derived = session.deriveMessages() const replayed = new Session(SessionId('replay'), [...session.events]) @@ -761,7 +761,7 @@ describe('BasicCompactService blocking (compaction in progress)', () => { const nodes = session.surface.nodes // Whole step (user → assistant) is a step-aligned region, so the call reaches // the in-progress check rather than being rejected for splitting a step. - await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')) + await expect(compactRegion(svc, session, nodes[0]!, nodes[1]!, 'm')) .rejects.toThrow(/compaction already in progress/) }) @@ -771,7 +771,7 @@ describe('BasicCompactService blocking (compaction in progress)', () => { const nodes = session.surface.nodes session.append('compact/start', { turn: 1 }) session.append('compact/end', { turn: 1 }) - const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm') + const result = await compactRegion(svc, session, nodes[0]!, nodes[1]!, 'm') expect(result).toBeDefined() }) @@ -794,7 +794,7 @@ describe('BasicCompactService blocking (compaction in progress)', () => { const nodes = s.surface.nodes // The stale start is before the turn/end, so it is NOT seen as in-progress. - const result = await compactRegion(svc, s, nodes[0]!.seq, nodes[1]!.seq, 'm') + const result = await compactRegion(svc, s, nodes[0]!, nodes[1]!, 'm') expect(result).toBeDefined() }) }) @@ -1112,7 +1112,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { const before = [...session.surface.nodes] const nodes = session.surface.nodes - await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'test-model')) + await expect(compactRegion(svc, session, nodes[0]!, nodes[1]!, 'test-model')) .rejects.toMatchObject({ code: 'MAX_TOKENS' }) // No replacement landed — the surface is byte-identical, and the lock was @@ -1129,7 +1129,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { const session = multiTurnSession(2, 1) const nodes = session.surface.nodes - const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'test-model') + const result = await compactRegion(svc, session, nodes[0]!, nodes[1]!, 'test-model') expect(result.summary).toEqual([{ type: 'text', text: 'CONDENSED' }]) // The raw summary is wrapped in the checkpoint framing on the surface. expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'CONDENSED' }) @@ -1141,7 +1141,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { const nodes = session.surface.nodes svc.mockSummary = Array.from({ length: 20 }, (_, index) => ({ type: 'text', text: `large ${index}` })) - await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')) + await expect(compactRegion(svc, session, nodes[0]!, nodes[1]!, 'm')) .rejects.toThrow(/summary is not smaller than the shadowed content/) expect(session.events.some(e => e.type === 'compact/summary')).toBe(false) }) @@ -1160,7 +1160,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { const before = [...session.surface.nodes] const nodes = session.surface.nodes - await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')) + await expect(compactRegion(svc, session, nodes[0]!, nodes[1]!, 'm')) .rejects.toThrow(/summary is not smaller than the shadowed content/) expect(session.events.some(e => e.type === 'compact/summary')).toBe(false) expect(session.surface.nodes).toEqual(before) @@ -1321,7 +1321,7 @@ describe('BasicCompactService transcript rendering (delegated to dsh-compact)', s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) const nodes = s.surface.nodes - await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + await compactRegion(svc, s, nodes[0]!, nodes[nodes.length - 1]!, 'm') const { text } = svc.summarizeCalls[0]! expect(text).toContain('[Context: project context here]') @@ -1350,7 +1350,7 @@ describe('BasicCompactService transcript rendering (delegated to dsh-compact)', s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) const nodes = s.surface.nodes - await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + await compactRegion(svc, s, nodes[0]!, nodes[nodes.length - 1]!, 'm') expect(svc.summarizeCalls[0]!.text).toContain('Tool error (call c9): boom failure') }) }) @@ -1384,7 +1384,7 @@ describe('BasicCompactService edge cases', () => { s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) const nodes = s.surface.nodes - await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + await compactRegion(svc, s, nodes[0]!, nodes[nodes.length - 1]!, 'm') const { text } = svc.summarizeCalls[0]! expect(text).toContain('[tool-result: [chart]]') // nested tool-result with content expect(text).toContain('[custom-widget]') // unknown block placeholder @@ -1432,7 +1432,7 @@ describe('BasicCompactService edge cases', () => { s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) const nodes = s.surface.nodes - await expect(compactRegion(svc, s, nodes[0]!.seq, nodes[1]!.seq, 'm')) + await expect(compactRegion(svc, s, nodes[0]!, nodes[1]!, 'm')) .rejects.toThrow(/no open turn/) // The lock was never acquired — no compact/start landed. expect(s.events.some(e => e.type === 'compact/start')).toBe(false) @@ -1447,7 +1447,7 @@ describe('BasicCompactService edge cases', () => { s.append('user/message', { content: [{ type: 'text', text: 'orphan' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const nodes = s.surface.nodes - await expect(compactRegion(svc, s, nodes[0]!.seq, nodes[0]!.seq, 'm')) + await expect(compactRegion(svc, s, nodes[0]!, nodes[0]!, 'm')) .rejects.toThrow(/no open turn/) expect(s.events.some(e => e.type === 'compact/start')).toBe(false) }) @@ -1464,7 +1464,7 @@ describe('BasicCompactService edge cases', () => { const svc = createTestService() const session = multiTurnSession(1, 1) const nodes = session.surface.nodes - await expect(compactRegion(svc, session, nodes[0]!.seq, 9999, 'm')) + await expect(compactRegion(svc, session, nodes[0]!, 9999, 'm')) .rejects.toThrow(/end seq 9999 not found in surface/) }) @@ -1476,7 +1476,7 @@ describe('BasicCompactService edge cases', () => { const nodes = session.surface.nodes // Whole step (user → assistant): a step-aligned region that reaches summarize. - await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')).rejects.toBe('plain string failure') + await expect(compactRegion(svc, session, nodes[0]!, nodes[1]!, 'm')).rejects.toBe('plain string failure') const endEvent = session.events.findLast(e => e.type === 'compact/end')! expect(endEvent.data).toMatchObject({ error: 'plain string failure' }) }) @@ -1542,7 +1542,7 @@ describe('BasicCompactService edge cases', () => { s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) const nodes = s.surface.nodes - await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + await compactRegion(svc, s, nodes[0]!, nodes[nodes.length - 1]!, 'm') // Every empty-content message (user text, empty reasoning, empty-content // tool/result, empty context, empty steering) extracted to nothing and was // skipped — the only surviving line is the assistant's tool-call (which a @@ -1580,7 +1580,7 @@ describe('BasicCompactService edge cases', () => { s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) const nodes = s.surface.nodes - await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + await compactRegion(svc, s, nodes[0]!, nodes[nodes.length - 1]!, 'm') const { text } = svc.summarizeCalls[0]! // Every non-text block surfaces as a placeholder rather than being dropped. expect(text).toContain('User: [chart]') @@ -1604,32 +1604,32 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a // First compaction: shadow the two oldest surface nodes. const nodes0 = session.surface.nodes - const first = await compactRegion(svc, session, nodes0[0]!.seq, nodes0[1]!.seq, 'm') + const first = await compactRegion(svc, session, nodes0[0]!, nodes0[1]!, 'm') // The summary node now sits at the head with a seq HIGHER than the // retained older nodes that follow it — the non-monotonic surface. (The // head is the user/message replace node, appended after the compact/summary // provenance event, so its seq is at least first.summarySeq.) const nodes1 = session.surface.nodes - expect(nodes1[0]!.seq).toBeGreaterThanOrEqual(first.summarySeq) - expect(nodes1[0]!.seq).toBeGreaterThan(nodes1[1]!.seq) + expect(nodes1[0]!).toBeGreaterThanOrEqual(first.summarySeq) + expect(nodes1[0]!).toBeGreaterThan(nodes1[1]!) // Second compaction: shadow [summary(head) … turn-2's step end]. The start // seq (the head summary node) is GREATER than the end seq (an older retained // node), so the range is a SURFACE-POSITION span, not a numeric seq interval. // The end must land on a step boundary (turn-2's assistant message closes // its step). - const startSeq = nodes1[0]!.seq - const endSeq = nodes1[2]!.seq + const startSeq = nodes1[0]! + const endSeq = nodes1[2]! expect(startSeq).toBeGreaterThan(endSeq) const second = await compactRegion(svc, session, startSeq, endSeq, 'm') // Exactly the three nodes at surface positions [0..2] are shadowed, in // surface order — the positional slice, regardless of their seq values. - expect(second.shadowedSeqs).toEqual([nodes1[0]!.seq, nodes1[1]!.seq, nodes1[2]!.seq]) + expect(second.shadowedSeqs).toEqual([nodes1[0]!, nodes1[1]!, nodes1[2]!]) // The surface still derives cleanly: a new head replace node + the rest. const finalNodes = session.surface.nodes - expect(finalNodes[0]!.seq).toBeGreaterThanOrEqual(second.summarySeq) + expect(finalNodes[0]!).toBeGreaterThanOrEqual(second.summarySeq) expect(session.deriveMessages().length).toBe(finalNodes.length) }) @@ -1640,14 +1640,14 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a // First compaction shadows the oldest two surface nodes, landing a high-seq // summary node at the head. const n0 = session.surface.nodes - await compactRegion(svc, session, n0[0]!.seq, n0[1]!.seq, 'm') + await compactRegion(svc, session, n0[0]!, n0[1]!, 'm') // Second compaction spans [head summary … turn-2's step end]. The head's seq // is higher than the older retained nodes' seqs, so a log-seq-order walk // would emit the older messages BEFORE the checkpoint. const n1 = session.surface.nodes svc.summarizeCalls = [] - await compactRegion(svc, session, n1[0]!.seq, n1[2]!.seq, 'm') + await compactRegion(svc, session, n1[0]!, n1[2]!, 'm') // The extracted transcript follows surface order: the checkpoint (head) // first, then the older retained messages — matching deriveMessages(). @@ -1679,7 +1679,7 @@ describe('BasicCompactService llm inject (real plugin-load path)', () => { const svc = ctx.compact as BasicCompactService const session = multiTurnSession(2, 1) const nodes = session.surface.nodes - const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'test-model') + const result = await compactRegion(svc, session, nodes[0]!, nodes[1]!, 'test-model') expect(result.summary).toEqual([{ type: 'text', text: 'CONDENSED' }]) // Tear the fiber down so this test owns no leaked registration; the @@ -1727,9 +1727,9 @@ describe('BasicCompactService under the real invariants plugin', () => { const nodes = session.surface.nodes // No invariant throws here: compact/* + the replacement are all in turn 3. - const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'test-model') + const result = await compactRegion(svc, session, nodes[0]!, nodes[1]!, 'test-model') expect(result.shadowedSeqs.length).toBe(2) - expect(session.surface.nodes[0]!.seq).toBeGreaterThan(session.surface.nodes[1]!.seq) + expect(session.surface.nodes[0]!).toBeGreaterThan(session.surface.nodes[1]!) }) it('accepts a second compaction over the non-monotonic surface left by the first', async () => { @@ -1740,14 +1740,14 @@ describe('BasicCompactService under the real invariants plugin', () => { session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } }) const n0 = session.surface.nodes - await compactRegion(svc, session, n0[0]!.seq, n0[1]!.seq, 'test-model') + await compactRegion(svc, session, n0[0]!, n0[1]!, 'test-model') // Surface head now carries a higher seq than the older retained nodes. A // second compaction spanning [head … a later closed-step end] must pass the // invariants' positional replace check even though startSeq > endSeq. const n1 = session.surface.nodes - expect(n1[0]!.seq).toBeGreaterThan(n1[2]!.seq) - const second = await compactRegion(svc, session, n1[0]!.seq, n1[2]!.seq, 'test-model') - expect(second.shadowedSeqs).toEqual([n1[0]!.seq, n1[1]!.seq, n1[2]!.seq]) + expect(n1[0]!).toBeGreaterThan(n1[2]!) + const second = await compactRegion(svc, session, n1[0]!, n1[2]!, 'test-model') + expect(second.shadowedSeqs).toEqual([n1[0]!, n1[1]!, n1[2]!]) }) }) diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index 1efb417b48..bb77e44a9c 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -142,12 +142,12 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () // scan reached the neighbouring step's assistant/message. const nodes = agent.session.surface.nodes for (const cp of checkpoints) { - const node = nodes.find(n => n.seq === cp.seq) - if (!node) continue // shadowed by a later checkpoint — no longer an edge. - expect(isToolPairingBalanced(nodes, events, node.seq), - `checkpoint seq ${node.seq} must be a balanced region START`).toBe(true) - expect(isToolPairingBalanced(nodes, events, node.next), - `checkpoint seq ${node.seq} must be a balanced region END`).toBe(true) + const index = nodes.indexOf(cp.seq) + if (index === -1) continue // shadowed by a later checkpoint — no longer an edge. + expect(isToolPairingBalanced(nodes, events, cp.seq), + `checkpoint seq ${cp.seq} must be a balanced region START`).toBe(true) + expect(isToolPairingBalanced(nodes, events, nodes[index + 1] ?? null), + `checkpoint seq ${cp.seq} must be a balanced region END`).toBe(true) } } finally { await ctx.fiber.dispose() diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 7a7ca2e28c..711e6fab69 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -170,8 +170,8 @@ export interface LoopHandle { * boundary = session.deriveMessages() ⟵ the reconstruction boundary: snapshot in the * session('step/start') same sync frame, strictly before step/start * config = waterfall agent/request(config) ⟵ frozen seed; a returned replacement switches - * session('request/header'|'request/header-delta') ⟵ the header event this request owes the - * log (initial/resume anchor, delta, fallback) + * session('request/header') ⟵ the header event this request owes the + * log (initial/resume anchor or changed snapshot) * req = freeze({header..., messages: prefix+boundary, sessionId, signal}) * stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks, frozen req) * session('assistant/chunk') @@ -774,7 +774,7 @@ async function runStep( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call const sessionPrefix = transmission.sessionPrefix! - // The request header (the log's request/header* vocabulary): canonical form, + // The request header (the log's request/header snapshots): canonical form, // recorded before dispatch so the log always explains the request — // including the session prefix, which no other event carries. const header = canonicalHeader({ diff --git a/packages/core/agent-loop/src/request-log.ts b/packages/core/agent-loop/src/request-log.ts index d2763f5c2a..94e121d0f6 100644 --- a/packages/core/agent-loop/src/request-log.ts +++ b/packages/core/agent-loop/src/request-log.ts @@ -5,12 +5,12 @@ * otherwise transmission-stateless — the comparison baseline is the log's own * folded header (`Session.requestHeader()`), so resume and fork need no * special path: a fresh loop instance simply logs a `'resume'` snapshot on - * its first request and deltas from there. + * its first request and full changed-header snapshots from there. * * @module dsh-agent-loop/request-log */ -import { diffHeader, headerEquals, applyHeaderDelta } from '@deepseek-ai/dsh-session' +import { headerEquals } from '@deepseek-ai/dsh-session' import type { EpochHeader, Session } from '@deepseek-ai/dsh-session' import type { Message } from '@deepseek-ai/dsh-llm' @@ -38,7 +38,7 @@ export function createTransmissionLog(): TransmissionLog { /** * Append whatever header event this request owes the log, so folding the log - * reproduces the header the request was built under. Exactly one of four + * reproduces the header the request was built under. Exactly one of three * things happens: * * 1. This loop instance has not logged a header yet → a full `request/header` @@ -48,11 +48,7 @@ export function createTransmissionLog(): TransmissionLog { * snapshot is appended even when nothing changed). * 2. The header equals the folded baseline → nothing; the log already * explains this request. - * 3. It differs and the delta round-trips (`applyHeaderDelta` on the baseline - * reproduces the header exactly) → a `request/header-delta`. - * 4. It differs and the delta encoding cannot express the change (a pure tool - * reordering) → a full snapshot with reason `'fallback'`; deltas are an - * encoding optimization, never a correctness dependency. + * 3. It differs → a full snapshot with reason `'change'`. * * @param session - the session whose log explains the request. * @param state - this loop instance's bookkeeping (mutated on first log). @@ -69,12 +65,5 @@ export function recordRequestHeader(session: Session, state: TransmissionLog, he // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const baseline = session.requestHeader()! if (headerEquals(baseline, header)) return - const delta = diffHeader(baseline, header) - /* v8 ignore next -- headerEquals false ⟹ diffHeader defined: both compare the same four parts */ - if (delta === undefined) return - if (headerEquals(applyHeaderDelta(baseline, delta), header)) { - session.append('request/header-delta', delta) - } else { - session.append('request/header', { header, reason: 'fallback' }) - } + session.append('request/header', { header, reason: 'change' }) } diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index d4ec6312ba..1ec48e553f 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -374,8 +374,8 @@ describe('agent/session-prefix', () => { expect(request.messages[0]).toEqual(reminder) } // The anchoring snapshot is the prefix's durable record — and the ONLY - // header event: reuse means no request/header-delta ever. - const headerEvents = events(agent).filter(e => e.type === 'request/header' || e.type === 'request/header-delta') + // header event: reuse means no changed snapshot ever. + const headerEvents = events(agent).filter(e => e.type === 'request/header') expect(headerEvents).toHaveLength(1) expect(headerEvents[0]?.type === 'request/header' && headerEvents[0].data.header.messagePrefix).toEqual([reminder]) // Never session history: the derivation starts at the real user prompt. @@ -491,7 +491,7 @@ describe('agent/session-prefix', () => { // cached prefix is a deep-frozen clone, so step 2's request is unchanged. held.content = [{ type: 'text', text: 'v2' }] expect(adapter.requests[1]!.messages[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'v1' }] }) - expect(events(agent).filter(e => e.type === 'request/header-delta')).toHaveLength(0) + expect(events(agent).filter(e => e.type === 'request/header')).toHaveLength(1) }) }) diff --git a/packages/core/agent-loop/tests/request-log.spec.ts b/packages/core/agent-loop/tests/request-log.spec.ts index a6befde84e..f281f1e9db 100644 --- a/packages/core/agent-loop/tests/request-log.spec.ts +++ b/packages/core/agent-loop/tests/request-log.spec.ts @@ -1,9 +1,8 @@ /** - * recordRequestHeader unit tests: exactly one of four things per request — + * recordRequestHeader unit tests: exactly one of three things per request — * an 'initial' snapshot (log has no header yet), a 'resume' snapshot (fresh - * loop instance over a log that has one), nothing (header unchanged), a - * round-tripping delta, or a 'fallback' snapshot when the delta encoding - * cannot express the change (pure tool reordering). + * loop instance over a log that has one), nothing (header unchanged), or a + * full 'change' snapshot. */ import { describe, expect, it } from 'vitest' @@ -23,7 +22,7 @@ function openSession(id: string): Session { } function headerEvents(session: Session): SessionEvent[] { - return session.events.filter(e => e.type === 'request/header' || e.type === 'request/header-delta') + return session.events.filter(e => e.type === 'request/header') } describe('recordRequestHeader', () => { @@ -53,8 +52,8 @@ describe('recordRequestHeader', () => { expect(events[1]?.type === 'request/header' && events[1].data.reason).toBe('resume') }) - it('logs a round-tripping delta for a mid-run change, and the fold reproduces the header', () => { - const session = openSession('rl-delta') + it("logs a full 'change' snapshot for a mid-run change, and the fold reproduces the header", () => { + const session = openSession('rl-change') const state = createTransmissionLog() const first = canonicalHeader({ config: { model: 'm' }, system: 'a\nb', tools: [tool('t')] }) recordRequestHeader(session, state, first) @@ -63,12 +62,12 @@ describe('recordRequestHeader', () => { recordRequestHeader(session, state, second) const events = headerEvents(session) expect(events).toHaveLength(2) - expect(events[1]?.type).toBe('request/header-delta') + expect(events[1]?.type === 'request/header' && events[1].data.reason).toBe('change') expect(session.requestHeader()).toEqual(second) }) - it("records a change the delta cannot express (pure reordering) as a 'fallback' snapshot", () => { - const session = openSession('rl-fallback') + it("records a pure tool reordering as a 'change' snapshot", () => { + const session = openSession('rl-reorder') const state = createTransmissionLog() const first = canonicalHeader({ config: { model: 'm' }, tools: [tool('a'), tool('b')] }) recordRequestHeader(session, state, first) @@ -77,9 +76,7 @@ describe('recordRequestHeader', () => { recordRequestHeader(session, state, reordered) const events = headerEvents(session) expect(events).toHaveLength(2) - expect(events[1]?.type === 'request/header' && events[1].data.reason).toBe('fallback') - // The fold still lands on the exact header — deltas are an encoding - // optimization, never a correctness dependency. + expect(events[1]?.type === 'request/header' && events[1].data.reason).toBe('change') expect(session.requestHeader()).toEqual(reordered) }) }) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index bb6d613954..7054b683cb 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -1,7 +1,7 @@ /** * Loop-level reconstructability: every request the loop sends is a pure * function of the session log — messages are the derivation at the step/start - * boundary, the header is the fold of request/header* events — and every + * boundary, the header is the latest request/header snapshot — and every * request is an append-extension of its predecessor unless a logged event * (compaction replace, header change) explains the difference. The requests * recorded by the mock adapter are the observable; the offline-rebuild test @@ -87,7 +87,7 @@ describe('request stability across the loop', () => { expect(Object.isFrozen(request.messages)).toBe(true) } // One anchoring header snapshot; no further header events (nothing changed). - const headerEvents = agent.session.events.filter(e => e.type === 'request/header' || e.type === 'request/header-delta') + const headerEvents = agent.session.events.filter(e => e.type === 'request/header') expect(headerEvents).toHaveLength(1) expect(headerEvents[0]?.type === 'request/header' && headerEvents[0].data.reason).toBe('initial') }) @@ -124,8 +124,8 @@ describe('request stability across the loop', () => { content: [{ type: 'text', text: '[summary of turn 1]' }], source: { kind: 'plugin', plugin: 'test-compact' }, }, { - surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes[1]!.seq }, - sourceEventSeqs: [nodes[0]!.seq, nodes[1]!.seq], + surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, + sourceEventSeqs: [nodes[0]!, nodes[1]!], }) }) @@ -139,7 +139,7 @@ describe('request stability across the loop', () => { expect(agent.session.events.filter(e => e.type === 'request/header')).toHaveLength(1) }) - it('a real system-prompt change is a logged header delta; a stable prompt logs nothing', async () => { + it('a real system-prompt change is a full changed-header snapshot; a stable prompt logs nothing', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -149,14 +149,15 @@ describe('request stability across the loop', () => { send(agent, 'second') await waitForIdle(ctx, agent) // Identical assembly re-rendered per step is NOT a change. - expect(agent.session.events.filter(e => e.type === 'request/header-delta')).toHaveLength(0) + expect(agent.session.events.filter(e => e.type === 'request/header')).toHaveLength(1) ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'new guidance' }) send(agent, 'third') await waitForIdle(ctx, agent) - const deltas = agent.session.events.filter(e => e.type === 'request/header-delta') - expect(deltas).toHaveLength(1) + const snapshots = agent.session.events.filter(e => e.type === 'request/header') + expect(snapshots).toHaveLength(2) + expect(snapshots[1]?.data.reason).toBe('change') expect(adapter.requests[2]!.system).toContain('new guidance') // History is preserved across the change — only the header moved. expect(adapter.requests[2]!.messages.length).toBeGreaterThan(adapter.requests[1]!.messages.length) @@ -262,9 +263,9 @@ describe('request stability across the loop', () => { send(agent, 'second') await waitForIdle(ctx, agent) - // No delta was logged (nothing really changed), and the session's own + // No changed snapshot was logged (nothing really changed), and the session's own // fold is immutable state. - expect(agent.session.events.filter(e => e.type === 'request/header-delta')).toHaveLength(0) + expect(agent.session.events.filter(e => e.type === 'request/header')).toHaveLength(1) expect(Object.isFrozen(agent.session.requestHeader())).toBe(true) expect(adapter.requests[1]!.temperature).toBeUndefined() }) @@ -298,7 +299,7 @@ describe('request stability across the loop', () => { const rebuilt = new Session(SessionId(`rebuild-${index}`), structuredClone(events.slice(0, stepStart.seq))) expect(structuredClone(request.messages)).toEqual(rebuilt.deriveMessages()) - // Header: the fold of request/header* events up to this step's dispatch + // Header: the latest request/header snapshot up to this step's dispatch // (its header event sits between step/start and the first chunk). const firstChunk = events.find(e => e.type === 'assistant/chunk' && e.seq > stepStart.seq)! const header = foldRequestHeader(events.slice(0, firstChunk.seq))! diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 7a046dcc82..a990f0d67d 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -464,7 +464,7 @@ declare module 'cordis' { * `additionalContext`, prompt sections via `system-prompt/assemble`, or * the header-logged session prefix via {@link agent/session-prefix} * — never through request mutation, and the loop records whatever config - * the request actually uses as a `request/header*` event before dispatch. + * the request actually uses as a `request/header` event before dispatch. * The step's messages are already snapshotted when this fires (the * `step/start` boundary): an `inject()` from a listener here lands in the * log but joins the NEXT request. For surface mutation that must precede diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 25d6b40be6..39582ad555 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -1,6 +1,6 @@ # dsh-session -Event-sourced session log and in-memory store. A `Session` is the append-only source of truth for an agent's whole interaction history — the LLM message history is *derived* from it. A **surface** layer (a linked list of message-producing events) is maintained on top of the raw log for efficient derivation and compaction. +Event-sourced session log and in-memory store. A `Session` is the append-only source of truth for an agent's whole interaction history — the LLM message history is *derived* from it. A **surface** layer (an ordered sequence of message-producing event seqs) is maintained on top of the raw log for efficient derivation and compaction. ## Service: `SessionStore` (ctx key: `sessions`) @@ -33,7 +33,7 @@ The store pairs announced creation with disposal, publishes each append, and pro Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. - `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. At this durable boundary, data and surface metadata are lossless-JSON snapshotted and deep-frozen. For an attached session, a reentrant append during dispatch/observer publication rejects, and detach waits for that publication to unwind. Callbacks resolve before the log push; the push is the commit point, after which each observer failure is contained independently. Runtime surface validation covers widened unions and raw seed/load logs. -- `session.deriveMessages(): Message[]` — the LLM message history, CACHED: each surface node is projected exactly once, when first seen (O(new nodes) per call; a surface rewrite rebuilds via `surface.replaceGeneration`). Returns a fresh array per call over shared, deep-frozen `Message` objects. Each projection reuses the already deep-frozen content in its durable log event, so no second deep clone is needed and a consumer still cannot mutate logged data. The surface is the single source of derived history — there is no raw-log fallback. +- `session.deriveMessages(): Message[]` — the LLM message history, CACHED: each surface entry is projected exactly once, when first seen (O(new entries) per call; a surface rewrite rebuilds via `surface.replaceGeneration`). Returns a fresh array per call over shared, deep-frozen `Message` objects. Each projection reuses the already deep-frozen content in its durable log event, so no second deep clone is needed and a consumer still cannot mutate logged data. The surface is the single source of derived history — there is no raw-log fallback. - `session.deriveEventMessage(event): Message | null` — the per-event projection `deriveMessages()` folds: a fresh message wrapper that reuses the event's already frozen content, or `null` when the event produces none (a non-surface event, or an empty-content `assistant/message` hosting only usage). External reconstructors and the dev invariant fold the same function over a log prefix's surface, so no two paths can disagree about what a request's messages were (the reconstructability RFC). - `session.surface: SurfaceManager` — the derived surface, lazily folded from `surfaceOp` markers in the log. Processes only new events (delta) on each access — the log is append-only, so prior events never change. `surface.replaceGeneration` is the rewrite signal: bumped by every folded `replace` and never reset, so an incremental consumer comparing generations cannot be fooled. - `session.events` — a cached, frozen array snapshot over deep-frozen events. Repeated reads without an append return the same array; an append invalidates the cache and the next read returns a new snapshot, while earlier snapshots stay unchanged. Neither a cast nor a retained reference can push into the live log or rewrite an accepted event. @@ -46,14 +46,13 @@ Durable values need one accepted representation, not a check followed by a secon ### Surface types -- `SurfaceOp` — how a surface node entered the linked list: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace nodes from `start` through `end` inclusive — both must be valid surface node seqs; `start === end` replaces a single node). Used by compaction to shadow old nodes without deleting them. +- `SurfaceOp` — how an event entered the ordered surface: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace entries from `start` through `end` inclusive — both must be valid surface seqs; `start === end` replaces one entry). Used by compaction to shadow old events without deleting them. - `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types. -- `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. +- `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully formed surface event (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 / whole session prefix) events make the request envelope logged session state, so every conversation request is a pure function of the log. The pure trio reconstructs it: `foldRequestHeader(events)` folds a log (or any prefix) into the header in force; `diffHeader(prev, next)` encodes a change (undefined when equal); `applyHeaderDelta(prev, delta)` replays one. Writer contract: every logged delta is round-trip-verified (`apply(prev, delta)` deep-equals the new header) with a `'fallback'` snapshot when the encoding cannot express the change (a pure tool reordering), so folding never needs error recovery on a well-formed log. `canonicalHeader` pins the one representation of absence (empty system/tools/messagePrefix ≡ absent fields; a delta's EMPTY prefix array encodes the transition back to absence). `EpochHeader.messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product — composed once per loop instance, the request is `messagePrefix + derived history`, and `deriveMessages()` never returns it. +The `request/header` event records a full canonical `EpochHeader` snapshot with reason `initial`, `resume`, or `change`, making the request envelope logged session state and every conversation request a pure function of the log. `foldRequestHeader(events)` selects the latest snapshot from a log or prefix; `canonicalHeader` pins the one representation of absence (empty system/tools/messagePrefix ≡ absent fields), and `headerEquals` compares canonical headers. `EpochHeader.messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product — composed once per loop instance, the request is `messagePrefix + derived history`, and `deriveMessages()` never returns it. Legacy v0 seeds containing the removed `request/header-delta` format are rejected rather than partially replayed. ### Session event vocabulary (`types.ts`) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index a6dff5cd27..06669ca85c 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -22,10 +22,9 @@ export * from './types.ts' export { isJsonValue, snapshotJsonValue } from './json.ts' export type { JsonValue } from './json.ts' 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, headerEquals } from './request-header.ts' +export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts' declare module 'cordis' { interface Context { @@ -197,6 +196,9 @@ function assertSurfaceMetadataShape( /** Validate the fixed event envelope after one-pass JSON materialization. */ function assertSessionEventEnvelope(value: Record, index: number): asserts value is SessionEvent { const event = value + if (event['type'] === 'request/header-delta') { + throw new Error(`seed event at index ${index} uses unsupported legacy request/header-delta format`) + } const allowed = new Set(['type', 'seq', 'time', 'data', 'surfaceOp', 'sourceEventSeqs']) if (Object.keys(event).some(key => !allowed.has(key)) || !Object.hasOwn(event, 'type') || typeof event['type'] !== 'string' @@ -262,7 +264,7 @@ export class Session { private log: SessionEvent[] = [] /** - * Derived surface — a cached linked list of message-producing events. + * Derived surface — a cached order of message-producing event sequences. * Lazily rebuilt from `surfaceOp` markers in the log; processes only new * events (delta) on each access — the log is append-only, so prior events * never change. @@ -270,7 +272,7 @@ export class Session { */ private _surface: SurfaceManager | undefined - /** The surface linked list over this session's event log. */ + /** The ordered surface over this session's event log. */ get surface(): SurfaceManager { if (!this._surface) this._surface = new SurfaceManager(this.log) return this._surface @@ -354,7 +356,7 @@ export class Session { * @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 + * the ordered surface; `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 @@ -463,8 +465,8 @@ 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 + * Derive the LLM message history by walking the ordered sequences 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 @@ -488,11 +490,11 @@ export class Session { this.derivedNodes = 0 this.derivedGeneration = generation } - for (const node of nodes.slice(this.derivedNodes)) { - // Surface nodes are built from this.log — node.seq is always a valid + for (const seq of nodes.slice(this.derivedNodes)) { + // Surface sequences are built from this.log — seq is always a valid // index by construction. The non-null assertion expresses that invariant. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const msg = this.deriveEventMessage(this.log[node.seq]!) + const msg = this.deriveEventMessage(this.log[seq]!) // A surface node is one of the five message-producing types, but an // empty-content assistant/message (a max-tokens step that hosts only // usage) derives to null and must not enter the transcript. diff --git a/packages/core/session/src/request-header.ts b/packages/core/session/src/request-header.ts index eeb2fe40ed..8dd61ee884 100644 --- a/packages/core/session/src/request-header.ts +++ b/packages/core/session/src/request-header.ts @@ -1,35 +1,20 @@ /** - * Request-header reconstruction utilities: the pure fold/diff/apply trio over - * the `request/header` / `request/header-delta` session events. Anyone - * holding a session log reconstructs the {@link EpochHeader} any request was - * built under by folding these events in log order; the loop uses the same - * functions to decide whether a step's header changed and to encode the - * change. Deltas are an encoding optimization with a safety valve — the - * writer round-trip-verifies every delta before appending and falls back to - * a full snapshot when the encoding cannot express the change — so folding - * never needs error recovery on a well-formed log. + * Request-header reconstruction utilities over full `request/header` session + * events. Anyone holding a session log reconstructs the {@link EpochHeader} + * any request was built under by taking the latest canonical snapshot; the + * loop uses the same equality helper to avoid logging unchanged headers. * * @module dsh-session/request-header */ import { callConfigEquals } from '@deepseek-ai/dsh-llm' -import type { LlmCallConfig, Message, ToolSchema } from '@deepseek-ai/dsh-llm' -import type { EpochHeader, SessionEvent, SystemDelta, ToolsDelta } from './types.ts' - -/** The `request/header-delta` payload shape: each present field amends the folded header. */ -type HeaderDelta = { - system?: SystemDelta - tools?: ToolsDelta - config?: LlmCallConfig - messagePrefix?: Message[] -} +import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { EpochHeader, SessionEvent } from './types.ts' /** - * Normalize a header to canonical form: an empty system prompt, an empty - * tool list, and an empty session prefix become ABSENT fields, matching how - * requests are built (the request-build spreads skip empty values). Diff, - * fold, and comparison all operate on canonical headers, so "no system - * prompt" (and "no session prefix") has exactly one representation. + * Normalize a header to canonical form: an empty system prompt, an empty tool + * list, and an empty session prefix become absent fields, matching how requests + * are built. Logging, folding, and comparison use this one representation. * @param header - the header to normalize (not mutated). * @returns the canonical header. */ @@ -42,88 +27,22 @@ export function canonicalHeader(header: EpochHeader): EpochHeader { } } -/** 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. */ +/** Canonical JSON equality for tool schemas assembled through the same path. */ 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] +/** Canonical JSON equality over session-prefix arrays; absence equals empty. */ +function sameMessages(a: readonly Message[] | undefined, b: readonly Message[] | undefined): boolean { + return JSON.stringify(a ?? []) === JSON.stringify(b ?? []) } /** - * 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. Tool schemas compare in order; + * the session prefix compares as canonical JSON. * @param a - one canonical header. * @param b - the other. - * @returns whether config, system, tools (in order), and the session prefix all match. + * @returns whether config, system, tools, and session prefix all match. */ export function headerEquals(a: EpochHeader, b: EpochHeader): boolean { if (!callConfigEquals(a.config, b.config) || a.system !== b.system) return false @@ -133,77 +52,19 @@ export function headerEquals(a: EpochHeader, b: EpochHeader): boolean { return at.length === bt.length && at.every((tool, i) => sameSchema(tool, bt[i] as ToolSchema)) } -/** Canonical JSON equality over session-prefix arrays; absence equals the empty array. */ -function sameMessages(a: readonly Message[] | undefined, b: readonly Message[] | undefined): boolean { - return JSON.stringify(a ?? []) === JSON.stringify(b ?? []) -} - /** - * 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". - * @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): HeaderDelta | undefined { - const delta: HeaderDelta = {} - 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 - if (!sameMessages(prev.messagePrefix, next.messagePrefix)) delta.messagePrefix = next.messagePrefix ?? [] - 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: HeaderDelta): 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 - const messagePrefix = delta.messagePrefix ?? prev.messagePrefix - return canonicalHeader({ - config: delta.config ?? prev.config, - ...system !== undefined ? { system } : {}, - ...tools !== undefined ? { tools } : {}, - ...messagePrefix !== undefined ? { messagePrefix } : {}, - }) -} - -/** - * 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). - * @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. + * Fold the header events of a log (or any prefix) into the + * {@link EpochHeader} in force after the last snapshot. Non-header events are + * skipped. This is the pure offline reconstruction path; the live session + * tracks the same fold incrementally. + * @param events - session events in log order. + * @param from - a previously folded state to continue from. + * @returns the latest canonical header, or undefined when none exists yet. */ export function foldRequestHeader(events: readonly SessionEvent[], from?: EpochHeader): EpochHeader | undefined { - let state: EpochHeader | undefined = from + let state = from 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) - } + if (event.type === 'request/header') state = canonicalHeader(event.data.header) } return state } diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index d6322c72e7..18721281db 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -1,6 +1,6 @@ /** - * Surface layer on top of the session event log: a derived, cached linked list - * of events that produce LLM messages. Rebuilt deterministically from + * Surface layer on top of the session event log: a derived, cached sequence + * list of events that produce LLM messages. Folded deterministically from * `surfaceOp` markers in the log — the log is the source of truth; the surface * is a view. * @@ -10,7 +10,7 @@ import type { SessionEvent, SurfaceEvent, SurfaceEventType, SurfaceOp } from './types.ts' /** - * The set of event type strings that are eligible for the surface linked list. + * The set of event type strings that are eligible for the surface sequence. * Mirrors the {@link SurfaceEventType} union; kept as a runtime set so the * type guard can check membership without a chain of string comparisons. */ @@ -51,28 +51,16 @@ export function isSurfaceEvent(event: SessionEvent): event is SurfaceEvent { return true } -/** One node in the surface linked list. */ -export interface SurfaceNode { - /** The event seq of this surface node. */ - seq: number - /** The previous surface node's seq, or null if this is the head. */ - prev: number | null - /** The next surface node's seq, or null if this is the tail. */ - next: number | null -} - /** - * Maintains a cached linked list of surface nodes, rebuilt lazily from + * Maintains a cached ordered list of surface event sequences, folded lazily from * `surfaceOp` markers in the event log. Because the log is append-only, it * processes only the delta since the last rebuild — new events are folded * into the existing surface in O(new events) rather than rescanning the * whole log. */ export class SurfaceManager { - /** Surface nodes in linked-list order (head to tail). Empty until first access. */ - private _nodes: SurfaceNode[] = [] - /** Map from event seq → node. */ - private _nodeBySeq = new Map() + /** Surface event sequences in head-to-tail order. Empty until first access. */ + private _nodes: number[] = [] /** The last processed seq. -1 folds the seeded log on first access. */ private _lastProcessedSeq = -1 @@ -95,15 +83,15 @@ export class SurfaceManager { return this._replaceGeneration } - /** The surface nodes in linked-list order (head to tail). */ - get nodes(): readonly SurfaceNode[] { + /** Surface event sequences in head-to-tail order. */ + get nodes(): readonly number[] { if (this._lastProcessedSeq < this.log.length - 1) this._processDelta() return this._nodes } /** * Process events from `_lastProcessedSeq + 1` through the end of the log, - * folding new surface markers into the existing linked list. + * folding new surface markers into the existing sequence list. */ private _processDelta(): void { for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) { @@ -116,11 +104,7 @@ export class SurfaceManager { if (!isSurfaceEvent(event)) continue if (event.surfaceOp === 'append') { - const tail = this._nodes.length > 0 ? this._nodes[this._nodes.length - 1] : undefined - const node: SurfaceNode = { seq: event.seq, prev: tail?.seq ?? null, next: null } - if (tail) tail.next = event.seq - this._nodes.push(node) - this._nodeBySeq.set(event.seq, node) + this._nodes.push(event.seq) } else { this._replace(event.seq, event.surfaceOp) } @@ -133,38 +117,21 @@ export class SurfaceManager { newSeq: number, op: Extract, ): void { - const startNode = this._nodeBySeq.get(op.start) - if (!startNode) { + const startIdx = this._nodes.indexOf(op.start) + if (startIdx === -1) { throw new Error(`surface replace: start seq ${op.start} not found in surface`) } - const endNode = this._nodeBySeq.get(op.end) - if (!endNode) { + const endIdx = this._nodes.indexOf(op.end) + if (endIdx === -1) { throw new Error(`surface replace: end seq ${op.end} not found in surface`) } - const startIdx = this._nodes.indexOf(startNode) - const endIdx = this._nodes.indexOf(endNode) if (startIdx > endIdx) { throw new Error(`surface replace: start seq ${op.start} (index ${startIdx}) is after end seq ${op.end} (index ${endIdx})`) } // Remove shadowed nodes from `[startIdx, endIdx]` inclusive. const count = endIdx - startIdx + 1 - const removed = this._nodes.splice(startIdx, count) - for (const r of removed) this._nodeBySeq.delete(r.seq) - - // Insert the new node where the removed range was. - const prevNode = startIdx > 0 ? this._nodes[startIdx - 1] : undefined - const nextNode = startIdx < this._nodes.length ? this._nodes[startIdx] : undefined - - const newNode: SurfaceNode = { - seq: newSeq, - prev: prevNode?.seq ?? null, - next: nextNode?.seq ?? null, - } - if (prevNode) prevNode.next = newSeq - if (nextNode) nextNode.prev = newSeq - this._nodes.splice(startIdx, 0, newNode) - this._nodeBySeq.set(newSeq, newNode) + this._nodes.splice(startIdx, count, newSeq) this._replaceGeneration += 1 } } diff --git a/packages/core/session/src/tool-pairing.ts b/packages/core/session/src/tool-pairing.ts index 6ea3042bd7..8f1b35708f 100644 --- a/packages/core/session/src/tool-pairing.ts +++ b/packages/core/session/src/tool-pairing.ts @@ -35,7 +35,6 @@ */ import type { SessionEvent } from './types.ts' -import type { SurfaceNode } from './surface.ts' /** * The tool-pairing delta of a surface node: how it shifts the count of @@ -62,20 +61,20 @@ function nodeDelta(event: SessionEvent): number { * 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. + * `nodes` is the surface sequence 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 + * event up by sequence. `beforeSeq` names the cut by the surface event 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 — + * surface successor (`nodes[index + 1]`), or `null` when `end` is the tail — * for the cut after `end`. * - * @param nodes - the surface linked list in head→tail order. - * @param events - the session log each node's `seq` indexes into. + * @param nodes - surface event sequences in head→tail order. + * @param events - the session log each sequence 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 @@ -86,18 +85,18 @@ function nodeDelta(event: SessionEvent): number { * rather than silently mis-classifying a boundary. */ export function isToolPairingBalanced( - nodes: readonly SurfaceNode[], + nodes: readonly number[], events: readonly SessionEvent[], beforeSeq: number | null, ): boolean { 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. + for (const seq of nodes) { + if (seq === beforeSeq) return depth === 0 + // seq is a surface event sequence, always a valid log index by construction. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - depth += nodeDelta(events[node.seq]!) + depth += nodeDelta(events[seq]!) if (depth < 0) { - throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`) + throw new Error(`tool-pairing balance: tool/result at surface seq ${seq} has no matching tool-call (corrupt surface)`) } } // Reached the after-tail cut (beforeSeq === null, or a seq not on the diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index c4838808c1..c2997c0b47 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -195,10 +195,9 @@ export interface TodoItem { * The request header: everything about an LLM request besides its derived * message history — the call configuration plus the rendered system prompt, * tool schemas, and the session prefix. Logged session state (the - * reconstructability RFC): a - * {@link SessionEventMap} `request/header` snapshot installs one, a - * `request/header-delta` amends it, and folding those events over the log - * (`foldRequestHeader`) reconstructs the header any request was built under. + * reconstructability RFC): each changed header is logged as a full + * {@link SessionEventMap} `request/header` snapshot, and taking the latest + * snapshot (`foldRequestHeader`) reconstructs the header any request used. * Canonical form: an empty system prompt, an empty tool list, and an empty * prefix are ABSENT fields, matching how requests are built. */ @@ -223,43 +222,9 @@ export interface EpochHeader { * 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. + * `'change'` — a later request used a different header. */ -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[] -} +export type RequestHeaderReason = 'initial' | 'resume' | 'change' /** * The session event vocabulary — the append-only source of truth for an @@ -363,32 +328,14 @@ export interface SessionEventMap { * 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 + * request-building step (`'initial'`/`'resume'`) or when a later request's + * header changes (`'change'`); always records what the request actually used, + * post-`agent/request`. Reconstruction reads the latest snapshot. 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}, 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}. - */ - 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] } } /** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */ @@ -396,7 +343,7 @@ export type SessionEventType = keyof SessionEventMap /** * The subset of {@link SessionEventType} values whose events produce LLM - * messages and are eligible to appear on the surface linked list. Only these + * messages and are eligible to appear on the ordered surface. Only these * event types may carry {@link SurfaceOp} and {@link SessionEvent.sourceEventSeqs}. */ export type SurfaceEventType = @@ -407,7 +354,7 @@ export type SurfaceEventType = | 'steering/message' /** - * A {@link SessionEvent} that is **on** the surface linked list — its + * A {@link SessionEvent} that is **on** the ordered surface — its * `surfaceOp` is guaranteed present (mandatory), narrowed from a * surface-eligible {@link SessionEvent} by checking both `type` and * `surfaceOp` at runtime. @@ -418,7 +365,7 @@ export type SurfaceEventType = export type SurfaceEvent = SessionEvent & { surfaceOp: SurfaceOp } /** - * How a session event entered the surface linked list. Only valid on + * How a session event entered the ordered surface. Only valid on * {@link SurfaceEventType} events. * * - `'append'`: added to the tail — normal path for user/assistant/tool/context @@ -435,7 +382,7 @@ export type SurfaceOp = /** * Surface metadata passed to {@link Session.append}. - * `surfaceOp` controls how the event enters the surface linked list; + * `surfaceOp` controls how the event enters the ordered surface; * `sourceEventSeqs` records the seq numbers of events that are provenance * sources of this one (e.g. the `assistant/chunk` seqs behind an * `assistant/message`, or the shadowed nodes behind a compaction replacement). diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts index 493a5a96f0..c65755ce8f 100644 --- a/packages/core/session/tests/derived-cache.spec.ts +++ b/packages/core/session/tests/derived-cache.spec.ts @@ -44,7 +44,7 @@ describe('derived-message cache', () => { const nodes = session.surface.nodes session.append('context/message', { content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' }, - }, { surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes[1]!.seq }, sourceEventSeqs: [nodes[0]!.seq, nodes[1]!.seq] }) + }, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] }) expect(session.deriveMessages()).toHaveLength(1) expect(session.deriveMessages()).toEqual(scratch(session)) diff --git a/packages/core/session/tests/request-header.spec.ts b/packages/core/session/tests/request-header.spec.ts index 8a5af819c3..fa2acdda28 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/packages/core/session/tests/request-header.spec.ts @@ -1,14 +1,7 @@ -/** - * 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. - */ +/** Request-header canonicalization, equality, snapshot folding, and format rejection. */ import { describe, expect, it } from 'vitest' -import { Session, SessionId, applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session' +import { Session, SessionId, canonicalHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session' import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' @@ -22,165 +15,57 @@ function msg(text: string): Message { return { role: 'user', content: [{ type: 'text', text }] } } -/** 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) + it('normalizes empty optional fields to absence and preserves populated fields', () => { + expect(canonicalHeader({ config: CONFIG, system: '', tools: [], messagePrefix: [] })).toEqual({ config: CONFIG }) + const full = canonicalHeader({ config: CONFIG, system: 's', tools: [tool('a')], messagePrefix: [msg('p')] }) + expect(full).toEqual({ config: CONFIG, system: 's', tools: [tool('a')], messagePrefix: [msg('p')] }) }) }) -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() +describe('headerEquals', () => { + const base = canonicalHeader({ config: CONFIG, system: 's', tools: [tool('a')], messagePrefix: [msg('p')] }) + + it('compares every canonical field and preserves tool order', () => { + expect(headerEquals(base, structuredClone(base))).toBe(true) + expect(headerEquals(base, { ...base, config: { model: 'other' } })).toBe(false) + expect(headerEquals(base, { ...base, system: 'other' })).toBe(false) + expect(headerEquals(base, { ...base, messagePrefix: [msg('other')] })).toBe(false) + expect(headerEquals(base, { ...base, tools: [] })).toBe(false) + expect(headerEquals(base, { ...base, tools: [tool('a', 'changed')] })).toBe(false) + expect(headerEquals({ config: CONFIG, tools: [tool('a'), tool('b')] }, { config: CONFIG, tools: [tool('b'), tool('a')] })).toBe(false) }) - 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('the session prefix (messagePrefix)', () => { - it('canonicalHeader normalizes an empty prefix to an absent field', () => { - expect(canonicalHeader({ config: CONFIG, messagePrefix: [] })).toEqual({ config: CONFIG }) - const full = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')] }) - expect(full.messagePrefix).toEqual([msg('p')]) - }) - - it('headerEquals treats absence and empty as one representation, content differences as unequal', () => { - expect(headerEquals(canonicalHeader({ config: CONFIG }), { config: CONFIG, messagePrefix: [] })).toBe(true) - expect(headerEquals({ config: CONFIG, messagePrefix: [msg('a')] }, { config: CONFIG, messagePrefix: [msg('b')] })).toBe(false) - expect(headerEquals({ config: CONFIG, messagePrefix: [msg('a')] }, { config: CONFIG })).toBe(false) - }) - - it('replaces a changed prefix whole and leaves untouched parts alone', () => { - const prev = canonicalHeader({ config: CONFIG, system: 'keep', messagePrefix: [msg('old')] }) - const next = canonicalHeader({ config: CONFIG, system: 'keep', messagePrefix: [msg('new'), msg('more')] }) - const delta = roundTrip(prev, next) - expect(delta).toEqual({ messagePrefix: [msg('new'), msg('more')] }) - }) - - it('round-trips a prefix gained from a bare header and lost back to one (empty array encodes absence)', () => { - const none = canonicalHeader({ config: CONFIG }) - const some = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')] }) - const gained = roundTrip(none, some) - expect(gained).toEqual({ messagePrefix: [msg('p')] }) - const lost = roundTrip(some, none) - expect(lost).toEqual({ messagePrefix: [] }) - }) - - it('folds prefix deltas over the log like any other header amendment', () => { - const session = new Session(SessionId('fold-prefix')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - const first = canonicalHeader({ config: CONFIG, messagePrefix: [msg('catalog v1')] }) - session.append('request/header', { header: first, reason: 'initial' }) - const second = canonicalHeader({ config: CONFIG, messagePrefix: [msg('catalog v2')] }) - session.append('request/header-delta', diffHeader(first, second)!) - expect(foldRequestHeader(session.events)).toEqual(second) - session.append('request/header-delta', diffHeader(second, canonicalHeader({ config: CONFIG }))!) - expect(foldRequestHeader(session.events)).toEqual({ config: CONFIG }) + it('treats absent and empty prefix/tool arrays as equivalent canonical absence', () => { + expect(headerEquals({ config: CONFIG }, { config: CONFIG, tools: [], messagePrefix: [] })).toBe(true) }) }) 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('returns the supplied baseline when no snapshot follows', () => { + const from: EpochHeader = { config: CONFIG, system: 'baseline' } + const unrelated: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + ] + expect(foldRequestHeader(unrelated)).toBeUndefined() + expect(foldRequestHeader(unrelated, from)).toBe(from) }) - it('folds snapshot then deltas into the header in force, skipping unrelated events', () => { + it('takes the latest full snapshot and skips 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('request/header', { header: { config: CONFIG, system: '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/) + session.append('request/header', { header: { config: { model: 'other' }, tools: [] }, reason: 'change' }) + expect(foldRequestHeader(session.events)).toEqual({ config: { model: 'other' } }) + }) +}) + +describe('legacy request-header format', () => { + it('rejects a v0 seed containing request/header-delta', () => { + const legacy = [{ + type: 'request/header-delta', seq: 0, time: 1, data: { config: CONFIG }, + }] as unknown as SessionEvent[] + expect(() => new Session(SessionId('legacy'), legacy)).toThrow(/unsupported legacy request\/header-delta/) }) }) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 2204fc9027..20746260d5 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -1171,8 +1171,8 @@ describe('todo/write event', () => { session.append('todo/write', { todos: [{ content: 'a task', status: 'pending' }] }) // The todo event must not add a message to the derived history… expect(session.deriveMessages()).toHaveLength(before) - // …and must not appear on the surface linked list. - expect(session.surface.nodes.some(node => node.seq === session.seq - 1)).toBe(false) + // …and must not appear on the ordered surface. + expect(session.surface.nodes).not.toContain(session.seq - 1) }) it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => { diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index c03a77d2c3..2a658ff82a 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -14,18 +14,12 @@ function surfaceSession(): Session { } describe('SurfaceManager', () => { - it('rebuilds a linked list from surfaceOp: append markers', () => { + it('folds an ordered sequence list from surfaceOp: append markers', () => { const s = surfaceSession() const nodes = s.surface.nodes // Only the user/message and assistant/message carry surfaceOp: 'append'. // The turn boundaries do not have surface markers. - expect(nodes.length).toBe(2) - expect(nodes[0]!.seq).toBe(1) // user/message (turn/start is seq 0) - expect(nodes[0]!.prev).toBeNull() - expect(nodes[0]!.next).toBe(2) // assistant/message (seq 2) - expect(nodes[1]!.seq).toBe(2) - expect(nodes[1]!.prev).toBe(1) - expect(nodes[1]!.next).toBeNull() + expect(nodes).toEqual([1, 2]) }) it('empty surface yields empty nodes', () => { @@ -46,9 +40,7 @@ describe('SurfaceManager', () => { // Append another surface node s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' }) expect(s.surface.nodes.length).toBe(3) - expect(s.surface.nodes[2]!.seq).toBe(4) // seq 4: after turn/end at seq 3 - expect(s.surface.nodes[2]!.prev).toBe(2) - expect(s.surface.nodes[1]!.next).toBe(4) + expect(s.surface.nodes[2]!).toBe(4) // seq 4: after turn/end at seq 3 }) it('replays identically from a seeded log with surface markers', () => { @@ -56,7 +48,7 @@ describe('SurfaceManager', () => { original.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' }) const replayed = new Session(SessionId('replay'), [...original.events]) // Surface rebuilds from the seeded log's markers. - expect(replayed.surface.nodes.map(n => n.seq)).toEqual([1, 2, 4]) + expect(replayed.surface.nodes).toEqual([1, 2, 4]) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) }) @@ -70,10 +62,7 @@ describe('SurfaceManager', () => { { surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] }, ) // Now the surface should have just the compaction node. - expect(s.surface.nodes.length).toBe(1) - expect(s.surface.nodes[0]!.seq).toBe(4) // seq of the compaction marker - expect(s.surface.nodes[0]!.prev).toBeNull() - expect(s.surface.nodes[0]!.next).toBeNull() + expect(s.surface.nodes).toEqual([4]) }) it('replace with both ends at real nodes splices only the range', () => { @@ -86,12 +75,7 @@ describe('SurfaceManager', () => { { turn: 1, step: 1, content: [{ type: 'text', text: 'summary' }] }, { surfaceOp: { op: 'replace', start: 0, end: 1 }, sourceEventSeqs: [0, 1] }, ) // seq 3 - expect(s.surface.nodes.map(n => n.seq)).toEqual([3, 2]) - // Links: 3 ↔ 2 - expect(s.surface.nodes[0]!.prev).toBeNull() - expect(s.surface.nodes[0]!.next).toBe(2) - expect(s.surface.nodes[1]!.prev).toBe(3) - expect(s.surface.nodes[1]!.next).toBeNull() + expect(s.surface.nodes).toEqual([3, 2]) }) it('single-node replacement (start === end)', () => { @@ -103,9 +87,7 @@ describe('SurfaceManager', () => { { turn: 1, step: 1, content: [{ type: 'text', text: 'x' }] }, { surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1] }, ) // seq 2 - expect(s.surface.nodes.map(n => n.seq)).toEqual([0, 2]) - expect(s.surface.nodes[0]!.next).toBe(2) - expect(s.surface.nodes[1]!.prev).toBe(0) + expect(s.surface.nodes).toEqual([0, 2]) }) it('throws when replace start is not found', () => { @@ -151,7 +133,7 @@ describe('SurfaceManager', () => { expect(logged.sourceEventSeqs).toEqual([10, 20]) }) - it('replace starting at non-head position links to previous node correctly', () => { + it('replace starting at non-head position preserves surrounding order', () => { const s = new Session(SessionId('mid-replace')) s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0 s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1 @@ -161,14 +143,7 @@ describe('SurfaceManager', () => { { turn: 1, step: 1, content: [{ type: 'text', text: 'x' }] }, { surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1] }, ) // seq 3 - expect(s.surface.nodes.map(n => n.seq)).toEqual([0, 3, 2]) - // Links: 0 → 3 → 2 - expect(s.surface.nodes[0]!.prev).toBeNull() - expect(s.surface.nodes[0]!.next).toBe(3) - expect(s.surface.nodes[1]!.prev).toBe(0) - expect(s.surface.nodes[1]!.next).toBe(2) - expect(s.surface.nodes[2]!.prev).toBe(3) - expect(s.surface.nodes[2]!.next).toBeNull() + expect(s.surface.nodes).toEqual([0, 3, 2]) }) it('surfaceOp replace object is snapshot so caller mutation is isolated', () => { @@ -340,7 +315,7 @@ describe('SurfaceManager.replaceGeneration', () => { const nodes = s.surface.nodes s.append('context/message', { content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' }, - }, { surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes[1]!.seq }, sourceEventSeqs: [nodes[0]!.seq, nodes[1]!.seq] }) + }, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] }) expect(s.surface.replaceGeneration).toBe(1) }) }) diff --git a/packages/core/session/tests/tool-pairing.spec.ts b/packages/core/session/tests/tool-pairing.spec.ts index 307b0d8658..8e7b8b4a03 100644 --- a/packages/core/session/tests/tool-pairing.spec.ts +++ b/packages/core/session/tests/tool-pairing.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId, isToolPairingBalanced } from '../src/index.ts' -import type { SessionEvent, SurfaceNode } from '../src/index.ts' +import type { SessionEvent } from '../src/index.ts' /** * Unit coverage for the tool-pairing balance check. It decides whether a CUT in @@ -12,8 +12,8 @@ import type { SessionEvent, SurfaceNode } from '../src/index.ts' * no step (pre-step user message, inter-step steering, injection context) are * pairing-neutral, so their cuts are free boundaries. * - * The fixtures are built through a real {@link Session} so the surface linked - * list is derived exactly as production does — including the non-monotonic + * The fixtures are built through a real {@link Session} so the ordered surface + * sequence list is derived exactly as production does — including the non-monotonic * surface a `replace` op leaves (a compaction checkpoint at a high log seq * sitting at the surface head), which is the case the abandoned log-position * scan mis-classified. @@ -27,7 +27,7 @@ import type { SessionEvent, SurfaceNode } from '../src/index.ts' const SURFACE = { surfaceOp: 'append' as const } /** Surface nodes + log for a session, the two args the balance check takes. */ -function surfaceOf(session: Session): { nodes: readonly SurfaceNode[]; events: readonly SessionEvent[] } { +function surfaceOf(session: Session): { nodes: readonly number[]; events: readonly SessionEvent[] } { return { nodes: session.surface.nodes, events: session.events } } @@ -40,9 +40,9 @@ function startBalanced(session: Session, seq: number): boolean { /** The cut AFTER the surface node at `seq` is balanced (safe region end). */ function endBalanced(session: Session, seq: number): boolean { const { nodes, events } = surfaceOf(session) - const node = nodes.find(n => n.seq === seq) - if (!node) throw new Error(`seq ${seq} is not a surface node`) - return isToolPairingBalanced(nodes, events, node.next) + const index = nodes.indexOf(seq) + if (index === -1) throw new Error(`seq ${seq} is not a surface node`) + return isToolPairingBalanced(nodes, events, nodes[index + 1] ?? null) } /** Surface seq of the nth (0-based) event of a given type. */ @@ -274,20 +274,20 @@ describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace it('the head checkpoint sits at the surface head while a later surface node follows it in the log', () => { const s = checkpointHeadedSession() const nodes = s.surface.nodes - const checkpointSeq = nodes[0]!.seq + const checkpointSeq = nodes[0]! // The checkpoint heads the surface, yet a surface node (the open step's // assistant) follows it in LOG order — the exact split between surface // position and log position that the log-position scan tripped on. const laterSurfaceInLog = s.events.find( - e => e.seq > checkpointSeq && nodes.some(n => n.seq === e.seq), + e => e.seq > checkpointSeq && nodes.includes(e.seq), ) expect(laterSurfaceInLog).toBeDefined() - expect(nodes[0]!.seq).toBe(checkpointSeq) + expect(nodes[0]!).toBe(checkpointSeq) }) it('start cut before the head checkpoint is balanced (it is the head)', () => { const s = checkpointHeadedSession() - expect(startBalanced(s, s.surface.nodes[0]!.seq)).toBe(true) + expect(startBalanced(s, s.surface.nodes[0]!)).toBe(true) }) it('end cut after the head checkpoint is balanced (it carries no tool pair)', () => { @@ -296,7 +296,7 @@ describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace // wrongly reported mid-step. The surface balance sees a neutral node whose // following cut closes no open call. const s = checkpointHeadedSession() - expect(endBalanced(s, s.surface.nodes[0]!.seq)).toBe(true) + expect(endBalanced(s, s.surface.nodes[0]!)).toBe(true) }) }) diff --git a/packages/llm/llm/src/call-config.ts b/packages/llm/llm/src/call-config.ts index 53cb157214..19bb95bd12 100644 --- a/packages/llm/llm/src/call-config.ts +++ b/packages/llm/llm/src/call-config.ts @@ -7,7 +7,7 @@ * the same way out of caution. It is per-conversation state recorded in the * session log (the reconstructability RFC), never a silently-drifting * per-call knob: the `agent/request` waterfall proposes a replacement, and - * the loop logs a real change as a `request/header-delta` event. + * the loop logs a real change as a `request/header` snapshot. * * @module dsh-llm/call-config */ @@ -27,7 +27,7 @@ export interface LlmCallConfig { /** * Field-wise equality over {@link LlmCallConfig} — the comparison a caller * runs to decide whether a proposed configuration is a real change (worth a - * logged header delta) or the held one restated. + * logged header snapshot) or the held one restated. * @param a - one configuration. * @param b - the other. * @returns whether every field (including the `stop` list, element-wise) matches. diff --git a/packages/llm/llm/tests/call-config.spec.ts b/packages/llm/llm/tests/call-config.spec.ts index 65ff7d7d34..951a250a9f 100644 --- a/packages/llm/llm/tests/call-config.spec.ts +++ b/packages/llm/llm/tests/call-config.spec.ts @@ -1,6 +1,6 @@ /** * call-config unit tests: field-wise LlmCallConfig equality (the real-change - * detector behind logged header deltas) and the deepFreeze ownership helper + * detector behind logged changed headers) and the deepFreeze ownership helper * the loop applies to every built request. */ diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index e5c2955ef2..b9bc0f8d9a 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -6,7 +6,7 @@ import { join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import { encodeSegment, logPath, scanLog, sessionDir } from '../src/format.ts' +import { encodeSegment, logPath, scanLog, sessionDir, toHeaderLine } from '../src/format.ts' import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' @@ -147,6 +147,21 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { expect(loaded.events).toEqual(log) // chunks preserved, contiguous seqs }) + it('rejects a stored v0 log containing a legacy request/header-delta event', async () => { + const m = meta('legacy-header-delta', '/legacy') + const path = logPath(root, m.cwd, m.id) + await mkdir(sessionDir(root, m.cwd), { recursive: true }) + await writeFile(path, [ + JSON.stringify(toHeaderLine(m)), + JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), + JSON.stringify({ type: 'request/header-delta', seq: 1, time: 2, data: { config: { model: 'legacy' } } }), + JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }), + '', + ].join('\n')) + + await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported legacy request\/header-delta event at seq 1/) + }) + it('persists a forked child seed through the existing session write path', async () => { const source = ctx.sessions.create(SessionId('persist-parent'), { meta: { cwd: '/workspace' } }) appendClosedTurn(source) diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index a6eda72685..12857d2cd8 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -144,6 +144,23 @@ describe('scanRows', () => { }) describe('SessionPersistenceSqlite: durability and crash semantics', () => { + it('rejects a stored v0 log containing a legacy request/header-delta event', async () => { + const path = await freshDbPath() + const m = meta('legacy-header-delta', '/legacy') + const db = openDatabase(path, 'wal') + db.prepare('INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length) VALUES (?, ?, ?, ?, NULL, NULL)') + .run(m.id, m.version, m.createdAt, m.cwd ?? null) + const insert = db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)') + insert.run(m.id, 0, 'turn/start', 1, JSON.stringify({ turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })) + insert.run(m.id, 1, 'request/header-delta', 2, JSON.stringify({ config: { model: 'legacy' } })) + insert.run(m.id, 2, 'turn/end', 3, JSON.stringify({ turn: 1, reason: { kind: 'completed' } })) + db.close() + + const mounted = await backend(path) + await expect(mounted.ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported legacy request\/header-delta event at seq 1/) + await mounted.dispose() + }) + it('an interrupted turn (rows after the last turn/end) is PRESERVED and closed during load', async () => { const path = await freshDbPath() const m = meta('crash') diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index a3bae9cf87..4c42751816 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -151,6 +151,15 @@ function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly Sessio }) } +/** Reject events from an obsolete v0 vocabulary that this build cannot replay. */ +function assertSupportedEvents(events: readonly SessionEvent[], id: SessionId): void { + const legacyType: string = 'request/header-delta' + const legacy = events.find(event => event.type === legacyType) + if (legacy !== undefined) { + throw new Error(`session "${id}" contains unsupported legacy request/header-delta event at seq ${legacy.seq}`) + } +} + /** * Owns the backend-agnostic session write-path orchestration. A backend * constructs one (`new PersistenceCoordinator(ctx, this)`), implements @@ -242,6 +251,7 @@ export class PersistenceCoordinator { if (batch === undefined) { throw new TypeError('session event batch is not losslessly JSON-serializable because it contains non-JSON-serializable data') } + assertSupportedEvents(batch, id) return this.serialize(id, () => this.appendCore(id, batch)) } @@ -280,6 +290,7 @@ export class PersistenceCoordinator { if (stored === undefined) throw new Error(`session "${id}" not found`) const { meta, events, tornMarker } = stored this.assertVersion(meta) + assertSupportedEvents(events, id) // Crash-recovery: if the log ended mid-turn (real, preserved events but no // closing turn/end), close it durably DURING load so disk, the returned log, diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 501b98b793..13dd1ab247 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -35,7 +35,7 @@ defineAcpSnapshotSuite({ }) ``` -A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode scenarios are the template. Each pinning directory's generated `system-prompt.golden.md` is the reviewable snapshot of the normalized composed prompt; `session.jsonl` stores `"system":"{{system}}"` while retaining the complete tool list. +A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode scenarios are the template. Each pinning directory's generated `system-prompt.golden.md` is the reviewable snapshot of the normalized composed prompt; `session.jsonl` stores `"system":"{{system}}"` while retaining the complete tool list. A pin whose scenario legitimately changes its header mid-run declares `expectedHeaderChanges`; the Markdown snapshot then records each later full prompt under a `request/header change` marker. The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config RFC](../../../docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log goldens, and each pin's Markdown prompt snapshot from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). diff --git a/packages/support/acp-snapshot/src/normalize.ts b/packages/support/acp-snapshot/src/normalize.ts index a68fcf83d3..778353f762 100644 --- a/packages/support/acp-snapshot/src/normalize.ts +++ b/packages/support/acp-snapshot/src/normalize.ts @@ -135,8 +135,8 @@ export function normalizeSessionLog(rawLog: string, ctx: NormalizeContext): stri } /** - * Replace system-prompt content in request headers and header deltas with - * `{{system}}` tokens while retaining field presence and delta structure. + * Replace system-prompt content in request headers with `{{system}}` tokens + * while retaining field presence. * Other header content stays verbatim, so a header-pinning fixture can keep * its complete tool schemas while every JSONL fixture omits the prompt text. * Lines without a system payload pass through byte-for-byte; the transform is @@ -153,9 +153,9 @@ export function scrubSystemPrompts(rawLog: string): string { * Replace all bulky request-header content in a session JSONL with stable * tokens. This includes the system-prompt fields handled by * {@link scrubSystemPrompts}, tool schemas, and session-prefix messages. It - * keeps system-delta line positions and arity, tool-delta names, prefix - * message counts, field presence, config, and reason. Lines without content - * to scrub pass through byte-for-byte, and the transform is idempotent. + * keeps prefix message counts, field presence, config, and reason. Lines + * without content to scrub pass through byte-for-byte, and the transform is + * idempotent. * * @param rawLog The raw session `.jsonl` content. * @returns The JSONL with all header bulk tokenized, other lines byte-identical. @@ -184,33 +184,7 @@ function scrubHeaderContent(rawLog: string, scrubToolsAndPrefix: boolean): strin } return touched ? JSON.stringify(record) : line } - if (record.type === 'request/header-delta') { - let touched = false - const system = data.system as Record | null | undefined - if (system !== null && typeof system === 'object' && Array.isArray(system.insert)) { - system.insert = system.insert.map(() => SYSTEM) - touched = true - } - const tools = data.tools as Record | null | undefined - if (scrubToolsAndPrefix && tools !== null && typeof tools === 'object') { - if (Array.isArray(tools.added)) { tools.added = tools.added.map(scrubToolSchema); touched = true } - if (Array.isArray(tools.changed)) { tools.changed = tools.changed.map(scrubToolSchema); touched = true } - } - if (scrubToolsAndPrefix && Array.isArray(data.messagePrefix)) { - data.messagePrefix = data.messagePrefix.map(() => MESSAGE_PREFIX) - touched = true - } - return touched ? JSON.stringify(record) : line - } return line }) return out.join('\n') } - -/** Tokenize one tool schema's bulk (description, parameters, anything else), keeping its identifying `name`. */ -function scrubToolSchema(tool: unknown): unknown { - if (tool === null || typeof tool !== 'object' || Array.isArray(tool)) return tool - const out: Record = {} - for (const [k, v] of Object.entries(tool)) out[k] = k === 'name' ? v : TOOLS - return out -} diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 418470f2be..523092bf09 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -16,7 +16,7 @@ * scenario stores the readable prompt in `system-prompt.golden.md` and keeps its full * tool schemas in `session.jsonl`, while every other fixture also scrubs tools * to `{{tools}}`. A per-run uniformity guard compares both artifacts against - * every live header and forbids unrepresented header deltas (see the + * every live header and forbids unrepresented changed headers (see the * pinned-header RFC, * docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md). * @@ -106,14 +106,11 @@ export interface Scenario { */ pinsHeader?: boolean /** - * How many `request/header-delta` events this PINNING scenario's fixture - * legitimately carries (default 0). A recorded mid-run header change — a - * config-option switch rewriting a prompt section — is part of the pinned - * surface, with readable prompt text in Markdown; any OTHER count - * still fails, so fixture rot stays caught. Meaningless off the pin (the - * live uniformity guard keeps non-pinning scenarios delta-free). + * How many changed `request/header` snapshots this PINNING scenario's primary + * fixture legitimately carries (default 0). Their full prompt text is kept in + * the readable Markdown pin; any other count fails. Meaningless off the pin. */ - expectedHeaderDeltas?: number + expectedHeaderChanges?: number /** * Which header-composition class this scenario belongs to. Scenarios that * boot the same config compose the same header; each class has exactly one @@ -225,79 +222,46 @@ export function normalizedSystemPrompts(rawLog: string, ctx: NormalizeContext): }) } -/** One normalized system-prompt edit carried by a `request/header-delta`. */ -export interface SystemPromptDeltaSnapshot { - /** How many leading lines remain from the prior prompt. */ - keepStart: number - /** How many trailing lines remain from the prior prompt. */ - keepEnd: number - /** The normalized replacement lines inserted between the retained ranges. */ - insert: string[] -} - -/** - * Extract normalized system-prompt edits from request-header deltas in log - * order. Deltas without a well-formed system edit are omitted; their non-prompt - * structure remains pinned in JSONL. - * - * @param rawLog The session `.jsonl` content to inspect. - * @param ctx The volatile values of the run that produced it. - * @returns The normalized system-prompt edits, in event order. - */ -export function normalizedSystemPromptDeltas(rawLog: string, ctx: NormalizeContext): SystemPromptDeltaSnapshot[] { - return normalizeSessionLog(rawLog, ctx) - .split('\n') - .filter(line => line.trim().length > 0) - .map(line => JSON.parse(line) as { type?: unknown; data?: { system?: unknown } }) - .filter(record => record.type === 'request/header-delta') - .flatMap((record) => { - const system = record.data?.system - if (system === null || typeof system !== 'object') return [] - const { keepStart, keepEnd, insert } = system as { keepStart?: unknown; keepEnd?: unknown; insert?: unknown } - if (typeof keepStart !== 'number' || typeof keepEnd !== 'number' || !Array.isArray(insert)) return [] - if (!insert.every(line => typeof line === 'string')) return [] - return [{ keepStart, keepEnd, insert: insert }] - }) -} - /** * Render a normalized prompt as a repository-friendly Markdown snapshot. * Prompt text is unchanged except that a missing terminal newline is added so * the committed file follows the repository newline contract. * * @param prompt The normalized system prompt. - * @param deltas Normalized prompt edits to append as readable sections. + * @param changes Full normalized prompts from later changed-header snapshots. * @returns Markdown snapshot text ending in a newline. */ export function formatSystemPromptSnapshot( prompt: string, - deltas: readonly SystemPromptDeltaSnapshot[] = [], + changes: readonly string[] = [], ): string { let snapshot = prompt.endsWith('\n') ? prompt : `${prompt}\n` - for (const [index, delta] of deltas.entries()) { - snapshot += `\n\n\n` - const insert = delta.insert.join('\n') - snapshot += insert.endsWith('\n') ? insert : `${insert}\n` + for (const [index, change] of changes.entries()) { + snapshot += `\n\n\n` + snapshot += change.endsWith('\n') ? change : `${change}\n` } return snapshot } -/** Return the initial-prompt portion of a possibly delta-bearing snapshot. */ +/** Return the initial-prompt portion of a possibly multi-header snapshot. */ function initialSystemPromptSnapshot(snapshot: string): string { - const marker = snapshot.indexOf('\n + + +SYS PROMPT NEW PROMPT LINE diff --git a/packages/support/acp-snapshot/tests/normalize.spec.ts b/packages/support/acp-snapshot/tests/normalize.spec.ts index bdde120a76..fa06d55d14 100644 --- a/packages/support/acp-snapshot/tests/normalize.spec.ts +++ b/packages/support/acp-snapshot/tests/normalize.spec.ts @@ -180,89 +180,19 @@ describe('scrubRequestHeaders', () => { expect(scrubRequestHeaders(`${headerLine}\n${odd}\n`)).toContain('"messagePrefix":"weird"') }) - it('scrubs a header-delta prefix replacement to one token per message', () => { - const delta = JSON.stringify({ - type: 'request/header-delta', seq: 8, time: 9, - data: { messagePrefix: [{ role: 'user', content: [{ type: 'text', text: 'leaked opener' }] }] }, - }) - const out = scrubRequestHeaders(`${headerLine}\n${delta}\n`) - expect(out).toContain('"messagePrefix":["{{messagePrefix}}"]') - expect(out).not.toContain('leaked opener') - // The empty-array transition-to-absence stays a structural fact. - const toNone = JSON.stringify({ type: 'request/header-delta', seq: 9, time: 9, data: { messagePrefix: [] } }) - expect(scrubRequestHeaders(`${headerLine}\n${toNone}\n`)).toContain('"messagePrefix":[]') - }) - - it('leaves a delta with no scrubbable payload byte-identical (config-only, or non-array shapes)', () => { - const configOnly = JSON.stringify({ type: 'request/header-delta', seq: 8, time: 9, data: { config: { model: 'm2' } } }) - const oddShapes = JSON.stringify({ type: 'request/header-delta', seq: 9, time: 9, data: { system: { insert: 'not-an-array' }, tools: null } }) + it('leaves malformed headers with no scrubbable payload byte-identical', () => { const headerless = JSON.stringify({ type: 'request/header', seq: 10, time: 9, data: { reason: 'initial' } }) const nullData = JSON.stringify({ type: 'request/header', seq: 11, time: 9, data: null }) - const raw = `${headerLine}\n${configOnly}\n${oddShapes}\n${headerless}\n${nullData}\n` + const raw = `${headerLine}\n${headerless}\n${nullData}\n` expect(scrubRequestHeaders(raw)).toBe(raw) }) - it('scrubs a one-sided tools delta and passes non-object schema entries through', () => { - const addedOnly = JSON.stringify({ - type: 'request/header-delta', seq: 8, time: 9, - data: { tools: { added: [null, 'weird', { name: 'x', description: 'D' }] } }, - }) - const out = scrubRequestHeaders(`${headerLine}\n${addedOnly}\n`) - // Non-object entries survive untouched; the object entry keeps only name. - expect(out).toContain('"added":[null,"weird",{"name":"x","description":"{{tools}}"}]') - const changedOnly = JSON.stringify({ - type: 'request/header-delta', seq: 8, time: 9, - data: { tools: { changed: [{ name: 'y', parameters: {} }] } }, - }) - expect(scrubRequestHeaders(`${headerLine}\n${changedOnly}\n`)) - .toContain('"changed":[{"name":"y","parameters":"{{tools}}"}]') - }) - - it('scrubs a header-delta system payload but keeps its line positions and arity', () => { - const delta = JSON.stringify({ - type: 'request/header-delta', seq: 8, time: 9, - data: { system: { keepStart: 1, keepEnd: 4, insert: ['leaked prompt line', 'second line'] }, config: { model: 'm2' } }, - }) - const out = scrubRequestHeaders(`${headerLine}\n${delta}\n`) - // One token PER inserted line: the edit's position AND extent survive. - expect(out).toContain('"insert":["{{system}}","{{system}}"]') - expect(out).toContain('"keepStart":1') - expect(out).toContain('"keepEnd":4') - expect(out).toContain('"config":{"model":"m2"}') - expect(out).not.toContain('leaked prompt line') - expect(out).not.toContain('{{tools}}') // no tools delta → none invented - }) - - it('scrubs a header-delta tools payload but keeps the added/removed/changed names', () => { - const delta = JSON.stringify({ - type: 'request/header-delta', seq: 8, time: 9, - data: { - tools: { - added: [{ name: 'grep', description: 'Search files.', parameters: { type: 'object' } }], - removed: ['bash_kill'], - changed: [{ name: 'read', description: 'Read v2.', parameters: { type: 'object' } }], - }, - }, - }) - const out = scrubRequestHeaders(`${headerLine}\n${delta}\n`) - // WHICH tools changed is behavior and survives; their bulk does not. - expect(out).toContain('"added":[{"name":"grep","description":"{{tools}}","parameters":"{{tools}}"}]') - expect(out).toContain('"removed":["bash_kill"]') - expect(out).toContain('"changed":[{"name":"read","description":"{{tools}}","parameters":"{{tools}}"}]') - expect(out).not.toContain('Search files') - expect(out).not.toContain('Read v2') - }) - it('passes every other line through byte-for-byte and is idempotent', () => { const other = JSON.stringify({ type: 'assistant/chunk', seq: 4, time: 9, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } } }) - const delta = JSON.stringify({ - type: 'request/header-delta', seq: 8, time: 9, - data: { system: { keepStart: 0, keepEnd: 0, insert: ['x'] }, tools: { added: [{ name: 't', description: 'd', parameters: {} }], removed: [], changed: [] } }, - }) - const raw = `${headerLine}\n${headerEvent({ config: { model: 'm' }, system: 's', tools: [] })}\n${delta}\n${other}\n` + const raw = `${headerLine}\n${headerEvent({ config: { model: 'm' }, system: 's', tools: [] })}\n${other}\n` const once = scrubRequestHeaders(raw) expect(once.split('\n')[0]).toBe(headerLine) - expect(once.split('\n')[3]).toBe(other) + expect(once.split('\n')[2]).toBe(other) expect(scrubRequestHeaders(once)).toBe(once) }) }) @@ -280,12 +210,15 @@ describe('scrubSystemPrompts', () => { reason: 'initial', }, }) - const delta = JSON.stringify({ - type: 'request/header-delta', seq: 2, time: 3, + const changed = JSON.stringify({ + type: 'request/header', seq: 2, time: 3, data: { - system: { keepStart: 1, keepEnd: 2, insert: ['new prompt line'] }, - tools: { changed: [{ name: 'read', description: 'changed schema' }] }, - messagePrefix: [{ role: 'user', content: [{ type: 'text', text: 'changed prefix' }] }], + header: { + system: 'new prompt', + tools: [{ name: 'read', description: 'changed schema' }], + messagePrefix: [{ role: 'user', content: [{ type: 'text', text: 'changed prefix' }] }], + }, + reason: 'change', }, }) const toolsOnly = JSON.stringify({ @@ -293,11 +226,10 @@ describe('scrubSystemPrompts', () => { data: { header: { tools: [{ name: 'read', description: 'schema only' }] }, reason: 'resume' }, }) - const out = scrubSystemPrompts(`${header}\n${delta}\n${toolsOnly}\n`) + const out = scrubSystemPrompts(`${header}\n${changed}\n${toolsOnly}\n`) expect(out).toContain('"system":"{{system}}"') - expect(out).toContain('"insert":["{{system}}"]') expect(out).not.toContain('full prompt') - expect(out).not.toContain('new prompt line') + expect(out).not.toContain('new prompt') expect(out).toContain('full schema') expect(out).toContain('full prefix') expect(out).toContain('changed schema') diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index d6e3e2b912..4ca9c8573d 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -9,9 +9,8 @@ import { childFixturePaths, fixtureContext, formatSystemPromptSnapshot, - headerDeltaCount, + headerChangeCount, normalizedHeaders, - normalizedSystemPromptDeltas, normalizedSystemPrompts, refreshFixtureReplacements, stabilizeRefreshLog, @@ -51,7 +50,7 @@ const RECORD_SRC = fileURLToPath(new URL('./fixtures/record-suite', import.meta. // is what this suite can exercise; the real overlay boot is the acp-agent // example's code-mode scenarios). const REPLAY_SCENARIOS: Scenario[] = [ - { name: 'pin-turn', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderDeltas: 1, headerClass: 'main' }, + { name: 'pin-turn', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderChanges: 1, headerClass: 'main' }, { name: 'plain-turn', hasModelTurn: true, recorded: true, childSessions: 1, headerClass: 'main', configPath: AGENT.configPath }, { name: 'no-model', hasModelTurn: false, recorded: false, headerClass: 'main' }, { name: 'blocked-log', hasModelTurn: false, comparesLog: true, recorded: false, headerClass: 'main' }, @@ -132,7 +131,9 @@ describe('defineAcpSnapshotSuite: refresh write-back', () => { expect(readFileSync(join(refreshDir, 'pin-turn', 'system-prompt.golden.md'), 'utf8')).toBe([ 'SYS PROMPT', '', - '', + '', + '', + 'SYS PROMPT', '', 'NEW PROMPT LINE', '', @@ -247,46 +248,30 @@ describe('normalizedSystemPrompts', () => { }) }) -describe('normalizedSystemPromptDeltas', () => { - it('extracts and normalizes well-formed system edits', () => { - const log = [ - '{"type":"request/header-delta","data":{"system":{"keepStart":1,"keepEnd":0,"insert":["work in /w"]}}}', - '{"type":"request/header-delta","data":{"tools":{"replace":[]}}}', - '{"type":"request/header-delta","data":{"system":{"keepStart":"1","keepEnd":0,"insert":[]}}}', - '{"type":"request/header-delta","data":{"system":{"keepStart":1,"keepEnd":0,"insert":[null]}}}', - '', - ].join('\n') - expect(normalizedSystemPromptDeltas(log, { sessionIds: [], cwd: '/w' })).toEqual([ - { keepStart: 1, keepEnd: 0, insert: ['work in {{cwd}}'] }, - ]) - }) -}) - describe('formatSystemPromptSnapshot', () => { it('adds a missing terminal newline without changing an existing one', () => { expect(formatSystemPromptSnapshot('prompt')).toBe('prompt\n') expect(formatSystemPromptSnapshot('prompt\n')).toBe('prompt\n') }) - it('renders readable system-prompt delta sections', () => { - expect(formatSystemPromptSnapshot('prompt', [ - { keepStart: 1, keepEnd: 0, insert: ['new', 'lines'] }, - ])).toBe('prompt\n\n\n\nnew\nlines\n') + it('renders readable changed-prompt sections', () => { + expect(formatSystemPromptSnapshot('prompt', ['new\nlines'])) + .toBe('prompt\n\n\n\nnew\nlines\n') }) - it('does not double the newline of a delta insert with a trailing blank line', () => { - expect(formatSystemPromptSnapshot('prompt\n', [ - { keepStart: 2, keepEnd: 1, insert: ['tail', ''] }, - ])).toBe('prompt\n\n\n\ntail\n') + it('does not double the newline of a changed prompt', () => { + expect(formatSystemPromptSnapshot('prompt\n', ['changed\n'])) + .toBe('prompt\n\n\n\nchanged\n') }) }) -describe('headerDeltaCount', () => { - it('counts request/header-delta events, ignoring blanks and other lines', () => { - const delta = JSON.stringify({ type: 'request/header-delta', seq: 2, time: 9, data: {} }) - const other = JSON.stringify({ type: 'request/header', seq: 0, time: 9, data: {} }) - expect(headerDeltaCount(`${other}\n\n${delta}\n${delta}\n`)).toBe(2) - expect(headerDeltaCount(`${other}\n`)).toBe(0) +describe('headerChangeCount', () => { + it('counts changed request headers, ignoring anchors, blanks, and other lines', () => { + const change = JSON.stringify({ type: 'request/header', seq: 2, time: 9, data: { reason: 'change' } }) + const anchor = JSON.stringify({ type: 'request/header', seq: 0, time: 9, data: { reason: 'initial' } }) + const other = JSON.stringify({ type: 'turn/start', seq: 1, time: 9, data: {} }) + expect(headerChangeCount(`${anchor}\n${other}\n\n${change}\n${change}\n`)).toBe(2) + expect(headerChangeCount(`${anchor}\n`)).toBe(0) }) }) diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index c4b909773d..849a741274 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -39,7 +39,7 @@ Agent status (per agent): Model requests (on `llm/stream`): -- **a loop-built request is exactly what the log reconstructs** — a frozen request with a live `sessionId` (the loop-built marker; hand-built one-shots like compaction's summarize are unfrozen and skipped) must carry frozen `messages` deep-equal to the derivation over the log prefix strictly before the in-flight step's `step/start` (rebuilt through a FRESH `Session`, so the live cache cannot vouch for itself — and boundary-correct: content logged after `step/start` legitimately belongs to the next request), and every non-content field must equal the fold of the log's `request/header*` events (see [the reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). Registered with `prepend: true` so a short-circuiting `llm/stream` listener (the replay adapter) cannot silence it; prepend orders it against append-registered listeners only — correctness rests on the seq-bounded rebuild, never listener timing. +- **a loop-built request is exactly what the log reconstructs** — a frozen request with a live `sessionId` (the loop-built marker; hand-built one-shots like compaction's summarize are unfrozen and skipped) must carry frozen `messages` deep-equal to the derivation over the log prefix strictly before the in-flight step's `step/start` (rebuilt through a FRESH `Session`, so the live cache cannot vouch for itself — and boundary-correct: content logged after `step/start` legitimately belongs to the next request), and every non-content field must equal the latest logged `request/header` (see [the reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). Registered with `prepend: true` so a short-circuiting `llm/stream` listener (the replay adapter) cannot silence it; prepend orders it against append-registered listeners only — correctness rests on the seq-bounded rebuild, never listener timing. On any violation it throws `InvariantError` (`code: 'INVARIANT'`). diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index f5ef6d2b4a..8dcf901d32 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -60,8 +60,8 @@ interface SessionTrace { /** Every seq seen so far — validates `sourceEventSeqs` references. */ knownSeqs: Set /** - * The seqs currently on the surface linked list, in linked-list order - * (head to tail). A replace reorders this relative to seq order (the new + * The seqs currently on the surface, in derived-message order. A replace + * reorders this relative to seq order (the new * node takes the replaced range's position), so range validation is * positional, not by seq comparison. */ @@ -154,7 +154,7 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr } } } - // Fold this event into the tracked surface linked list, validating the + // Fold this event into the tracked surface order, validating the // replace contract as we go. `append` adds a tail node; `replace` shadows a // positional range — every shadowed node must appear in sourceEventSeqs. if (se.surfaceOp !== undefined) { @@ -497,7 +497,7 @@ export function apply(ctx: Context): void { // the boundary (an `agent/request`-window inject) is legitimately absent // from this request, and a current-surface comparison would false-fire. // - header: every non-content field must equal the fold of the log's - // `request/header*` events — the loop logs the header event BEFORE + // `request/header` events — the loop logs the header event BEFORE // dispatch, so the fold already covers this request. // // Registered with `prepend: true` so a short-circuiting llm/stream listener diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index 5c26b1f4d7..365a59fed7 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -635,7 +635,7 @@ describe('surface invariants', () => { session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3 // Replace node 2 (position 0) with seq 4 — surface is now [4, 3], so seq 4 - // precedes seq 3 in linked-list order even though 4 > 3 numerically. + // precedes seq 3 in surface order even though 4 > 3 numerically. session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] }) // seq 4 // A replace with start=3, end=4 passes the seq check (3 <= 4) but is // reversed positionally (3 is at pos 1, 4 is at pos 0). @@ -745,7 +745,7 @@ describe('request-reconstruction cross-check (llm/stream)', () => { it('expects the folded header\'s session prefix ahead of the derivation (prefix + derived)', async () => { const { ctx, session, boundary } = await requestSetup() const prefix = { role: 'user' as const, content: [{ type: 'text' as const, text: 'catalog' }] } - session.append('request/header-delta', { messagePrefix: [prefix] }) + session.append('request/header', { header: { config: { model: 'm' }, messagePrefix: [prefix] }, reason: 'change' }) // The prefixed request matches the fold… const prefixed = Object.freeze({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id }) expect(() => { dispatch(ctx, prefixed) }).not.toThrow() diff --git a/packages/ui/user-approval/README.md b/packages/ui/user-approval/README.md index a50bffad0b..32d7f31ab5 100644 --- a/packages/ui/user-approval/README.md +++ b/packages/ui/user-approval/README.md @@ -6,7 +6,7 @@ The contract in one line: `ctx.approval.request(req)` puts exactly one question The service is the mechanism, answerers are the policy. Answerers are `approval/request` waterfall listeners occupying a single decision slot: answer for an agent you own by returning an outcome without calling `next()`, or delegate an agent you don't recognize by calling `next()` — the chain's built-in default is `unavailable`, so a deployment with no answerer (headless, CI) fails closed with zero configuration. Dispatch is keyed by `req.agent`: a listener registered through `agent.ctx` receives only that agent's questions, while a plain-context listener receives every agent's. Registration order across sibling plugins is not load-order deterministic; compose one terminal answerer per deployment and use `prepend` listeners only for decide-or-delegate gates. -The seam also owns the per-session POLICY tier ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): `ApprovalPolicy` is `'ask'` (delegate to the answerers) or `'never'` (deterministically reject without prompting anyone; the strict CI/unattended stance), with `effective = fold(the session's 'approval/policy' events, last one wins) ?? Config.policy` — the session log is the store, written only through `setApprovalPolicy(session, policy)`, which rejects any value outside that closed vocabulary before appending. The service decides `'never'` inside `request()` itself, before dispatching the waterfall (`'never'` → `'rejected'` with the audit pair still landing; no listener registration, including a later `prepend`, can precede it), states `'never'` — and only `'never'` in prose — in a per-agent prompt section, records either value with a source-owned header marker, and narrates a policy switch to the model in at most one coalesced `agent/pre-step` notice. The restart fallback reads the marker rather than deployment-controlled persona prose; attribution is positional (an override event after the last `request/header*` reads `changed by the user`, otherwise `changed by the operator/config`). +The seam also owns the per-session POLICY tier ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): `ApprovalPolicy` is `'ask'` (delegate to the answerers) or `'never'` (deterministically reject without prompting anyone; the strict CI/unattended stance), with `effective = fold(the session's 'approval/policy' events, last one wins) ?? Config.policy` — the session log is the store, written only through `setApprovalPolicy(session, policy)`, which rejects any value outside that closed vocabulary before appending. The service decides `'never'` inside `request()` itself, before dispatching the waterfall (`'never'` → `'rejected'` with the audit pair still landing; no listener registration, including a later `prepend`, can precede it), states `'never'` — and only `'never'` in prose — in a per-agent prompt section, records either value with a source-owned header marker, and narrates a policy switch to the model in at most one coalesced `agent/pre-step` notice. The restart fallback reads the marker rather than deployment-controlled persona prose; attribution is positional (an override event after the last `request/header` reads `changed by the user`, otherwise `changed by the operator/config`). One seam serves both ask paths of [the sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md): the `tools/pre-execute` `ask` decision (routed by [`@deepseek-ai/dsh-tools`](../../core/tools/) when this service is mounted; degrading to deny when it is not), and the sandbox post-denial escalated retry (the bash tool's `sandbox_permissions` gate in [`@deepseek-ai/dsh-tool-bash`](../../bash/tool-bash/) — [the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). The full design: [the approval-seam RFC](../../../docs/rfc/implemented/feature/2026-07-06-approval-seam.md). diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 6b3ef962bb..45d7fa91aa 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -102,7 +102,7 @@ declare module '@deepseek-ai/dsh-session' { * from the prompt section and the narrator's notices). The LAST such * event is the session's override ({@link effectiveApprovalPolicy}); * who asked for it is derivable from position (an event after the log's - * last `request/header*` was a runtime switch by the user). + * last `request/header` was a runtime switch by the user). */ 'approval/policy': { policy: ApprovalPolicy } } @@ -328,7 +328,7 @@ export class ApprovalService extends Service { // narrated no later than the next step. What each session was last told // is in-memory with a log-derived fallback (the folded header's system // text), so restarts lose nothing. Attribution is positional: an - // override event after the log's last `request/header*` was a runtime + // override event after the log's last `request/header` was a runtime // switch by the user; otherwise the configured default moved under the // session (operator/config). const narrated = new WeakMap() @@ -341,7 +341,7 @@ export class ApprovalService extends Service { const event = events[index] as (typeof events)[number] if (overrideIndex < 0 && event.type === 'approval/policy') { overrideIndex = index - } else if (headerIndex < 0 && (event.type === 'request/header' || event.type === 'request/header-delta')) { + } else if (headerIndex < 0 && event.type === 'request/header') { headerIndex = index } } diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 9e7dfe44bd..02e0199fbb 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -165,7 +165,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolCordis) }, note: - 'Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes.', + 'Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes.', }, { pkg: '@deepseek-ai/dsh-tool-fs', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index a219ea1b1d..874f0754a6 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -39,7 +39,6 @@ { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceEventType", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceOp", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceIntent", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceNode", "source": "packages/core/session/src/surface.ts" }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" },