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 1/9] 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" }, From b35d66b86dcbb814f0183174c4d42c303a963178 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:59:25 +0800 Subject: [PATCH 2/9] fix: reject legacy session events on every path --- .../session-persistence/src/coordinator.ts | 7 +- .../tests/persistence.spec.ts | 43 ++++++++++++ .../advanced/result.json | 66 +++++++++++-------- .../advanced/session.jsonl | 4 +- 4 files changed, 91 insertions(+), 29 deletions(-) diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 4c42751816..b80e4c8d23 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -251,11 +251,15 @@ 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)) } private async appendCore(id: SessionId, events: readonly SessionEvent[]): Promise { + // Every append route converges here: the public service, live write-behind + // drains, and HMR seed/suffix adoption. Keep vocabulary rejection at that + // shared boundary so a stale JavaScript plugin cannot persist an event that + // this same backend will refuse to load. + assertSupportedEvents(events, id) if (events.length === 0) return let state = this.states.get(id) if (state === undefined) state = await this.adopt(id) // calls loadCore, not load @@ -528,6 +532,7 @@ export class PersistenceCoordinator { private async adoptLivePrefix(session: Session, seed: readonly SessionEvent[], stored: StoredPrefix): Promise { const { meta, events, tornMarker } = stored this.assertVersion(meta) + assertSupportedEvents(events, session.header.id) if (!seedCoversPrefix(seed, events)) { throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`) } diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index e28bd32851..a20d850d83 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -12,6 +12,16 @@ import { runCoordinatorContract, type CoordinatorFixture } from './coordinator-c /** The durable store shape: materialized sessions only (no lazy entries). */ type MemoryStore = Map +/** An obsolete event fixture that emulates an untyped pre-change producer. */ +function legacyHeaderDelta(seq = 0): SessionEvent { + return { + type: 'request/header-delta', + seq, + time: 1, + data: { config: { model: 'legacy' } }, + } as unknown as SessionEvent +} + /** Optional plugin config: an EXTERNAL store shared across backend instances. */ interface MemoryConfig { store?: MemoryStore } @@ -164,4 +174,37 @@ describe('SessionPersistence service registration', () => { .rejects.toThrow('session metadata must be losslessly JSON-serializable') await fiber.dispose() }) + + it('rejects a legacy header delta buffered by a pre-change live producer', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(MemoryPersistence) + const session = ctx.sessions.create(SessionId('legacy-live'), { meta: { cwd: '/legacy' } }) + // Model the runtime shape available to JavaScript or a hot-loaded plugin + // compiled against the obsolete event vocabulary. + const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent + appendLegacy('request/header-delta', { config: { model: 'legacy' } }) + + await expect(ctx.sessions.flush(session)) + .rejects.toThrow(/unsupported legacy request\/header-delta event at seq 0/) + await fiber.dispose() + }) + + it('rejects a legacy stored prefix during live HMR adoption', async () => { + const id = SessionId('legacy-hmr') + const m = meta(id, '/legacy') + const legacy = legacyHeaderDelta() + const store: MemoryStore = new Map([[id, { meta: m, events: [legacy] }]]) + const ctx = new Context() + await ctx.plugin(SessionStore) + // A current live session cannot carry the obsolete event in its seed, but + // HMR still has to identify the persisted prefix as unsupported rather than + // treating it as an ordinary live-prefix collision. + const session = ctx.sessions.create(id, { meta: { cwd: '/legacy' } }) + const fiber = await ctx.plugin(MemoryPersistence, { store }) + + await expect(ctx.sessions.flush(session)) + .rejects.toThrow(/unsupported legacy request\/header-delta event at seq 0/) + await Promise.allSettled([fiber.dispose()]) + }) }) diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index 2ecfc76a2e..75aa113cec 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -253,7 +253,7 @@ "workflow" ] }, - "reason": "fallback" + "reason": "change" } }, { @@ -916,22 +916,29 @@ } }, { - "type": "request/header-delta", + "type": "request/header", "seq": 56, "time": 0, "data": { - "system": { - "keepStart": 62, - "keepEnd": 34, - "insert": [] + "header": { + "config": { + "model": "smoke-model" + }, + "system": "{{system}}", + "tools": [ + "bash", + "bash_kill", + "bash_output", + "cordis_inspect", + "cordis_mount", + "cordis_unmount", + "run_code", + "skill", + "subagent", + "workflow" + ] }, - "tools": { - "added": [], - "removed": [ - "snapshot_double" - ], - "changed": [] - } + "reason": "change" } }, { @@ -1397,7 +1404,7 @@ "workflow" ] }, - "reason": "fallback" + "reason": "change" } } } @@ -2360,22 +2367,29 @@ "payload": { "sessionId": "{{parent}}", "event": { - "type": "request/header-delta", + "type": "request/header", "seq": 56, "time": 0, "data": { - "system": { - "keepStart": 62, - "keepEnd": 34, - "insert": [] + "header": { + "config": { + "model": "smoke-model" + }, + "system": "{{system}}", + "tools": [ + "bash", + "bash_kill", + "bash_output", + "cordis_inspect", + "cordis_mount", + "cordis_unmount", + "run_code", + "skill", + "subagent", + "workflow" + ] }, - "tools": { - "added": [], - "removed": [ - "snapshot_double" - ], - "changed": [] - } + "reason": "change" } } } diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl index bb0ee1d4d0..6f984e294a 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl @@ -13,7 +13,7 @@ {"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"\", state: active)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} {"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}} -{"type":"request/header","seq":14,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"fallback"}} +{"type":"request/header","seq":14,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"change"}} {"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}}} {"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}}}} @@ -55,7 +55,7 @@ {"type":"tool/result","seq":53,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"\")"}],"isError":false},"sourceEventSeqs":[52],"surfaceOp":"append"} {"type":"step/end","seq":54,"time":0,"data":{"turn":1,"step":5}} {"type":"step/start","seq":55,"time":0,"data":{"turn":1,"step":6}} -{"type":"request/header-delta","seq":56,"time":0,"data":{"system":{"keepStart":62,"keepEnd":34,"insert":[]},"tools":{"added":[],"removed":["snapshot_double"],"changed":[]}}} +{"type":"request/header","seq":56,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","workflow"]},"reason":"change"}} {"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}} {"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}} From 49e45ff184dbb1c5a293d83359afd0b8ce333f95 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:32:44 +0800 Subject: [PATCH 3/9] fix: reject legacy fallback headers --- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/session.md | 2 +- ...-12-simplify-session-log-representation.md | 2 +- packages/core/session/README.md | 2 +- packages/core/session/src/index.ts | 11 ++++++ .../core/session/tests/request-header.spec.ts | 14 ++++++++ .../tests/jsonl.spec.ts | 19 ++++++++++ .../tests/sqlite.spec.ts | 19 ++++++++++ .../session-persistence/src/coordinator.ts | 5 +++ .../tests/persistence.spec.ts | 36 +++++++++++++++++++ 10 files changed, 108 insertions(+), 4 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 05ecf9df56..9501a86c6b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -245,7 +245,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:593`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:604`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index e4a508e4dd..b7fa0e126f 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -109,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`); 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. +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` event or its full-snapshot `fallback` reason are rejected at seed, append, and persistence-load boundaries rather than replayed incompletely. ## `SessionEvent` — one log entry 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 index 4f4bf8d569..a1dfb5eafb 100644 --- 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 @@ -18,7 +18,7 @@ This proposal deliberately retains append and replacement `sourceEventSeqs`, cra 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. +`SESSION_FORMAT_VERSION` remains pinned at `0`, so seed, append, and persistence-load validation explicitly reject old v0 `request/header-delta` events and full snapshots carrying the removed `fallback` reason. 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 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index ae168ad3eb..9f196c6d3d 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -53,7 +53,7 @@ Durable values need one accepted representation, not a check followed by a secon ### Request-header reconstruction (`request-header.ts`) -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. +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` event or its full-snapshot `fallback` reason 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 f4a87e5a2f..11451811c3 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -212,6 +212,15 @@ function assertSessionEventEnvelope(value: Record, index: numbe } } +/** Reject request-header vocabulary removed with the legacy delta codec. */ +function assertSupportedRequestHeader(type: string, data: unknown, location: string): void { + if (type === 'request/header' + && data !== null && typeof data === 'object' && !Array.isArray(data) + && (data as Record)['reason'] === 'fallback') { + throw new Error(`${location} uses unsupported legacy request/header reason "fallback"`) + } +} + type SessionCallback = (...args: unknown[]) => unknown /** Resolve one listener snapshot, including Cordis's internal dispatch checks. */ @@ -306,6 +315,7 @@ export class Session { throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`) } assertSessionEventEnvelope(snapshot, index) + assertSupportedRequestHeader(snapshot.type, snapshot.data, `seed event at index ${index}`) if (snapshot.seq !== index) { throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`) } @@ -391,6 +401,7 @@ export class Session { if (dataSnapshot === undefined) { throw new Error(`session event "${type}" carries non-JSON-serializable data`) } + assertSupportedRequestHeader(type, dataSnapshot, `session event "${type}"`) const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata) if (surfaceMetadataSnapshot === undefined) { throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`) diff --git a/packages/core/session/tests/request-header.spec.ts b/packages/core/session/tests/request-header.spec.ts index fa2acdda28..8bc2a4bf6c 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/packages/core/session/tests/request-header.spec.ts @@ -68,4 +68,18 @@ describe('legacy request-header format', () => { }] as unknown as SessionEvent[] expect(() => new Session(SessionId('legacy'), legacy)).toThrow(/unsupported legacy request\/header-delta/) }) + + it('rejects the removed fallback reason in seeds and untyped appends', () => { + const legacy = [{ + type: 'request/header', seq: 0, time: 1, data: { header: { config: CONFIG }, reason: 'fallback' }, + }] as unknown as SessionEvent[] + expect(() => new Session(SessionId('legacy-seed-reason'), legacy)) + .toThrow('unsupported legacy request/header reason "fallback"') + + const session = new Session(SessionId('legacy-append-reason')) + const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent + expect(() => appendLegacy('request/header', { header: { config: CONFIG }, reason: 'fallback' })) + .toThrow('unsupported legacy request/header reason "fallback"') + expect(session.events).toHaveLength(0) + }) }) 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 b9bc0f8d9a..7c5febb88f 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -162,6 +162,25 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported legacy request\/header-delta event at seq 1/) }) + it('rejects a stored v0 full header carrying the legacy fallback reason', async () => { + const m = meta('legacy-header-fallback', '/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: 'request/header', + seq: 0, + time: 1, + data: { header: { config: { model: 'legacy' } }, reason: 'fallback' }, + }), + '', + ].join('\n')) + + await expect(ctx.sessionPersistence.load(m.id)) + .rejects.toThrow(/unsupported legacy request\/header reason "fallback" at seq 0/) + }) + 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 12857d2cd8..15d2a44492 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -161,6 +161,25 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { await mounted.dispose() }) + it('rejects a stored v0 full header carrying the legacy fallback reason', async () => { + const path = await freshDbPath() + const m = meta('legacy-header-fallback', '/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) + db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)') + .run(m.id, 0, 'request/header', 1, JSON.stringify({ + header: { config: { model: 'legacy' } }, + reason: 'fallback', + })) + db.close() + + const mounted = await backend(path) + await expect(mounted.ctx.sessionPersistence.load(m.id)) + .rejects.toThrow(/unsupported legacy request\/header reason "fallback" at seq 0/) + 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 b80e4c8d23..e35a6e2e41 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -158,6 +158,11 @@ function assertSupportedEvents(events: readonly SessionEvent[], id: SessionId): if (legacy !== undefined) { throw new Error(`session "${id}" contains unsupported legacy request/header-delta event at seq ${legacy.seq}`) } + const fallback = events.find(event => event.type === 'request/header' + && (event.data as { reason?: string }).reason === 'fallback') + if (fallback !== undefined) { + throw new Error(`session "${id}" contains unsupported legacy request/header reason "fallback" at seq ${fallback.seq}`) + } } /** diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index a20d850d83..5d0ba2c607 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -22,6 +22,16 @@ function legacyHeaderDelta(seq = 0): SessionEvent { } as unknown as SessionEvent } +/** An obsolete full-header reason fixture from the removed delta codec. */ +function legacyFallbackHeader(seq = 0): SessionEvent { + return { + type: 'request/header', + seq, + time: 1, + data: { header: { config: { model: 'legacy' } }, reason: 'fallback' }, + } as unknown as SessionEvent +} + /** Optional plugin config: an EXTERNAL store shared across backend instances. */ interface MemoryConfig { store?: MemoryStore } @@ -190,6 +200,19 @@ describe('SessionPersistence service registration', () => { await fiber.dispose() }) + it('rejects a legacy fallback header buffered by a pre-change live producer', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(MemoryPersistence) + const session = ctx.sessions.create(SessionId('legacy-fallback-live'), { meta: { cwd: '/legacy' } }) + const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent + + expect(() => appendLegacy('request/header', legacyFallbackHeader().data)) + .toThrow('unsupported legacy request/header reason "fallback"') + expect(session.events).toHaveLength(0) + await fiber.dispose() + }) + it('rejects a legacy stored prefix during live HMR adoption', async () => { const id = SessionId('legacy-hmr') const m = meta(id, '/legacy') @@ -207,4 +230,17 @@ describe('SessionPersistence service registration', () => { .rejects.toThrow(/unsupported legacy request\/header-delta event at seq 0/) await Promise.allSettled([fiber.dispose()]) }) + + it('rejects a stored legacy fallback header during load', async () => { + const id = SessionId('legacy-fallback-load') + const m = meta(id, '/legacy') + const store: MemoryStore = new Map([[id, { meta: m, events: [legacyFallbackHeader()] }]]) + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(MemoryPersistence, { store }) + + await expect(ctx.sessionPersistence.load(id)) + .rejects.toThrow('unsupported legacy request/header reason "fallback" at seq 0') + await fiber.dispose() + }) }) From 539850578aabd477c734a6cbf142d93aeb035764 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:04:29 +0800 Subject: [PATCH 4/9] fix: reject legacy header deltas on append --- docs/cordis-catalog/services.md | 2 +- .../2026-07-12-simplify-session-log-representation.md | 2 +- packages/core/session/src/index.ts | 3 +++ packages/core/session/tests/request-header.spec.ts | 8 +++++++- .../session-persistence/tests/persistence.spec.ts | 9 ++++----- 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 9501a86c6b..0bd8a8c4a0 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -245,7 +245,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:604`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:607`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` 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 index a1dfb5eafb..dc51986e79 100644 --- 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 @@ -10,7 +10,7 @@ The session log maintains two representations that cost more machinery than thei 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. +The implementation retains append and replacement `sourceEventSeqs`, crash-repair provenance, and all `SessionStartSource` variants because those fields have an audit/interception role that zero current readers does not overturn. ## Decision diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 11451811c3..2850286cde 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -214,6 +214,9 @@ function assertSessionEventEnvelope(value: Record, index: numbe /** Reject request-header vocabulary removed with the legacy delta codec. */ function assertSupportedRequestHeader(type: string, data: unknown, location: string): void { + if (type === 'request/header-delta') { + throw new Error(`${location} uses unsupported legacy request/header-delta format`) + } if (type === 'request/header' && data !== null && typeof data === 'object' && !Array.isArray(data) && (data as Record)['reason'] === 'fallback') { diff --git a/packages/core/session/tests/request-header.spec.ts b/packages/core/session/tests/request-header.spec.ts index 8bc2a4bf6c..da84ea239c 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/packages/core/session/tests/request-header.spec.ts @@ -62,11 +62,17 @@ describe('foldRequestHeader', () => { }) describe('legacy request-header format', () => { - it('rejects a v0 seed containing request/header-delta', () => { + it('rejects request/header-delta in seeds and untyped appends', () => { 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/) + + const session = new Session(SessionId('legacy-append-delta')) + const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent + expect(() => appendLegacy('request/header-delta', { config: CONFIG })) + .toThrow(/unsupported legacy request\/header-delta/) + expect(session.events).toHaveLength(0) }) it('rejects the removed fallback reason in seeds and untyped appends', () => { diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 5d0ba2c607..f8ea43fd05 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -185,7 +185,7 @@ describe('SessionPersistence service registration', () => { await fiber.dispose() }) - it('rejects a legacy header delta buffered by a pre-change live producer', async () => { + it('rejects a legacy header delta from a pre-change live producer', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const fiber = await ctx.plugin(MemoryPersistence) @@ -193,10 +193,9 @@ describe('SessionPersistence service registration', () => { // Model the runtime shape available to JavaScript or a hot-loaded plugin // compiled against the obsolete event vocabulary. const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent - appendLegacy('request/header-delta', { config: { model: 'legacy' } }) - - await expect(ctx.sessions.flush(session)) - .rejects.toThrow(/unsupported legacy request\/header-delta event at seq 0/) + expect(() => appendLegacy('request/header-delta', { config: { model: 'legacy' } })) + .toThrow(/unsupported legacy request\/header-delta format/) + expect(session.events).toHaveLength(0) await fiber.dispose() }) From b12b5f8a9536e99fad72024451b9c1df84300c8e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:03:28 +0800 Subject: [PATCH 5/9] test: refresh permission-switching header snapshot --- .../tests/snapshots/permission-switching/session.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl index 0d8e018ae5..07cd6233b3 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl +++ b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl @@ -107,7 +107,7 @@ {"type":"user/message","seq":105,"time":1783962244624,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: cat out.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"context/message","seq":106,"time":1783962244624,"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":107,"time":1783962244624,"data":{"turn":2,"step":1}} -{"type":"request/header","seq":108,"time":1784000791271,"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"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"change"}} +{"type":"request/header","seq":108,"time":1784000791271,"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"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"change"}} {"type":"assistant/chunk","seq":109,"time":1783860671025,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":110,"time":1783860671026,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":111,"time":1783860671026,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} From 0998db87d869c423fceba3d3f89d18504babf773 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:18:46 +0800 Subject: [PATCH 6/9] test: align time context with full headers --- packages/context/time-context/tests/time-context.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 562b002b68..1bb40fa304 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -318,7 +318,7 @@ describe('configuration and lifecycle', () => { }) describe('real agent-loop request logging', () => { - it('refreshes a long turn in the system prompt and records the header delta without context history', async () => { + it('refreshes a long turn in the system prompt and records full headers without context history', async () => { const adapter = new ScriptedAdapter([toolCallResponse(), textResponse('done'), textResponse('next turn')]) const ctx = await loopHarness(adapter, { refreshIntervalMs: 60_000 }) ctx.tools.register(defineTool({ @@ -338,7 +338,7 @@ describe('real agent-loop request logging', () => { expect(adapter.requests[0]!.system).toContain('2026-07-14T00:00:00+00:00[UTC]') expect(adapter.requests[1]!.system).toContain('2026-07-14T00:01:01+00:00[UTC]') expect(agent.session.events.some(event => event.type === 'context/message')).toBe(false) - expect(agent.session.events.filter(event => event.type === 'request/header-delta')).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'request/header')).toHaveLength(2) expect(foldRequestHeader(agent.session.events)?.system).toBe(adapter.requests[1]!.system) vi.setSystemTime(BASE + 361_000) From 649865043db86045ece6d586f2b0cdb9c2c13268 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:59:20 +0800 Subject: [PATCH 7/9] docs: preserve prose cleanup in session simplification --- docs/cordis-catalog/events.md | 54 +- docs/core-data-structures/core.md | 19 +- docs/event-producer-consumer.md | 26 +- docs/persistence-catalog.md | 36 +- .../2026-07-05-reconstructable-requests.md | 2 +- .../2026-06-18-compaction-capability-seam.md | 14 +- .../feature/2026-07-06-explicit-tool-order.md | 6 +- .../implemented/feature/2026-07-06-sandbox.md | 45 +- packages/compact/compact-basic/src/index.ts | 201 +------ .../compact-basic/tests/compact-basic.spec.ts | 86 +-- packages/core/agent-loop/src/loop.ts | 481 +++-------------- packages/core/agent-loop/src/request-log.ts | 24 +- packages/core/agent/src/types.ts | 494 +++--------------- packages/core/session/README.md | 2 +- packages/core/session/src/tool-pairing.ts | 71 +-- packages/core/session/src/types.ts | 168 ++---- .../core/session/tests/tool-pairing.spec.ts | 43 +- packages/llm/llm/src/call-config.ts | 27 +- packages/support/acp-snapshot/src/suite.ts | 118 +---- 19 files changed, 377 insertions(+), 1540 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 4e77ab89e2..aa87ea66d7 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -15,7 +15,7 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n ### `agent/created` — emit -An agent's fully composed scoped world was published in the AgentRegistry. Its session is already live in the session store. Setup is composition-only by contract; the subsequent `agent/session-start` boundary is the first supported place to inject or queue startup work. A synchronous listener throw vetoes publication and rollback emits the matching disposal edges; returned-promise rejection is observed and logged but cannot retroactively veto this synchronous boundary. A synchronous listener that requests the advanced registry detach does not remove the entry immediately: removal and the paired `agent/disposed` edge wait until the creation dispatch unwinds, so no later creation listener observes a disposal that preceded its own creation callback. +A fully configured agent and live session were published. Setup is composition-only; `agent/session-start` is the first startup-driving seam. Synchronous listener failure vetoes publication, while returned-promise rejection is reported. Detach requested during dispatch waits until every creation listener has observed the stable entry. ```ts cordis-catalog 'agent/created'(this: Scoped, agent: Agent): void @@ -23,11 +23,11 @@ An agent's fully composed scoped world was published in the AgentRegistry. Its s Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:316`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:139`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit -An agent was removed from the registry. The concrete AgentLoop lifecycle emits this only after its driver and any in-flight turn reach quiescence; a custom agent registered through the public registry owns its own driver contract, which the registry cannot infer. Ordered teardown may still be detaching the session and unwinding scoped registrations when this runs. +An agent left the registry; AgentLoop emits this after driver quiescence but before session detachment and scoped-registration unwind. Custom registry users own their driver-ordering contract. ```ts cordis-catalog 'agent/disposed'(this: Scoped, agent: Agent): void @@ -35,7 +35,7 @@ An agent was removed from the registry. The concrete AgentLoop lifecycle emits t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:331`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:148`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -47,13 +47,11 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:605`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:283`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial -Awaited pre-step surface-mutation checkpoint, fired once per step AFTER `turn/start` (and after the prior step closed) but BEFORE this step's `step/start` — so anything a listener appends lands OUTSIDE the step, between `turn/start`/`step/end` and the upcoming `step/start`. `step` is the number of the step about to start. The loop awaits `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then opens the step and derives the request history ONCE from whatever the surface now holds. This is where compaction belongs: it mutates the session surface in place (shadowing an older range with a summary node) with its log-only `compact/*` records cleanly outside any step, and the single subsequent derive reflects the mutation — so there is no double-derive and no listener can see (or be expected to act on) an assembled `messages` array that does not exist yet. - -Serial (awaited in registration order), not a waterfall: a listener mutates the surface as a side effect; there is nothing to transform, but the loop must wait for the mutation to complete before opening the step and deriving. Cordis `serial` bails early if a listener returns a bail value; this event is typed and documented as `void`, so listeners must not return a semantic veto value. `fullSystemPrompt` is the assembled prompt a listener needs to measure pressure (the system prompt counts toward the budget), and `sessionPrefix` is the instance's composed agent/session-prefix product for the same reason — every request carries it in front of the derived history, and it is composed BEFORE this seam fires precisely so a pressure gate counts the prefix the request will actually send (never a stale logged one). `signal` cancels any in-flight work a listener starts (e.g. a summarization model call). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered through `agent.ctx` fires only for that agent's dispatches; a listener on a plain plugin context fires for every agent. The dispatch `this` is the scope carrier (`Scoped`), built by the emitting side via `scopeTarget`/`agentEvents`. +Awaited serial checkpoint for session-surface mutation after prompt assembly and before `step/start`; appends land outside the pending step. The loop derives history once afterward, so compaction records and replacements are included without rewriting an assembled request. The prompt and prefix are the exact pressure inputs for that request, and `signal` cancels listener work. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog 'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise | void @@ -61,11 +59,11 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:438`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall -Waterfall: decide what happens to ONE drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContext`) or block it. Fires inside the already-open turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook. Call `next()` to delegate to the default (allow unchanged), or return a PromptDecision without calling `next()` to short-circuit. +Allow, rewrite, or block one drained prompt before it becomes a user message. Call `next()` for the unchanged default. ```ts cordis-catalog 'agent/prompt-submit'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise @@ -73,11 +71,11 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:456`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit -A message entered the agent's inbox (queued or steering). Content and the resolved source are the detached, deeply-frozen values retained by the inbox. `source` has defaults applied and is not the caller's raw options. +Detached, frozen content entered the agent's inbox. Source defaults have already been applied, so these are the exact values retained for the log. ```ts cordis-catalog 'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void @@ -85,11 +83,11 @@ A message entered the agent's inbox (queued or steering). Content and the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:360`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts) ### `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. +Replace the frozen call configuration. Model-visible content must use logged channels; this seam cannot mutate messages. Injection here joins the next request because the current step boundary is already fixed. ```ts cordis-catalog 'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise @@ -97,15 +95,11 @@ Waterfall: shape the step's call configuration — model switching, sampling ove Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:485`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall -Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the ENTIRE derived history (directly after the provider's system slot) on every request this loop instance sends. Fired ONCE per loop instance, lazily before its first step's agent/pre-step seam — BEFORE the pre-step so a token-pressure gate (compaction) counts the prefix this instance will actually send, never a previous instance's logged one. The composed result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the instance's anchoring `'initial'`/`'resume'` header snapshot, and reused verbatim for every subsequent request — never recomputed mid-session, so the provider prefix cache holds by construction (a process restart or `ctx.agents.resume()` is a new instance: it recomposes, and any drift lands attributably on the `'resume'` snapshot). Composition runs outside the step, before the boundary snapshot: a composing listener's session append joins the CURRENT request's derived history. A composition interrupted by a cancel/dispose landing inside the waterfall is discarded — never cached, logged, or sent — and the next turn recomposes under a live signal, so an abort-aware listener's degraded fallback cannot leak into later requests. - -This is the home for session-stable openers the model must always see but that must NOT become durable history — a skills catalog, an AGENTS.md digest, a workspace baseline: `Session.deriveMessages()` never returns the prefix, and the header events are its only durable record, so the request stays reconstructable from the log. Content that CHANGES mid-session belongs in the append-only history channels instead — `agent.inject()`, a `tools/post-execute` decision's `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter. - -The seed is a frozen empty list; a contributing listener returns a NEW array — never an in-place push. The canonical contribution is a PREPEND, `[mine, ...await next()]`: the waterfall unwinds innermost-first (the LAST-registered listener's `next()` resolves first), so prepending yields registration order on the wire, and every plugin using it composes deterministically. The append form `[...await next(), mine]` is legal but places a contribution AFTER every later-registered plugin's — reverse registration order when all contributors append. Call `next()` to delegate, or return a list without it to short-circuit. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered through `agent.ctx` fires only for that agent's dispatches; a listener on a plain plugin context fires for every agent. The dispatch `this` is the scope carrier (`Scoped`), built by the emitting side via `scopeTarget`/`agentEvents`. +Compose request-only messages placed before derived history. The frozen result is computed once per loop instance, logged on its anchoring request header, and reused so the provider prefix remains stable. Interrupted composition is discarded. Composition precedes the first `agent/pre-step` and request boundary, so listener appends join the current request and pressure accounting sees the composed prefix. Changing context belongs in history; contributors should prepend to `await next()` to preserve registration order. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog 'agent/session-prefix'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise @@ -113,11 +107,11 @@ The seed is a frozen empty list; a contributing listener returns a NEW array — Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:537`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit -The agent's session lifecycle began, fired once before its first turn. `source` says why (SessionStartSource: fresh startup, a resumed persisted session, …). A pure NOTIFICATION (emit, not waterfall): a listener cannot veto by returning a decision or throwing. A listener that wants to seed context does so via `agent.inject()` (a `context/message` the first request sees). A lifecycle owner can still dispose its structural ownership edge during this notification; publication rechecks liveness and then aborts before the driver starts. +The session lifecycle began, once before the first turn. Use `agent.inject()` to seed model-facing context. This is a notification, not a veto; disposal requested by a lifecycle owner is rechecked before the driver starts. ```ts cordis-catalog 'agent/session-start'(this: Scoped, agent: Agent, source: SessionStartSource): void @@ -125,11 +119,11 @@ The agent's session lifecycle began, fired once before its first turn. `source` Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:381`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:180`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit -Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle off this transition, never off a status you just requested — `send()` does not flip status to `running` before it returns. +Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does not enter `running` synchronously; drive lifecycle from this event. ```ts cordis-catalog 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void @@ -137,7 +131,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:157`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -149,11 +143,11 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:552`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:250`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall -Waterfall: override the turn-continuation decision via a typed ContinuationDecision. The loop's `defaultDecision` is `continue` when the step had tool calls or steering was injected, else `stop`. Listeners force-continue (`/goal`, `/loop` — optionally attaching a `reason` recorded as next-step steering) or force-stop (budget guards). Call `next()` to delegate to the default, or return a decision to override. +Override whether the turn continues. The default continues after tool calls or steering and stops otherwise; a continue reason becomes steering. ```ts cordis-catalog 'agent/turn-continuation'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise @@ -161,11 +155,11 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:570`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:260`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial -Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` waterfall, any `continue.reason`, and the pending-steering continuation override have been folded. A listener returns `{ action: 'stop' }` to make this turn terminal, or `undefined` to abstain. Terminal stop is monotonic: listener order and steering cannot resume the turn, and pending steering is discarded rather than becoming another step or turn. +Monotonic terminal-stop checkpoint after continuation and steering are folded; a stop remains authoritative through turn close and flush: steering queued in that window is discarded, while ordinary sends survive. ```ts cordis-catalog 'agent/turn-stop'(this: Scoped, agent: Agent, turn: number): ContinuationStop | undefined @@ -173,7 +167,7 @@ Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` wat Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:588`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:270`](../../packages/core/agent/src/types.ts) ## `approval/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index ee49134b7c..c68ab6c923 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -157,17 +157,8 @@ interface GenerateOptions { stop?: string[] signal?: AbortSignal /** - * The id of the session this request belongs to — stamped by the agent loop - * from `agent.session.id`. Adapters ignore it; it lets an `llm/stream` listener - * route a call by WHICH session issued it (the replay adapter keys its per-call - * cursor by session, so a parent and its in-process subagent — each with its - * own session on one context — replay from their own recorded scripts). - * - * Typed as `Branded<'SessionId'>` rather than importing `SessionId` from - * `dsh-session`: that package imports `Message` from here, so importing its - * `SessionId` back would cycle. `SessionId` IS `Branded<'SessionId'>`, so a - * real session id assigns with no cast. (A future ids package could own the - * brand and dissolve this note.) + * Session identity stamped by the loop for listener routing. Adapters ignore + * it; replay uses it to keep concurrent parent and child cursors independent. */ sessionId?: Branded<'SessionId'> } @@ -354,7 +345,9 @@ interface Agent { } ``` -`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging. Persona is not an agent option: the `dsh-system-prompt` config supplies the global default, and an agent-scoped `deployment:persona` section may shadow it. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, serial `agent/pre-step`/`agent/turn-stop` checkpoints, and the `agent/prompt-submit`/`agent/request`/`agent/session-prefix`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits. +`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `AgentId` is branded. `AgentOptions` is merge-extensible and currently includes `model?`. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. + +The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits. ## Interception decisions @@ -397,7 +390,7 @@ type ContinuationStop = Extract type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' ``` -`agent/session-prefix` composes the session prefix — a plain `Message[]`, no dedicated payload type. Fired ONCE per loop instance, lazily on its first request: the composed list is deep-frozen, recorded as the header's `messagePrefix` ([the request envelope](#the-request-envelope-llmcallconfig-and-the-logged-header)), and placed in front of the ENTIRE derived history on every request the instance sends — the home for session-stable openers like a skills catalog or an AGENTS.md digest, never returned by `deriveMessages()`. Reuse is structural, so the prefix cannot drift mid-session (resume = a new instance = a recompose); content that changes mid-session goes through the append-only history channels instead (`agent.inject()`, `tools/post-execute` / prompt-submit `additionalContext`). Not a Decision union: the seam contributes content instead of vetoing, so the shape is the contribution itself. +`agent/session-prefix` composes a `Message[]` once per loop instance. The deep-frozen result is recorded in the request header and prepended to every derived history, making it the home for session-stable openers. A resumed instance recomposes; mid-session changes use append-only context channels. The waterfall returns content directly because it contributes rather than decides. ## `ToolDefinition` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 27b891b2f3..d1a097fe24 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,19 +7,19 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:331`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:605`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:438`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:456`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:360`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:485`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:537`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:381`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:552`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:570`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:588`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:139`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:148`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:283`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:202`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:167`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:224`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:180`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:157`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:250`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:260`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:270`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:68`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 22534a9bfa..516527f137 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -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:287`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:208`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -69,7 +69,7 @@ Assembled assistant message for one step (derived history uses this). Carries th Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts) ### `bash/*` @@ -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:285`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:206`](../packages/core/session/src/types.ts) ### `hook/*` @@ -169,7 +169,7 @@ Source: [`packages/ui/permission/src/index.ts:33`](../packages/ui/permission/src #### `prompt/blocked` — log-only -A queued prompt an `agent/prompt-submit` listener VETOED — the durable record of a blocked prompt and why. Appended in place of the `user/message` the prompt would have become, so the block survives replay even in a MIXED batch where another queued prompt is allowed (there the turn does not end `rejected`, so the boundary reason alone would not preserve it). `content` is the original prompt the listener rejected; `reason` is the veto text (PromptDecision `block.reason`). NOT a SurfaceEventType: a blocked prompt produces no LLM message and never reaches `deriveMessages()`. +Durable record of a prompt veto and its reason. It is log-only: the blocked prompt never enters the model-visible surface, including in a mixed batch. ```ts persistence-catalog 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } @@ -177,19 +177,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:279`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:200`](../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 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). +Full header for the next request, appended inside its step before dispatch. It is log-only; the latest snapshot reconstructs the request header. ```ts persistence-catalog 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:240`](../packages/core/session/src/types.ts) ### `steering/*` @@ -203,7 +203,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:312`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:233`](../packages/core/session/src/types.ts) ### `step/*` @@ -215,7 +215,7 @@ Closes step `step` of turn `turn`. 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -225,15 +225,13 @@ 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:264`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:191`](../packages/core/session/src/types.ts) ### `todo/*` #### `todo/write` — log-only -The agent's whole todo list, carried as a full snapshot and replaced wholesale on each write — the current list is the most recent `todo/write` (last-write-wins on replay, no fold). Appended by an owning agent via `session.append('todo/write', { todos })`. - -NOT a SurfaceEventType: it produces no LLM message and never reaches `deriveMessages()`, so it carries no `surfaceOp` and stays off the surface — it is durable, replayable UI state, distinct from the conversation history. It is a `SessionEventMap` member riding the existing `session/event` emit, not a first-class Cordis `interface Events` notification, so it has no cordis-catalog row. +Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. ```ts persistence-catalog 'todo/write': { todos: TodoItem[] } @@ -241,7 +239,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:326`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:235`](../packages/core/session/src/types.ts) ### `tool/*` @@ -255,7 +253,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:300`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:221`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -279,7 +277,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:310`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:231`](../packages/core/session/src/types.ts) ### `turn/*` @@ -293,7 +291,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:262`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:189`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -305,7 +303,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:256`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:183`](../packages/core/session/src/types.ts) ### `user/*` @@ -319,4 +317,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:268`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:195`](../packages/core/session/src/types.ts) 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 38f5a690c6..a5bc946af4 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -Two gaps shared one root. First, provider KV caching (DeepSeek context caching) is prefix-based — a request pays full price only for the tokens after the longest stored prefix it matches — yet nothing in the request pipeline stated, checked, or measured prefix stability: every registered [`PromptSection`](../../../../packages/core/system-prompt/src/index.ts) happened to be static, the tool set happened not to change mid-session, no listener happened to rewrite requests. A single time-interpolating section would have silently multiplied context cost, and no test or metric would have moved. Second, and deeper: the session log — the system's single source of truth — could not actually answer *what the model saw*. It recorded every message but never the system prompt, the tool schemas, or even which model; the mutable `agent/request` waterfall handed listeners the whole `GenerateOptions` to rewrite per call; replay equivalence was therefore a property of the plugin population, not of the design. +The request pipeline did not guarantee prefix stability for provider caching, and the session log could not reconstruct what the model saw. It omitted model, system prompt, and tool schemas while allowing per-call request rewrites. Cache behavior and replay equivalence therefore depended on whichever plugins happened to be loaded. The reference shape for the happy path is MiniCode's `LLMClient`: a stateful conversation client, appended to — never rebuilt — as the conversation advances, resetting only when the system prompt, tool set, or compaction genuinely changes what the model must see. The design question this RFC answers is how to get that discipline without giving up event-sourcing. 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 e0835dcaa6..a9efbee2b4 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 @@ -34,7 +34,7 @@ An earlier draft put the full algorithm (the retention walk, token-summing, text ### Auto-compaction runs on `agent/pre-step`, a dedicated surface-mutation seam -Compaction is a **surface mutation**, not a request transform — and that distinction is the seam it belongs on. The loop's request lifecycle, per step, is: assemble the system prompt → open the step → derive the message history from the surface → run the `agent/request` waterfall → call the model. An earlier cut wedged compaction into the `agent/request` waterfall, which forced two problems: (1) the loop had already derived `messages` from the *stale* surface, so the listener had to mutate the surface and then *re-derive* and overwrite `request.messages` — a double-derive whose only purpose was to undo the premature first derive; and (2) `agent/request` also carries downstream-injected context a listener might have added to `request.messages`, which compaction cannot act on (it can only compact the surface), inviting the confusion of measuring tokens compaction can't shed. +Compaction mutates the session surface, so it runs before the step opens and before messages are derived. `agent/request` remains a call-config transform and never needs to rebuild history after a surface change. The fix is a dedicated loop seam, **`agent/pre-step`** (`@mode serial`), fired by the loop *after* system assembly and *before* the step opens (`step/start`): @@ -62,7 +62,7 @@ A runaway turn thus compacts exactly like any other history: its early *closed* ### Head-anchoring: one auto checkpoint, always at the head -`compactIfNeeded` always anchors the compacted range at the surface **head** (`nodes[0]`). After a first compaction lands a summary node at the head, the *second* compaction's range starts at that summary node and re-summarizes it together with the steps accumulated since — so the surface holds **at most one** auto-generated checkpoint, always at the head, re-consolidated each cycle (the backend's checkpoint-merge prompt makes this a cheap incremental merge — see below). This is *why* `CompactionResult.shadowedRange` is a **surface-position span, not a numeric seq interval**: after a replace lands a fresh high-seq summary node at an older range's position, `start` can be numerically **greater** than `end`. The range is resolved positionally (index into the ordered node list and slice), and `shadowedSeqs` is the authoritative set in surface order. (Manual `compactRegion` may target any aligned mid-range and so *can* leave several checkpoints; the checkpoint framing does not claim everything after it is recent.) +Auto-compaction always starts at the surface head, merging the prior checkpoint with newly compacted history so only one automatic checkpoint remains. `shadowedRange` is therefore positional rather than a numeric sequence interval: a newer summary sequence may occupy an older surface position. `shadowedSeqs` records the authoritative surface order. Manual mid-range compaction may leave multiple checkpoints. ### Approximate convergence invariant @@ -85,7 +85,7 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab ### Checkpoint framing + incremental merge (backend-private) -The landed `user/message` is not the raw summary: the backend wraps it in a checkpoint preamble (so a resuming model reads it as established background, not a fresh request) and `` tags. The tags make a prior checkpoint detectable on the next cycle, and the summarization prompt then instructs the model to *merge it in place* (preserve still-true facts, drop stale) rather than re-summarize verbatim — a cheap incremental merge that needs no extra log/event machinery. The raw, unframed summary stays on the `compact/summary` provenance event. This framing is entirely a **backend HOW decision** — the contract only promises "a single replace `user/message` carries the (possibly framed) summary; the raw summary lives on `compact/summary`." A template or remote backend may frame differently or not at all. +The basic backend wraps the summary as established checkpoint context and tags it for incremental merging on the next cycle. The raw summary remains on `compact/summary`. Framing is backend policy; the seam promises only that one replacement user message carries the possibly framed summary. ### Blocking via a log-recorded lock, plus a crash/recoverable failure taxonomy @@ -121,7 +121,7 @@ Two failure paths, both documented: ## Testing -- **Unit** (`dsh-compact-basic`): the whole-unit retention walk, the convergence-invariant throw, both failure paths (`compact/end` with/without `error`), head-anchoring producing a non-monotonic `shadowedRange`, decline-on-open-tail, crash-orphan inertness, and the **runaway-turn regression** — a single oversized open turn compacts its early closed steps (proven to fail on the layer-2 protection it replaced). Driven through the real `dsh-invariants` plugin and the real Loader/inject path. -- **Loop** (`dsh-agent-loop`): `agent/pre-step` fires once per step, after `turn/start` and before `step/start`, awaited; a surface mutation in a `pre-step` listener lands outside the step and is reflected in the single derived request. -- **With-key e2e** (`examples/coding-agent`): a real model + real bash session with a lowered `contextWindow`/`retainTokens` triggers compaction mid-session; the test verifies the WORLD (a `compact/start…end` pair landed, the surface shrank, the agent still completed the task after compaction). This is compaction's first real-world exercise and the runaway-survival net. -- **Snapshot (deferred, named gap)**: a full-transcript snapshot of a runaway-turn compaction is NOT yet possible — `dsh-llm-replay` derives one model call per `(turn, step)` from `assistant/chunk` events, but the summarization call records no `assistant/chunk`s and carries no `sessionId` (it binds to the anonymous cursor and claims a non-existent extra script). Covering it needs net-new replay infrastructure (record/replay an interleaved summarization call) and is scheduled as a follow-up rather than discovered mid-build. +- **Unit:** Real Loader and invariant plugins cover whole-unit retention, convergence failure, both `compact/end` outcomes, head anchoring, open-tail refusal, inert crash orphans, and compacting closed steps inside one oversized open turn. +- **Loop:** Tests pin one awaited `agent/pre-step` per step between `turn/start` and `step/start`; a surface mutation there lands outside the step and appears in the single derived request. +- **With-key e2e:** A real model and bash session with lowered limits triggers compaction, records a complete `compact/start…end` pair, shrinks the surface, and finishes the task. +- **Snapshot gap:** Runaway-turn compaction cannot yet replay because the summarization call records no `assistant/chunk` events or `sessionId`; interleaved summarization-call replay remains follow-up work. 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 048c359948..05fa373433 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 @@ -4,7 +4,7 @@ Status: implemented ## Problem -The order of the tool list a model call carries — `request/header.tools` on the session log and `GenerateOptions.tools` on the wire — was an emergent artifact: the tool registry returns schemas in registration order, the system-prompt assembly concatenates providers in registration order, and the loop logged and dispatched the result verbatim. Registration order is plugin load order, and plugin load order is a race: the cordis loader imports every `cordis.yml` entry concurrently, so which tool plugin registers first depends on module-import completion timing. The plugin dependency relation cannot rescue this — it is a partial order under which independent tool plugins (e.g. `tool-subagent` vs `tool-todo`) are incomparable, so both interleavings are legal linearizations. This stopped being theoretical when a CI runner resolved the race differently from every recording machine: snapshot goldens pinned one permutation of `request/header.tools`, the `node 22.18` CI leg produced the other, and 5/5 snapshot tests failed on a diff that was pure array reordering. Tool order is part of the request bytes (prompt-cache stability, potentially model behavior) and, since the reconstructability contract, part of the durable session log — it must be a decision, not a residue. +Model-facing tool order followed plugin registration order, which depends on concurrent module loading for otherwise independent plugins. That race produced different request headers in CI and snapshot recordings. Because order affects request bytes, caching, and the durable header, it needs an explicit deterministic policy. ## Decision @@ -17,7 +17,7 @@ The system-prompt assembly owns the canonical model-facing tool order, exactly w - The list must contain the rest entry exactly once and no duplicate names. - When `toolOrder` is unset, the canonical order is plain lexicographic name order (code-unit comparison, locale-independent), so determinism requires no configuration. -The policy is applied where the list is born: `assemble()`, before the `system-prompt/assemble` waterfall. The assembly canonicalizes the tools it collects from providers the same way it sorts sections by their `order` field — on the initial assembly, killing the registration-order entropy at its source. The waterfall therefore starts from one deterministic list; when a listener leaves that order intact, the loop's `EpochHeader`, the `request/header` event, the deep-frozen request, and the dev invariant's cross-check inherit it with no new loop change. +`assemble()` canonicalizes provider tools before the `system-prompt/assemble` waterfall, removing registration-order variance at its source. The waterfall starts from this deterministic list; unchanged order then flows into the request header, frozen request, and reconstruction checks without loop-specific ordering logic. Scope is deliberately narrow: this fixes the REGISTRATION-ORDER race, not plugin behavior. A `system-prompt/assemble` listener may still add, remove, or rearrange tools — same as it may edit sections after their sort — and owns the determinism of what it emits; the waterfall contract already demands deterministic listeners (the reconstructability invariant would catch a listener that diverges between build and replay). @@ -46,4 +46,4 @@ Config plumbing follows the `persona` precedent, and `toolOrder` sits beside it: ## Testing -Unit tests on `dsh-system-prompt` pin the ordering semantics (lexicographic default, listed/rest placement, unknown-name rejection at assembly, reserved tool-name rejection, stable handling of shared names, provider-order independence), the pre-waterfall contract (listeners observe the canonical list; a listener-appended tool is not re-sorted), and each invalid-list rejection at load. Loop-level tests assert the `request/header` fold carries the canonical order for scrambled registration orders (identical across permutations), that a configured `toolOrder` reaches both the logged header and the dispatched deep-frozen request, that the frozen loop-built envelope survives to the adapter, and that an unregistered `toolOrder` name fails the turn with a balanced `error` `turn/end`, an `agent/error`, no step, no logged header, and no dispatched request. Forwarding is asserted at every level that exposes the key (`dsh-agent-core`, `dsh-stdio-agent`, `dsh-acp-agent`). The snapshot tier replays all scenarios while only the pinned `text-turn` header carries the full canonical tool list; non-pinning fixtures continue to compare through `{{tools}}`. +System-prompt tests cover lexicographic default order, listed/rest placement, provider-order independence, shared names, invalid lists, unknown or reserved names, the canonical pre-waterfall list, and the rule that listener-added tools are not re-sorted. Loop tests pin identical logged and dispatched order across registration permutations, forwarding through agent-core and both apps, deep-frozen requests, and balanced turn failure with no step, header, or adapter call for an unknown configured name. Snapshot replay keeps the full canonical list only in the pinned `text-turn` header; other fixtures continue to use `{{tools}}`. diff --git a/docs/rfc/implemented/feature/2026-07-06-sandbox.md b/docs/rfc/implemented/feature/2026-07-06-sandbox.md index 58321fbf81..bb3684e1b7 100644 --- a/docs/rfc/implemented/feature/2026-07-06-sandbox.md +++ b/docs/rfc/implemented/feature/2026-07-06-sandbox.md @@ -38,30 +38,13 @@ The swap is invisible to every consumer of `ctx.bash`: the bash tools, hook comm Misconfiguration fails loud: `mode` outside the closed vocabulary is rejected at plugin load, and a host with no usable backend throws the structured `SANDBOX_UNAVAILABLE` — at `confine()` before the command ever spawns — rather than degrading to unconfined execution. `runnerCommand` on `dsh-sandbox-local` is the operator's explicit assertion of a bwrap-compatible runner (chain and probes skipped); it doubles as the deterministic fake-runner seam for keyless tests. -What the model then experiences: denied file effects come back as result facts with a `[sandbox: file access denied under mode]` marker plus standing instructions not to retry around them; under a confining executor the schema offers `sandbox_permissions` + `justification` for the one-approval escalated retry (validated strictly wider than the session's effective mode at execution); the system prompt deliberately does NOT state the sandbox mode — the model learns the boundary from the marker (which names the mode) when it hits it, instead of preemptively refusing work a standing declaration discourages. What an ACP editor experiences: one `Permissions` config-option select per session (advertised when the `dsh-permission` preset layer is composed; each preset bundles a sandbox mode and an approval policy and writes through to both knob events — a knob state outside the table derives a switch-away-only `custom` current), switchable at runtime; a sandbox switch simply changes what subsequent commands may do, while an approval-policy switch to `'never'` is stated in the prompt and narrated. - -The product path, concretely (the escalation arc is verbatim from the recorded `escalation-approved` scenario; the denial leg is pinned on the real-kernel e2e tier): - -``` -tool/result … [sandbox: file access denied under read-only mode] ← the write RAN; the kernel refused it -tool/call bash {"command": "printf 'escalated\n' > escalated.txt && cat escalated.txt", - "sandbox_permissions": "workspace-write", - "justification": "the user asked to write escalated.txt in the workspace"} - → the editor is prompted on this very call (session/request_permission through the approval seam); Allow once -tool/result "escalated" — THIS call ran under workspace-write and its result facts say so; the session stays read-only -``` - -Reject instead and nothing executes: the result is the verbatim `the user rejected escalating this command to "workspace-write"`, and the teaching makes that final — no re-ask. +Denied file effects return a `[sandbox: file access denied under mode]` marker and instructions not to work around the denial. A confining executor adds paired `sandbox_permissions` and `justification` fields for one approved retry that must be strictly wider than the session's effective mode. A grant widens only that retry; rejection executes nothing, returns `the user rejected escalating this command to ""`, and permits no re-ask. The prompt does not announce sandbox mode, avoiding preemptive refusal. When `dsh-permission` is composed, ACP exposes one `Permissions` select whose presets write both knob events; unmatched knobs appear as switch-away-only `custom`. Only a switch to the deterministic `'never'` approval policy is stated in the prompt and narrated. ### Design detail -#### Grounding — verified against the code +#### Scope grounding -- Runtime OS subprocesses exist at exactly two sites: the `ctx.bash` seam's single spawn (`packages/bash/bash-local/src/run.ts`; hook commands flow through `ctx.bash`, so bash confinement covers them transitively) and `subagent-acp`'s child agents (`packages/subagent/subagent-acp/src/run.ts`) — the second consumer that makes a shared seam due rather than preemptive under the [capability seams RFC](../architecture/2026-06-13-capability-seams.md)'s "don't split preemptively" rule. -- Everything else executes inside the harness process (fs is in-process `node:fs`, web is in-process `fetch`, every `ToolDefinition.execute()` closes over `ctx`): an OS sandbox wraps `execve` and cannot wrap an in-process function call, so "sandbox any tool" is policy at each tool's seam, never a mechanical transport change. -- `tools/pre-execute` (`allow`/`deny`/`ask`) exists, with `ask` serviced by [the approval seam](2026-07-06-approval-seam.md); the fs intent gates are version guards with no mode input yet. -- `dsh-bash`'s request/spec split (`BashExecRequest` → `resolve()` → `BashExecSpec`) carries per-call fields the way escalation needs — `owner` is the template: request-optional, spec required-but-nullable, carried verbatim — and the result types already speak `SandboxMode`, so a per-call policy field adds no dependency edge. -- The pinned-header snapshot design means a schema/description change churns at most one pinning fixture per suite, and the escalation fields are advertised only under a sandboxing executor — so they live in exactly one pinned header, the acp example suite's `permission-switching` fixture. +OS subprocess confinement applies to the bash executor, including hook commands, and later to ACP subagent children. Filesystem, web, and other tools execute in-process and require policy at their own seams; an argv wrapper cannot confine a function closing over `ctx`. The existing bash request/spec split carries per-call overrides, while `tools/pre-execute` and the approval seam own the human decision. #### The seam: `ctx.sandbox` @@ -75,33 +58,33 @@ Left open, for the phase that needs them: whether network restriction arrives as #### Local backends and the shipped launcher -`dsh-sandbox-local` selects BY PLATFORM, once per lifetime, and caches the verdict: each platform names its runner chain, a chain of one is selected directly — probing arbitrates between candidates, and a sole candidate leaves nothing to arbitrate — and a chain of several is probed FUNCTIONALLY in preference order (build and enforce a real profile, never `--version` — a present-but-unusable `bwrap` must fail its probe). Linux: `bwrap` first (its mount profile is closest to the mode vocabulary: whole tree read-only, fresh `/dev`+`/proc`, `workspace-write` adds an ephemeral `/tmp` and rebinds the workspace root; deliberately no `--unshare-pid` and no network claim), else the npm-distributed `landlock-run` Landlock launcher. darwin: `sandbox-exec` speaking a Seatbelt (SBPL) profile — allow-default with `(deny file-write*)` plus write allow-lists, every granted root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`) — unprobed, the sole candidate. A platform with no chain fails closed at `confine()`; an unprobed runner that turns out unusable fails closed at EXECUTION instead — it refuses to run the command, and every wrap carries `runnerFailureSignatures` (the runner's own error prefix, which also matches the shell's runner-not-found message) so the consumer classifies that as a SANDBOX failure, never a task failure: on either path the command neither runs unconfined nor slips through as a plain failure. A non-empty `runnerCommand` config is the operator's assertion of a runner that fully enforces the bwrap-shaped profile — chain and probes skipped; it doubles as the deterministic fake-runner seam for keyless tests. It is not exempt from fail-closed execution: its wrap carries argv0-scoped outer-shell failure shapes (`exec: : not found`, `: No such file or directory`, `: Permission denied`) as its runner-failure dialect, so a missing or unexecutable configured runner classifies as a sandbox failure like every other rung — never as a failing command, and never as a denial. +`dsh-sandbox-local` selects one platform runner per provider lifetime and caches the verdict. Linux functionally probes `bwrap` then Landlock; macOS uses Seatbelt. Unsupported platforms and unusable runners fail closed. Each wrap carries backend-specific denial and runner-failure signatures so `dsh-bash-sandbox` can distinguish a denied file effect from a broken sandbox. `runnerCommand` skips selection as an operator assertion of a bwrap-shaped runner, but missing or unexecutable commands still classify as sandbox failure and never run the payload unconfined. The launcher is a ~300-line C program (plain C11 over the raw Landlock UAPI — no libraries beyond a statically linked musl, so the audit surface is that one file plus the kernel's stable syscall contract): `--ro ` / `--rw ` grants, `--`, the wrapped argv; it installs the ruleset on itself and `exec`s (rulesets are inherited across `execve`, and it sets `no_new_privs` before restricting); `--probe` enforces a maximal ruleset in a short-lived child and exits 0 only when the kernel actually enforces; launcher failures exit 125 without exec'ing. -The launcher lives in its own repository and reaches the harness as the npm package family [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) (the per-platform-package pattern of `node-addon-require-builtin` and esbuild): an entry package — `dsh-sandbox-local`'s one runtime dependency — plus per-platform binary packages selected at install time by npm's `os`/`cpu` fields. The entry package owns the launcher's CLI contract end to end (`launcherPath()` resolution with a never-existing fallback, the functional `probe()`, `grantArgs()` flag spelling), versioned together with the binary so probe-report parsing can never drift against it; the harness keeps only the policy side, `landlockProfileArgs()` mapping the mode vocabulary to grants. Native-only per-architecture builds, pack gates (binary presence, executability, ELF architecture), and the byte-pinned publish rehearsal are that repository's release pipeline; this repo's Landlock CI legs install the published family from the registry — the true consumer path — and prove real-kernel confinement through it. +The Landlock launcher ships through [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run), with platform binaries selected by npm. That package owns path resolution, probing, and CLI flags; the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned. FIXME: Revisit the separate-repository boundary and try to maintain the launcher source and its platform package family inside this monorepo, so the native release surface and harness contract evolve together. -Profile parity is honest rather than identical: under Landlock, `read-only` grants `--ro /` plus `--rw /dev/null` (the node, not `/dev` — the host's `/dev/shm` is a persistent shared tmpfs), and `workspace-write` grants the HOST `/tmp` where bwrap's is ephemeral; under Seatbelt, `read-only` likewise grants only the `/dev/null` literal, and `workspace-write` grants the host `/tmp` plus the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools; omitting it would deny what the mode promises). Every wrap carries the rung's denial dialect (`denialSignatures`: EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) so consumers match the active backend rather than a cross-runner union. Enforcement is honest per ABI level: an older kernel enforces the subset its ABI governs (path truncate is ungoverned before ABI v3), the probe's report line distinguishes the cases, and every confined result carries the structured `enforcement: 'full' | 'partial'` fact — refusing partial enforcement would deny the fallback to precisely the older-kernel hosts that need it. The bwrap and Seatbelt profiles govern every promised file effect by construction, so their passing probes always report `full`. +Backend profiles share the mode contract but differ in necessary host grants. Landlock and Seatbelt allow only `/dev/null` in read-only mode; workspace-write also permits their required host temp roots. Each wrap carries backend-specific denial signatures. Landlock reports partial enforcement on older ABIs that cannot govern every operation, while successful bwrap and Seatbelt profiles report full enforcement. #### The bash consumer -`dsh-bash-sandbox` extends `LocalBashExecutor` (spawn mechanics, process-group kills, spill files, background tasks, credential scrub inherited verbatim) and hands `ctx.sandbox` the exact `['bash', '-c', command]` argv it is about to spawn. A sandbox denial is a RESULT FACT, not an error: the command RAN and the kernel refused a file operation, so `result.sandbox.denied` is orthogonal to `exitCode`/`signal`. Classification is conservative text inference over the collected stderr tail against the WRAP's own dialect, so a backend is never credited with a denial text its kernel does not speak (bare EPERM under a Linux runner names non-file boundaries the mode vocabulary does not govern); the known residual false positive is non-sandbox text in the active dialect (an ssh auth failure under Landlock, a refused `kill` under Seatbelt), and a structured runner signal wins once one exists. A RUNNER failure is the opposite of a denial and outranks it in classification (a runner's error text can itself contain denial words): the wrap's `runnerFailureSignatures` matching a failed run means the sandbox broke and the command NEVER RAN — the foreground path re-throws it as the structured `SANDBOX_UNAVAILABLE` error (the late twin of the confine-time throw, carrying the runner's first stderr line), a settled background task stamps `sandbox.runnerFailed` and `bash_output` renders its own marker — so a broken sandbox can never read as a failing command. +`dsh-bash-sandbox` reuses local process execution and asks `ctx.sandbox` to wrap the exact bash argv. A kernel denial is a result fact independent of exit status and is inferred only from the selected wrap's stderr dialect. Runner failure outranks denial because it means the command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while settled background tasks set `sandbox.runnerFailed` for `bash_output`. This keeps broken confinement distinct from both task failure and an enforced denial. The model's view is result facts only: the static tool description explains the denial marker (`[sandbox: file access denied under mode]`), encourages attempting commands that may be denied, and forbids retrying around a denial; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). No prompt section states the sandbox mode (§ Per-session modes). #### Escalation: one approved wider retry after a denial -The seam level is mechanism only. `BashExecRequest` carries `sandboxMode?: SandboxMode`, an explicit per-call policy input; `BashExecSpec` carries it required-but-nullable (the `owner` pattern: a forgotten field is a visible `undefined`, and `resolve()` is the one explicit defaulting step); `BashExecutor` exposes the capability fact `get sandboxMode(): SandboxMode | undefined` — `undefined` in the base class, the configured mode in `SandboxBashExecutor` — so the tool layer can advertise only what the mounted executor honors: composition truth, not configuration. The seam honors ANY explicit mode, including a narrower one; the wider-only ladder is escalation policy and lives in the tool. A non-sandboxing executor (`dsh-bash-local`) carries the field verbatim and confines nothing — the field reaching it means the caller bypassed the tool's gate, and its honest behavior stays unconfined execution, not a guess at enforcement it does not have. +`BashExecRequest.sandboxMode` is an optional per-call input; resolved specs make the field explicit. `BashExecutor.sandboxMode` advertises whether the mounted executor can honor it, so only a confining composition exposes escalation. The seam accepts any explicit mode; the tool owns the wider-only escalation rule. Non-sandboxing executors remain honestly unconfined. `SandboxBashExecutor.resolve()` stamps the effective mode — escalation grant > session override > configured default — so `run()`/`start()` read the spec, never the config. The `danger-full-access` branch, the confine call, and the result facts all key off the spec's mode, and the per-task facts map carries each task's mode alongside its wrap facts (`notifyTaskDone()` stamps from the map entry): one escalated call — foreground or background — reports the mode it ACTUALLY ran under while every neighbor keeps its own. -The tool gate advertises two extra parameters exactly when `ctx.bash.sandboxMode` reports a confining mode at registration: `sandbox_permissions`, an enum of the closed escalation-target vocabulary — `workspace-write`/`danger-full-access`, every mode a session could ever escalate TO — and `justification`, required together with it. The enum is deliberately NOT cut down to the modes wider than the executor's DEFAULT: schemas are registry-global while the effective mode is per-session and switchable, so a default-relative ladder strands a session overridden NARROWER than the default (with a `danger-full-access` default and a `read-only` override it would advertise nothing at all — confined, but with no lever). Strict widening is instead enforced at EXECUTION against the call's effective mode (session override ?? executor default): a request that is not strictly wider fails closed with its own text and prompts no one. An escalating call resolves approval BEFORE anything executes — no `ctx.approval` composed, or no agent on the execution, fails closed with its own text; otherwise `ctx.approval.request({ agent, toolName: 'bash', callId, reason, signal })` with the audit-self-contained reason `escalate sandbox to ${mode}: ${justification}`, while the UI attaches the prompt to the already-streamed call (the command is visible there; the approval RFC's no-arguments rule holds). The four outcomes map to distinct results: `allowed-once` stamps `sandboxMode` onto the bash request and proceeds; `rejected`, `cancelled`, and `unavailable` each produce their own error text, so the model can tell a human "no" from a dismissed prompt from a missing channel. The grant is consumed by the very call that asked; nothing is stored. +When a confining executor is mounted, `bash` advertises paired `sandbox_permissions` and `justification` fields. The schema exposes the full closed escalation vocabulary because effective mode is per-session; execution rejects any target that is not strictly wider than that call's effective mode. Approval resolves before execution. `allowed-once` stamps the granted mode onto only that request, while `rejected`, `cancelled`, `unavailable`, a missing approval service, or a missing agent all fail closed with distinct results. No grant is persisted. -The tool description teaches — and a denied result itself prompts — the SAME-TURN flow when the fields exist: on a denial a wider mode would cure, escalate immediately in that turn by retrying the exact command once with `sandbox_permissions` (the narrowest mode that suffices) + `justification`, without detouring through chat to ask first — the approval prompt raised by the retry IS how the user consents. Never speculatively: an escalation is grounded in a real denial — normally the one the command just hit, up front only when the session already denied the same access — and a prompt stating approvals are disabled turns the exception off entirely; a rejected escalation is final for that command. Denial-grounding is deliberately model discipline plus human judgment, not harness bookkeeping — the human sees the exact command and justification on the prompt (see Alternatives for why hard-matching is rejected). No new session events anywhere: the attempt is an ordinary `tool/call` whose logged arguments carry the two fields, the decision is the approval seam's `approval/asked`/`approval/decided` pair, the outcome is an ordinary `tool/result` whose sandbox facts name the mode it ran under. The asker lives in `dsh-tool-bash`, NOT the executor: a transport seam has no `agent`, no `callId`, and no business asking humans questions. +Escalation is a same-turn retry of the denied command with the narrowest sufficient `sandbox_permissions` and a `justification`; the approval prompt is the consent step. It must be grounded in an actual denial, except when the session already observed the same denied access, and a disabled or rejected approval ends that command. The retry, approval decision, and result use existing tool and approval events. `dsh-tool-bash` owns the ask because the executor seam has neither the agent nor call id required for user interaction. -Left open, recorded for the phase that picks them up: what a grant's scope identity is beyond the sandbox mode — the exact call, a path, a command prefix, the session, a time window — the question `allow_always` grant storage must answer before that option can be advertised; how cancellation behaves while an approval prompt is pending; and how escalation is defined for `run_in_background` denials that arrive via `bash_output`. +Left open, recorded for the phase that picks them up: what a grant's scope identity is beyond the sandbox mode — the exact call, a path, a command prefix, the session, a time window — the question `allow_always` grant storage must answer before that option can be advertised; and how escalation is defined for `run_in_background` denials that arrive via `bash_output`. #### Per-session modes: the session log as the store @@ -198,15 +181,13 @@ Costs and accepted limits: - **The model may over-ask.** Escalating without denial grounding, or picking `danger-full-access` where `workspace-write` suffices: the description steers and the enum forces the ladder, but the human prompt is the actual gate; the `approval/asked` reasons make over-asking auditable, and a `prepend` policy answerer can auto-reject patterns a deployment never wants. - **The advertised target set is static while the effective mode is per-session** (schemas are registry-global) — a session already at the widest mode is still offered the fields. Harmless by construction: the strict-wider check at execution, not the enum, is the safety boundary — a non-widening request fails with its own text and never prompts anyone. - **A granted escalation is not a working sandbox.** An unavailable backend still fails closed even for a granted escalation to a confining mode — at `confine()` when the platform has no chain or every probe fails, at execution when an unprobed sole runner refuses (classified as a sandbox failure, not a command failure) — while a granted `danger-full-access` run never touches the provider at all: there the grant, not the probe, is the authority. -- **An idle switch lives in bridge memory until the next turn anchors it.** A crash in that window reverts it (reported honestly on `session/load`), and a session that never runs another turn never persists it — accepted, with the loop-owned idle commit turn named as future work if durability becomes a requirement. +- **An idle switch lives in bridge memory until the next prompt submission anchors it.** A crash in that window reverts it (reported on `session/load`), and a session that never submits another prompt never persists it — accepted, with a loop-owned idle commit turn left as future work if durability becomes required. - **The approval narrator's restart baseline parses prompt prose.** The closed candidate sentence is owned by the writing module itself, so a wording change is a coordinated writer+parser edit in one file; a session whose headers predate the section silently adopts the current policy without a notice. - **The approval section is still a dynamic prompt surface** (a `'never'` switch breaks provider prompt-prefix caching for that session). Accepted: policy switches are rare, and a model acting on a stale `'never'` is worse. The sandbox knob no longer touches the prompt at all. - **The model may hold a stale belief about the sandbox mode** (nothing announces a switch). Accepted deliberately: the next attempt's marker or success corrects it, and the observed failure mode of announcing — preemptive refusal — is worse than one wasted retry. ## FAQ -Behavioral and usage questions only — every "why not X?" design question lives in [Alternatives considered](#alternatives-considered), whose job is exactly that. - - **A command came back with `[sandbox: file access denied under read-only mode]` — did it fail?** It RAN, and the kernel refused a file effect: the denial is a result fact orthogonal to exit code. The teaching forbids retrying around it; the one sanctioned move is the same command retried once with an escalation request. - **How is a BROKEN sandbox told apart from a failing command?** Runner failure outranks denial in classification: a failed run matching the wrap's `runnerFailureSignatures` means the command NEVER ran — foreground re-throws the structured `SANDBOX_UNAVAILABLE` with the runner's stderr line, a background task stamps `sandbox.runnerFailed` and renders its own marker. A broken sandbox can never read as a failing command, and the command never runs unconfined. - **What happens on a platform with no backend — Windows today?** `confine()` throws the fail-closed `SANDBOX_UNAVAILABLE` and the command never spawns; `win32` is a reserved EMPTY chain, pinned by test to fail closed identically until a Windows runner fills it (§ Deferred phases). @@ -214,7 +195,7 @@ Behavioral and usage questions only — every "why not X?" design question lives - **Does the sandbox restrict network or process visibility?** No — `SandboxMode` claims FILE effects only; the bwrap profile deliberately does not unshare pid, and no backend claims network. Whether network restriction becomes its own knob is left open in § The seam. - **Which tools actually run confined?** OS subprocesses through `ctx.bash` — the bash tools, and hook commands transitively. fs/web/todo execute in-process, where an `execve` wrapper is mechanically meaningless; their `read-only` semantics arrive with the cross-family deferred phase, and until then the contract says bash-only honestly. - **Does a granted escalation persist, or cover background tasks?** Neither: the grant is consumed by the very call that asked (foreground or background), that one call reports the mode it actually ran under, and every neighbor keeps its own. How escalation should be DEFINED for a background denial that only surfaces later via `bash_output` is left open in § Escalation. -- **When does an editor's mode switch take effect?** Mid-turn: appended immediately, honored by the very next call's stamp. Idle: held on the bridge's session record, anchored at the next turn's `agent/prompt-submit`, with N flips coalescing to at most one event (none if net-zero); a crash before anchoring reverts it and `session/load` reports the truth. The model is not told — its next command simply behaves under the new mode. +- **When does an editor's mode switch take effect?** Mid-turn: appended immediately, honored by the very next call's stamp. Idle: held on the bridge's session record, anchored at the next `agent/prompt-submit` inside its open turn, with N flips coalescing to at most one event (none if net-zero); a crash before anchoring reverts it and `session/load` reports the truth. The model is not told — its next command simply behaves under the new mode. - **What survives a restart — and what if the operator changed the config default while the process was down?** Overrides replay from the session log (`effective = fold ?? config`), so a resumed session keeps its modes with zero catch-up machinery; a default that drifted offline changes behavior the same way a switch does (the approval policy, being stated, is additionally narrated with operator/config attribution). - **What does `enforcement: 'partial'` on a result mean?** The selected backend enforces the subset its kernel ABI governs — e.g. Landlock before ABI v3 does not govern path truncate — and says so structurally instead of refusing the host; the probe's report line distinguishes the cases. The bwrap and Seatbelt profiles govern every promised file effect by construction, so they always report `full`. diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 0b0300472c..c879dabfa1 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -1,31 +1,8 @@ /** - * `BasicCompactService`: the first implementation of the - * `@deepseek-ai/dsh-compact` seam. It owns the entire compaction strategy: - * - * - **Token estimation** — chars/`charsPerToken` heuristic (config, default 4) - * with per-block structural overhead. - * - **Retention policy** — walk surface nodes tail→head, keep recent nodes up - * to a token budget, compact everything older. The cutoff is snapped forward - * to the next balanced tool-pairing boundary so a compacted region never - * splits a step's tool-call/result pair (an open tail step is never crossed — - * compaction declines and retries once it closes). - * - **Summarization** — a direct one-shot `ctx.llm.stream()` call assembled - * via `BlockAssembler` with a fixed condense-the-history system prompt; - * NOT a loop step, so `agent/request` never fires — interception happens - * at `llm/stream` like any other direct call. - * - **Surface mutation** — a single `user/message` replace node carries the - * summary; `compact/*` events are log-only lock + provenance records. - * - **Auto-compaction** — an `agent/pre-step` listener delegates to - * {@link BasicCompactService.compactIfNeeded} before EVERY step (so a - * tool-heavy turn that grows the surface mid-turn still compacts); it owns the - * sole token-pressure check. - * - * A different backend (real tokenizer, template summarizer, turn-count - * retention) either subclasses this and overrides the {@link - * BasicCompactService.estimateContentTokens} / {@link - * BasicCompactService.summarize} hooks, or implements the abstract - * {@link CompactService} from scratch. - * + * Basic compaction backend. It estimates request pressure, retains a recent + * tool-balanced surface tail, summarizes the older head through a one-shot model + * call, and replaces that head with one checkpoint. Auto-compaction runs before + * every step so a growing turn can compact its earlier closed steps. * @module @deepseek-ai/dsh-compact-basic */ @@ -54,15 +31,8 @@ const SUMMARY_OPEN_TAG = '' const SUMMARY_CLOSE_TAG = '' /** - * The summarization system prompt: instructs the model to condense the - * conversation into a fixed, fully-populated structure rather than freeform - * bullets. The fixed structure guarantees coverage of the things a resuming - * model needs (original intent, pending work, the next step, critical context) - * and is stable across compaction cycles, so a prior checkpoint can be merged - * in place. The final rule keys off {@link SUMMARY_OPEN_TAG}: when the - * transcript already contains a prior checkpoint, the model consolidates rather - * than re-summarizing it verbatim (a cheap incremental-merge that needs no - * extra log/event machinery — the tag travels on the summary surface node). + * Fixed summary structure for resumable checkpoints. A tagged prior checkpoint + * is merged with newer history instead of copied forward verbatim. */ const SUMMARIZE_SYSTEM_PROMPT = [ 'You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context.', @@ -100,29 +70,13 @@ const SUMMARIZE_SYSTEM_PROMPT = [ `- If the transcript already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`, ].join('\n') -/** - * Framing prepended to the landed summary so a resuming model reads it as a - * checkpoint rather than a fresh user request, and continues the task from it. - * It summarizes an earlier span of the conversation; the messages that follow - * are the continuation. Because region compaction can be invoked manually, a - * surface may hold several checkpoints, so the framing does NOT claim that - * everything after it is recent or verbatim — only that the captured context - * should be built on, not restated. - */ +/** Framing that makes a landed summary established context rather than a new request. */ const CHECKPOINT_PREAMBLE = 'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.' /** - * Map a terminal `FinishReason` to the error a SUMMARIZATION must throw, or - * `undefined` for an acceptable finish. `FinishReason` is merge-extensible. - * - * Compaction fails CLOSED on a truncated summary: `error`, `aborted`, AND - * `max-tokens` all raise. Unlike an ordinary agent turn — where `max-tokens` is - * a normal "the model hit its budget" outcome the loop keeps — a summary cut off - * at the token cap is an INCOMPLETE checkpoint, and committing it would shadow - * (discard) the real history it summarizes. Raising here keeps the original - * surface intact (the caller appends `compact/end` with the error and the auto - * path proceeds with full history). `stop`/future kinds are accepted. + * Map a terminal summary failure to an error. A max-token finish is rejected + * because committing an incomplete checkpoint would shadow the full history. */ function finishError(finish: FinishReason): Error | undefined { switch (finish.kind) { @@ -164,25 +118,8 @@ export class BasicCompactService extends CompactService { this.config = resolveConfig(config) if (this.config.auto) { - // Auto-compaction: delegate to compactIfNeeded before EVERY step. This is - // LOAD-BEARING for runaway-turn survival: a tool-heavy ReAct turn appends - // an assistant/message and a tool/result per step, so the surface (and the - // derived token count) grows WITHIN a turn. The only moment to rescue a - // turn that alone approaches the window is the next step's pre-step - // checkpoint; gating to a turn's first step would let a runaway turn - // overflow before the next turn's check. The listener owns NO threshold - // logic — compactIfNeeded is the single place that decides whether to - // compact, and its in-progress lock serializes concurrent attempts. - // - // It runs on `agent/pre-step` (a serial surface-mutation checkpoint fired - // AFTER turn/start but BEFORE step/start), NOT `agent/request`: compaction - // mutates the session surface, and the loop derives the request `messages` - // AFTER this fires — so a single derive already reflects the compaction, - // with no double-derive and no need to rewrite an already-assembled - // `messages` array. Firing pre-step (outside any open step) keeps the - // log-only `compact/*` records and the replacement node cleanly outside a - // step, so a crash mid-compaction leaves an inert orphan the turn-repair - // closes — never a half-open step. + // Check before every step so a single growing turn can compact earlier closed steps. + // This serial pre-step seam mutates the surface outside the pending step. ctx.on('agent/pre-step', async (agent: Agent, _turn: number, _step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal) => { try { const result = await this.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal) @@ -289,27 +226,9 @@ export class BasicCompactService extends CompactService { } /** - * Summarize conversation text into content blocks via `ctx.llm.stream()` - * assembled through a `BlockAssembler`. A direct one-shot model call, NOT a - * loop step: it does not run the `agent/request` waterfall (that seam shapes - * the loop's conversation requests); per-call - * interception happens at `llm/stream` like any other direct call. The model - * comes from `BasicCompactConfig.summarizationModel`, falling back to the - * agent's own model. - * Override in a subclass for a template or remote summarizer. - * - * Honors the adapter failure contract: an adapter may report a model failure - * by throwing from `stream()` (propagated here) OR by ending the stream with - * a `finish {kind:'error'|'aborted'}` chunk — the latter is re-thrown so a - * provider error never yields an empty summary. - * - * Forwards `signal` into `GenerateOptions.signal` so an abort/dispose tears - * down the in-flight summarization rather than orphaning the model call. - * - * Returns the summary blocks TOGETHER with the call envelope it actually - * used (`model`, `maxTokens`) — the caller logs the envelope on the - * `compact/summary` provenance event, so an overriding subclass (template - * or remote summarizer) reports its own envelope honestly. + * Summarize through a direct one-shot `ctx.llm.stream()` call, not an agent + * step or `agent/request` dispatch. Failure finishes and truncated summaries + * reject; the signal is forwarded and only text reaches the checkpoint. * * @param text - plain-text rendering of the conversation region to condense. * @param agent - supplies the fallback model and the session id stamped on @@ -359,42 +278,10 @@ export class BasicCompactService extends CompactService { // ---- Core API (implements the abstract contract) ---- /** - * The sole token-pressure gate: estimate the NEXT request's pressure — the - * session prefix + the surface-derived history + the system prompt - * ({@link estimatePressure}) — and if it exceeds the threshold - * (`contextWindow * thresholdRatio`), compact - * the oldest surface nodes outside the `retainTokens` budget. The auto- - * compaction listener delegates here rather than pre-checking, so this is the - * only place the decision lives. The prefix counts because every request - * carries it in front of the history (`EpochHeader.messagePrefix`) even - * though it is not derived history — omitting it would under-estimate by - * exactly the prefix and let a deployment at the window edge skip - * compaction, then ship an over-window request. The loop composes the - * prefix BEFORE the pre-step seam and hands it through, so the gate sees - * this instance's actual prefix (never a previous instance's logged one — - * a resumed/forked instance whose contributor grew is gated on the grown - * value from its very first step). Compaction itself can only - * shrink HISTORY: a prefix that alone approaches the window is a - * configuration error no compactor fixes. - * - * Retention is a UNIFORM tail→head walk over the whole surface — turn - * boundaries play NO role. Walking node-by-node from the tail and summing - * token estimates, once the retained total reaches `retainTokens` the cutoff - * is rounded to a balanced tool-pairing boundary: if the cut before the - * retained node is unbalanced (an unanswered tool-call sits before it — i.e. - * it is mid-step), the walk continues head-ward until the cut is balanced so - * the whole step is retained (never splitting a step's tool-calls from their - * results); if it stopped on a free node (a node belonging to no step), that - * cut is already balanced. This always rounds toward retaining MORE (retained - * ≥ `retainTokens`) and is boundary-safe by construction — no separate snap - * pass. - * - * The compacted range is always anchored at the surface HEAD (`nodes[0]`): - * auto-compaction re-consolidates any prior head checkpoint into one fresh - * checkpoint. Declines (`null`) when nothing is over threshold, when the whole - * surface fits the retain budget, or when no balanced cutoff exists in the - * compactable range (its only content is an open tail step — retry once it - * closes). + * The sole pressure gate: count the next request's prefix, derived history, + * and system prompt. Above threshold, retain a recent tool-balanced tail and + * compact the head, reconsolidating any prior automatic checkpoint. Returns + * `null` when no safe or necessary range exists. */ override async compactIfNeeded( agent: Agent, @@ -466,14 +353,7 @@ export class BasicCompactService extends CompactService { throw new Error(`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`) } - // The region must never split a step's assistant-message tool-calls from - // their tool/results (which would orphan one side and produce a transcript - // every provider rejects). A region is safe iff BOTH its edges are balanced - // cuts: the cut before `start`, and the cut after `end`. A node that belongs - // to no step (pre-step user message, inter-step steering, injection context) - // is a balanced (free) boundary; an `end` inside an open (unclosed) tail step - // leaves the cut after it unbalanced (the open tool-call has no result yet), - // so it is rejected. See dsh-session's tool-pairing balance check. + // Both range edges must preserve assistant tool-call/result pairing. const events = session.events if (!isToolPairingBalanced(nodes, events, start)) { throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`) @@ -489,13 +369,8 @@ export class BasicCompactService extends CompactService { throw new Error('compaction already in progress') } - // Compaction's events (compact/* and the replacement user/message) must be - // turn-enclosed: the session-log contract rejects any plugin event appended - // outside an open turn. Auto-compaction satisfies this — it runs on the - // `agent/pre-step` seam, after `turn/start` and before `step/start`, so - // strictly inside the open turn (but outside any step). A manual call on a - // fully-closed session has no turn to enclose the events, so reject rather - // than emit an un-enclosed run. + // Compaction's events (compact/* and the replacement user/message) must be turn-enclosed: + // the session-log contract rejects any plugin event appended outside an open turn. const openTurn = this._openTurn(session) if (openTurn === null) { throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn') @@ -536,13 +411,8 @@ export class BasicCompactService extends CompactService { ...maxTokens !== undefined ? { maxTokens } : {}, }) - // --- Surface replacement --- - // The user/message directly shadows all compacted surface nodes with a - // single replace op. It is the ONLY surface event in the compaction - // sequence — compact/start, compact/summary, and compact/end are log-only - // (surfaceOp is rejected by the compiler for non-SurfaceEventType). - // The landed content is FRAMED (checkpoint preamble + tag-wrapped summary); - // the compact/summary provenance event above holds the raw model output. + // --- Surface replacement --- The user/message directly shadows all compacted surface + // nodes with a single replace op. session.append('user/message', { content: framedSummary, source: { kind: 'plugin', plugin: 'compact' }, @@ -596,17 +466,8 @@ export class BasicCompactService extends CompactService { } /** - * Whether a compaction is currently in progress for `session` — an unmatched - * `compact/start` (no later `compact/end`) WITHIN the current turn. - * - * The scan is scoped to the current turn: walking back from the tail it stops - * at the first `turn/end` (the boundary closing the prior turn). A - * `compact/start` left orphaned by a crash mid-compaction lives in a turn that - * persistence repair then closes with a synthetic `turn/end`; scoping here so - * that a stale orphan from a PAST turn cannot wedge compaction forever (it sits - * before the nearest `turn/end`, so the scan never reaches it). An in-progress - * compaction's `compact/start` is always in the still-open current turn, - * before any `turn/end`, so it is still detected. + * Whether a compaction is currently in progress for `session` — an unmatched `compact/start` + * (no later `compact/end`) WITHIN the current turn. */ private _isCompactionInProgress(session: Session): boolean { const events = session.events @@ -672,17 +533,7 @@ export class BasicCompactService extends CompactService { return { start: firstSeq, end: cutoffSeq } } - /** - * Keep ONLY text blocks from the model-produced summary before storing it. - * - * The summary lands on the surface as a synthesized `user/message` (see - * {@link _frameSummary}), so the only block type that is both useful and safe - * there is `text`. A model assistant message can otherwise carry `reasoning` - * (private chain-of-thought, must not leak into the durable checkpoint) and - * `tool-call` blocks — and a surviving `tool-call` in a user message would be - * an orphaned call with no matching `tool-result`, exactly the tool-pairing - * breakage compaction works to avoid. Filtering to text drops both. - */ + /** Keep only text; checkpoints cannot contain reasoning or orphan tool calls. */ private _textOnly(blocks: readonly ContentBlock[]): ContentBlock[] { return blocks.filter((block): block is Extract => block.type === 'text') } diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 1a30cc9fc9..44ae51ccad 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -82,15 +82,7 @@ function createTestService(overrides: Partial = {}): TestCom return new TestCompactService(new Context(), cfg({ auto: false, ...overrides })) } -/** - * Build a multi-turn session with surface markers (simulating real agent-loop - * output). Compaction always runs inside an OPEN turn (the loop fires the - * `agent/pre-step` seam after a turn's start and before a step's start), so by - * default the session is left with a trailing open turn: turns `1..turns` - * close, then one more `turn/start` opens with no matching `turn/end`. Pass - * `{ leaveOpen: false }` for a fully-closed session (e.g. to assert that manual - * compaction is rejected when no turn is open). - */ +/** Build closed turns plus an open compaction turn unless `leaveOpen` is false. */ function multiTurnSession(turns: number, messagesPerTurn: number = 2, opts: { leaveOpen?: boolean } = {}): Session { const leaveOpen = opts.leaveOpen ?? true const s = new Session(SessionId('test')) @@ -211,12 +203,8 @@ function expectNoOrphanToolResults(messages: Message[]): void { describe('BasicCompactService step-alignment (never split a tool-call/result pair)', () => { it('compactIfNeeded rounds the retained boundary head-ward to keep a whole step (no orphaned tool-result)', async () => { - // 3 turns, each one step = { assistant(tool-call), tool/result }. Surface - // (9 nodes): user1, asst1, res1, user2, asst2, res2, user3, asst3, res3 — - // 10/20/10 tokens. The tail→head walk retains by whole units; the compacted - // region always ends on a step boundary, so no step's tool-call is split - // from its result. retainTokens=55 keeps the recent tail; the older steps - // compact intact. + // Retain the recent tail while the older assistant/result pairs compact as + // whole units; no boundary may orphan a result. const svc = createTestService({ contextWindow: 280, thresholdRatio: 0.5, retainTokens: 55 }) const session = toolTurnSession(3) @@ -231,12 +219,8 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai }) it('compactIfNeeded returns null when the only compactable region is an un-splittable single step', async () => { - // The surface is exactly ONE step: [assistant(tool-call), tool/result]. Over - // threshold (by the derived role overhead), the tail→head walk stops with the - // retained boundary at the tool/result — which is NOT a step-aligned start (its - // issuing assistant precedes it in the same step). Rounding head-ward to find a - // clean boundary reaches index 0, so there is no step-aligned cutoff in the - // compactable range: compactIfNeeded declines rather than splitting the step. + // The only candidate cut is inside one assistant/result pair; with no safe + // compactable prefix, decline rather than split it. const s = new Session(SessionId('one-step')) s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) s.append('step/start', { turn: 1, step: 1 }) @@ -605,27 +589,16 @@ describe('BasicCompactService.compactIfNeeded', () => { }) it('returns null when the whole surface fits the retain budget (over threshold by role/system overhead)', async () => { - // threshold = floor(480*0.1) = 48. The 4 surface nodes weigh 10 each (raw 40 - // for the retention walk), but the derived estimate adds 4 role tokens per - // message → 56 ≥ 48, so the threshold check passes and the walk runs. The - // walk accumulates all 40 < retainTokens (45) without crossing the budget, - // so keepFromIdx reaches 0 and compaction declines. + // Role overhead pushes the request above its 48-token threshold, but the + // raw four-node retention walk remains below retainTokens=45, so all fit. const svc = createTestService({ contextWindow: 480, thresholdRatio: 0.1, retainTokens: 45 }) const session = multiTurnSession(2, 1) expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull() }) it('compacts a runaway turn: its early CLOSED steps summarize while recent steps stay verbatim', async () => { - // The REGRESSION that motivated dropping turn-protection. A single in-flight - // (open) turn has grown past the threshold on its own: several CLOSED steps, - // each [assistant(tool-call), tool/result]. Retention is turn-agnostic, so - // the turn's OWN early closed steps are eligible — they compact while the - // recent tail stays verbatim, and the harness survives. - // - // On the OLD layer-2 code this test FAILS: the entire open turn was retained - // verbatim (protectedIdx = first open-turn node = 0), so compactIfNeeded - // returned null and shadowedSeqs would be empty — the runaway turn could - // never compact and the next model call would overflow the window. + // Completed early steps of the open turn remain eligible; protecting the + // whole turn would make a runaway turn impossible to compact. const svc = createTestService({ contextWindow: 800, thresholdRatio: 0.1, retainTokens: 25 }) const s = new Session(SessionId('runaway')) // ONE open turn with 5 closed steps; each step is [asst(tool-call), result]. @@ -665,12 +638,7 @@ describe('BasicCompactService.compactIfNeeded', () => { }) it('compacts again after a prior summary node heads the surface (the summary stays eligible)', async () => { - // After the first compaction lands a replacement summary node at the head, - // a second compaction (still over threshold) re-consolidates it with newer - // context — head-anchoring means the prior checkpoint is always re-included, - // never stranded. retainTokens=25 leaves a couple of retained nodes after - // the first compaction (so the surface is [summary, …retained], not just - // [summary]). + // Head-anchored recompaction must include the previous summary and retained context. const svc = createTestService({ contextWindow: 800, thresholdRatio: 0.1, retainTokens: 25 }) const s = multiTurnSession(4, 1) // turns 1-4 closed, turn 5 open (no surface yet) @@ -776,10 +744,8 @@ describe('BasicCompactService blocking (compaction in progress)', () => { }) it('is not wedged by an orphaned compact/start from a prior (now-closed) turn', async () => { - // A crash mid-compaction left a compact/start with no compact/end; the turn - // it lived in was later closed (persistence repair appends turn/end). A - // whole-log scan would treat that stale start as an active lock forever. The - // scan is scoped to the current turn, so a NEW turn compacts normally. + // An orphaned start in a closed repaired turn is stale; only the current + // turn participates in the in-progress lock. const svc = createTestService() const s = new Session(SessionId('stale-lock')) s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -860,11 +826,8 @@ describe('BasicCompactService HMR safety', () => { }) it('disposing the plugin fiber unregisters ctx.compact', async () => { - // Mount through the real plugin fiber (the Loader path), then dispose it and - // confirm the service registration is torn down. LlmService is mounted first - // so the service's `inject: ['llm']` resolves and the fiber activates. (The - // sibling-fiber ctx.llm resolution this same setup also exercises is covered - // under the "llm inject (real plugin-load path)" suite.) + // Mount through the real plugin fiber (the Loader path), then dispose it and confirm the + // service registration is torn down. const ctx = new Context() await ctx.plugin(LlmService) const fiber = await ctx.plugin(BasicCompactService, cfg({ auto: false })) @@ -1259,11 +1222,8 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => it('summarization is interceptable at llm/stream (model routing for direct calls)', async () => { const { ctx, adapter } = await ctxWithModel('ROUTED SUMMARY', 'routed-model') - // The summarize call is a direct one-shot model call, not a loop step: it - // does not run agent/request (that seam shapes the loop's conversation - // requests). llm/stream is its interception surface, and a hand-built - // request is not frozen, so mutate-then-next model routing works — the - // adapter resolves AFTER the waterfall, so the rewrite picks the adapter. + // One-shot summaries bypass agent/request but remain mutable at llm/stream; + // adapter selection happens after the waterfall rewrite. ctx.on('llm/stream', (options, next) => { options.model = 'routed-model' return next() @@ -1517,19 +1477,13 @@ describe('BasicCompactService edge cases', () => { const svc = createTestService() const s = new Session(SessionId('empties')) s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - // Step 1: an empty-text user, an empty-reasoning assistant with NO tool-call - // (balanced: nothing to answer), and empty context/steering — all extract to - // nothing and are skipped. s.append('step/start', { turn: 1, step: 1 }) s.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'reasoning', text: '' }] }, { surfaceOp: 'append' }) s.append('context/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append' }) s.append('steering/message', { turn: 1, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) s.append('step/end', { turn: 1, step: 1 }) - // Step 2: a tool exchange whose tool/result has empty content → empty - // extraction → skipped. The assistant carries the matching tool-call so the - // surface stays tool-pairing balanced; its text extracts to the tool-call - // placeholder (the one surviving line). + // Keep the log pairing-valid while the empty result covers the final message kind. s.append('step/start', { turn: 1, step: 2 }) s.append('assistant/message', { turn: 1, step: 2, @@ -1661,10 +1615,8 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a describe('BasicCompactService llm inject (real plugin-load path)', () => { it('declares llm in static inject so a sibling fiber can resolve ctx.llm', () => { - // summarize() reads ctx.llm; the inject lets the cordis ctx proxy resolve a - // sibling LlmService when this service is mounted as its own plugin fiber. - // Asserting the declaration (and exercising the real mount below) guards the - // resolution that root-ctx unit tests cannot, since they share one fiber. + // summarize() reads ctx.llm; the inject lets the cordis ctx proxy resolve a sibling + // LlmService when this service is mounted as its own plugin fiber. expect(BasicCompactService.inject).toContain('llm') }) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 711e6fab69..15cc0aa410 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -1,9 +1,7 @@ /** - * The agent loop driver: one `runLoop()` invocation drives one agent for its - * whole lifetime. Error-contained at the turn level — a throwing plugin ends - * the turn, never kills the loop. See the JSDoc on `runLoop()` for the full - * lifecycle pseudo-code. - * + * Drives one agent across queued durable turns. Turn failures are contained so + * later work can run; the session log, not this driver, owns conversation state. + * See docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md. * @module dsh-agent-loop/loop */ @@ -25,33 +23,12 @@ import type { Inbox } from './inbox.ts' /** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */ type CodedError = Error & { code?: string } -/** - * Normalize an arbitrary thrown value into a coded Error. A real Error passes - * through (its `code`, if any, is preserved by {@link errorData}); a non-Error - * throw is wrapped in a {@link HarnessError} with code `UNKNOWN` and the - * original value chained as `cause`, so a bad throw still carries a routable - * code instead of degrading to a bare message. - */ +/** Normalize thrown values while preserving an existing error code. */ function toError(error: unknown): CodedError { return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error }) } -/** - * Map a model-call {@link FinishReason} to the step error it should raise, or - * `undefined` when the step completed normally. - * - * Adapters report provider/transport failures one of two sanctioned ways (see - * the StreamChunk contract in dsh-llm): throw from `stream()` (handled by the - * caller's try/catch), OR end the stream with a finish-error/aborted chunk - * (the only option for adapters that can't throw mid-stream, e.g. - * library-backed ones). This translates the latter into a thrown step error - * so the turn ends error/aborted (the failure recorded on `turn/end.reason`), - * never as a normal `completed` assistant message. - * - * `FinishReason` is merge-extensible (plugins/adapters can add `kind`s), so - * the switch handles the known terminal-failure kinds and treats every other - * kind — `stop`, `tool-calls`, `max-tokens`, future additions — as success. - */ +/** Convert terminal failure finishes into step errors; unknown extensible finishes remain successful. */ function finishError(finish: FinishReason): CodedError | undefined { switch (finish.kind) { case 'error': { @@ -78,19 +55,7 @@ function errorData(err: CodedError): { message: string; code?: string } { return { message: err.message, ...typeof err.code === 'string' ? { code: err.code } : {} } } -/** - * The turn-end contribution of a step's *successful* finish, or `undefined` - * when the step finished ordinarily (a plain `completed`). - * - * {@link finishError} has already converted `error`/`aborted` finishes into - * thrown step errors, so the finishes that reach here are `stop`, - * `tool-calls`, `max-tokens`, or a future merge-extensible kind. Only - * `max-tokens` carries forward as a distinct {@link TurnEndReason}: a step that - * hit the output-token ceiling ended the turn cut-short rather than by the - * model's choice. `stop`/`tool-calls`/unknown kinds contribute nothing beyond - * the default `completed`. {@link runTurn} applies this with the rule "any - * `max-tokens` step in the turn makes the turn end `max-tokens`". - */ +/** Map a successful max-token finish onto the turn reason; other successful finishes add nothing. */ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined { switch (finish.kind) { case 'max-tokens': @@ -103,11 +68,7 @@ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined { } } -/** - * Ambient handles the loop driver receives from the agent. Decouples the - * pure function `runLoop` from the mutable ReactLoopAgent fields, making the - * loop testable without a real agent. - */ +/** Mutable agent controls supplied to the loop driver. */ export interface LoopHandle { /** Native-private agent inbox handed to the driver only at internal startup. */ readonly inbox: Inbox @@ -116,122 +77,37 @@ export interface LoopHandle { /** Resolves when the agent is disposed — unblocks the idle wait. */ disposed: Promise isDisposed(): boolean - /** - * Whether a `cancel()` is pending for the current turn. The driver checks this - * at every decision point where a turn could start or continue (right after - * the idle wait, after the `running` flip, before each step, and at the - * continuation gate) and drops the about-to-run / continuing turn. Reset once - * per loop iteration via {@link clearCancel} after the turn returns, so the - * marker governs exactly one cancellation and never leaks to a later prompt. - */ + /** Whether cancellation is pending for the current loop iteration. */ isCancelled(): boolean - /** - * The resolved reason for the pending cancel (`reason ?? 'cancelled'`), read - * by the marker branches (pre-step / continuation) so a turn dropped where no - * `AbortController` carries the reason still records the caller's - * `cancel(reason)` value — matching the mid-step abort path. Only meaningful - * when {@link isCancelled} is true. - */ + /** Resolved pending-cancellation reason; meaningful only while {@link isCancelled} is true. */ cancelReason(): string /** Clear the cancel marker (called once per iteration after the turn returns). */ clearCancel(): void - /** - * Settle pending `whenIdle()` waiters WITHOUT a status transition. Used by the - * pre-step cancel-skip path: it drops the about-to-run turn and re-parks at the - * idle wait, so no `running→idle` transition fires to settle a `whenIdle()` - * waiter that was registered in the pre-step window — this settles it directly - * (it emits no `agent/status`, so an ACP `agent/status` listener never sees a - * spurious idle that would resolve a freshly-queued prompt as cancelled). - */ + /** Settle idle waiters when pre-running cancellation skips a turn, without emitting `agent/status`. */ settleIdle(): void } /** - * The agent loop. One invocation drives one agent for its whole lifetime: - * - * ``` - * create agent → emit agent/session-start(source) ⟵ once, before turn 1 - * forever: - * wait for queued messages (idle) - * TURN (error-contained — a throwing plugin ends the turn, never the loop): - * 'turn/start'; each queued msg: waterfall agent/prompt-submit ⟵ durable turn boundary (no agent/* mirror) - * allow → session('user/message'…) (+ inject additionalContext) | block → drop - * every prompt blocked → 'turn/end'(rejected), 0 steps - * STEP loop: - * drain steering → session('steering/message') ⟵ catches late steering - * assembly = ctx.systemPrompt.assemble(assembleContextFor(agent)) ⟵ waterfall system-prompt/assemble - * (scope-filtered; scoped sections/tools join); renderPrompt - * (persona section + {{variables}}) IS the full prompt - * prefix ??= waterfall agent/session-prefix ⟵ once per loop instance (first step): frozen - * session prefix; logged on the header, never - * session history (scope-filtered, fused dispatch) - * await events.serial('agent/pre-step', …, prefix) ⟵ surface mutation (compaction) OUTSIDE the step; - * pressure gates see the prefix the request carries - * 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') ⟵ 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') - * msg = waterfall agent/step-result ⟵ BEFORE the log append, so the - * session('assistant/message' {content, usage?}) session records what actually ran - * each tool-call in msg (sequential, abort-checked): - * session('tool/call'); ctx.tools.execute() ⟵ tools/pre-execute (allow/deny/ask) - * → dispatch → tools/post-execute - * session('tool/result') - * append buffered post-execute additionalContext → session('context/message')(s) - * drain steering → session('steering/message') - * session('step/end') ⟵ durable step boundary (no agent/* mirror) - * cont = waterfall agent/turn-continuation ⟵ ContinuationDecision; default - * {action: hadToolCalls||steered ? 'continue':'stop'}; a continue.reason is - * recorded as next-step steering - * if action==stop && steering arrived (step/end/continuation listeners): continue anyway - * terminal = serial agent/turn-stop ⟵ stop or abstain; after all ordinary - * continuation and steering folding - * if terminal: discard pending steering and break - * if action==stop: break - * session('turn/end') ⟵ durable turn boundary (no agent/* mirror) - * await ctx.sessions.flush(session) ⟵ durability checkpoint (store-owned carrier) - * re-enqueue leftover steering as queued ⟵ steering is never stranded - * idle (emit agent/status) unless more queued - * ``` + * Drive queued batches as durable turns until disposal. Plugin failures end the + * current turn without terminating the driver. * @param ctx - the plugin context the loop reaches events (agent/…, session/flush) and services (systemPrompt, llm, tools) through. * @param agent - the agent this invocation drives for its whole lifetime (its inbox, session, and options). * @param handle - the bridge to the agent's mutable state: status/abort setters plus the disposal and cancel-marker reads. */ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise { - // Per-instance transmission bookkeeping: whether THIS loop instance has - // anchored the log's header fold yet (its first request logs a - // 'initial'/'resume' request/header snapshot). Everything else the request - // needs is read from the session log itself — the loop holds no - // conversation state (the reconstructability RFC). + // Per-instance prefix and request-header state; conversation history remains in the session log. const transmission = createTransmissionLog() const { session } = agent - // The fused agent-subject dispatcher: every agent/* dispatch below carries - // the agent's scope (an `agent.ctx` listener hears only this agent) with - // the subject injected — one spelling, checked by the dev invariants. + // Fused subject and scope carrier for every agent event below. const events = agentEvents(ctx, agent) while (!handle.isDisposed()) { await handle.inbox.waitForQueued(handle.disposed) if (handle.isDisposed()) break - // Pre-step cancel (window 1): a `cancel()` landed after a `send()` woke the - // idle wait but before we flip to `running`. The cancelled queued/steering - // work is already cleared by `cancel()`. Clear the marker, then: - // - if NOTHING new is queued, drop the about-to-run turn and re-park, - // settling any `whenIdle()` waiter DIRECTLY (no running→idle transition - // fires here to settle it) and WITHOUT emitting `agent/status` (an ACP - // listener must not see a spurious idle that resolves a freshly-queued - // prompt as cancelled); - // - if a NEW prompt was queued AFTER the cancel (a send() that raced in - // before the loop resumed), the marker was for the cancelled work only — - // fall through and run the new prompt's turn. Do NOT settle waiters here: - // a whenIdle() waiter must wait for that new turn's running→idle, not - // resolve before it runs (the quiescence contract). + // Cancellation between wake and `running` skips only the cancelled work; + // a replacement prompt still runs and owns the eventual idle transition. if (handle.isCancelled()) { handle.clearCancel() if (!handle.inbox.hasQueued) { @@ -242,18 +118,8 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH handle.setStatus('running') - // Pre-step cancel (window 2): `setStatus('running')` emits `agent/status` - // SYNCHRONOUSLY, so a `running` listener can `cancel()` in the gap between the - // check above and `runTurn`. Mirror window 1: clear the marker, then - // - if NOTHING new is queued, drop the about-to-run turn and transition - // back to `idle` (`running` was already emitted, so a real idle - // transition balances the status AND settles `whenIdle()` waiters); - // - if a NEW prompt was queued AFTER the cancel (a `running` listener that - // cancels then sends), the marker was for the cancelled work only — fall - // through and run the new prompt's turn (status is already `running`), so - // a `whenIdle()` waiter resolves on THAT turn's running→idle, not before - // it runs. Settling here would resolve quiescence while the replacement - // is still queued and unrun (the same early-resolve race window 1 fixes). + // A synchronous `running` listener can cancel before `runTurn`; balance the + // status only when no replacement prompt was queued by that listener. if (handle.isCancelled()) { handle.clearCancel() if (!handle.inbox.hasQueued) { @@ -262,24 +128,13 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH } } - // Re-derive the turn number from the log each iteration (do NOT keep a local - // counter): an idle `agent.inject()` can append its own one-shot turn while - // the loop waits above, so the next real turn must continue from whatever - // turn number is actually last in the log — a stale counter would collide. + // Idle injection can add a turn, so derive the next number from the log. const turn = lastTurnNumber(session) + 1 let terminalStopped = false try { terminalStopped = await runTurn(ctx, events, agent, handle, turn, transmission) } catch (error: unknown) { - // Backstop: runTurn rethrows only a PRE-turn throw (the invariant guard - // before turn/start) — no turn/start was appended, so no turn is open and - // none is owed. A session `error` here would land outside any turn (after - // the previous turn/end), where the persistence backend drops it as a - // crash tail (the turn-enclosure RFC). Report via agent/error + the logger only; the - // driver survives and moves on. - // Acceptance and internal dispatch validation can reject before - // turn/start commits. Report that supported pre-turn failure without - // inventing a turn/end for a turn that never opened. + // Pre-turn failure has no durable boundary to close; report it without appending outside a turn. const err = toError(error) ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`) try { @@ -287,21 +142,10 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH } catch { /* contained: a throwing agent/error listener must not kill the driver */ } } - // Reset the cancel marker UNCONDITIONALLY here, after the turn returns and - // before the next iteration's idle wait. NOT gated on the idle transition - // below: a `send()` that lands during the cancelled turn's flush window makes - // `hasQueued` true at the `setStatus('idle')` guard, so an idle-gated reset - // would never fire and the stale marker would wrongly drop that next prompt's - // turn. Resetting per iteration scopes the marker to exactly the turn that was - // cancelled. + // Reset per iteration, including when a prompt arrives during the flush window. handle.clearCancel() - // Steering that arrived too late to join an ordinary turn (turn-end - // listeners, flush) becomes queued input so it is never stranded. A - // terminal-stop owner is the deliberate exception: discard the steering - // again after the close + flush window so terminal policy cannot be undone - // after its in-turn drain. Ordinary queued sends live in a separate FIFO and - // remain untouched. + // Late steering becomes queued input unless terminal policy stopped the turn. for (const message of handle.inbox.drainSteering()) { if (!terminalStopped) handle.inbox.enqueue(message) } @@ -315,10 +159,7 @@ async function runTurn( ): Promise { const { session } = agent - // --- Pre-turn. A throw here (the invariant guard) is owed NO turn/end — - // turn/start has not been appended — so it propagates to runLoop's backstop - // untouched. The queued messages are drained here but appended AFTER - // turn/start (below), so every event in the log lives inside a turn. + // Drain before opening the turn, but append only after `turn/start`. const queued = handle.inbox.drainQueued() const first = queued[0] /* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */ @@ -331,28 +172,17 @@ async function runTurn( let errorReported = false let terminalStopped = false - // Close the open step exactly once (idempotent via stepOpen). Post-commit - // session/event observers are contained by Session; a pre-commit validator - // failure still escapes so the outer recovery path may retry the boundary or - // fail loudly without pretending an uncommitted step/end exists. + // Close the committed step once; pre-commit validation failure still escapes. const closeStep = (): void => { if (!stepOpen) return session.append('step/end', { turn, step }) stepOpen = false } - // Record a step/turn failure exactly once: set the error reason (carrying the - // failing `step` — the durable failure lives entirely on turn/end.reason, there - // is no separate session error event) and emit agent/error (contained — trap: a - // throwing agent/error listener must not re-escape and strand the turn). - // Disposal and abort set `reason` directly without calling this (they are not - // failures). + // Record the durable turn failure once and contain the live error notification. const failTurn = (err: CodedError): void => { if (errorReported) return errorReported = true - // The turn is still open here. Post-commit observers cannot escape append, - // and a pre-commit turn/end veto leaves no closing boundary to overwrite. - // Set the reason that the next successful closeTurn will append. reason = { kind: 'error', step, ...errorData(err) } try { events.emit('agent/error', turn, step, err) @@ -362,9 +192,7 @@ async function runTurn( } } - // Close the turn. Post-commit observer failures are contained by Session; - // pre-commit validation failures escape to recovery instead of being mistaken - // for a committed boundary. Turn boundaries are durable session events only. + // Pre-commit validation failure escapes rather than masquerading as a committed boundary. const closeTurn = (): void => { session.append('turn/end', { turn, reason }) } @@ -414,11 +242,7 @@ async function runTurn( } while (true) { - // A fully-blocked batch (every prompt vetoed by prompt-submit) opens a - // zero-step turn that ends `rejected`: break BEFORE the first step so the - // boundary stays balanced (turn/start → turn/end) and the block is a - // durable in-turn fact. `anyAllowed` never changes inside the loop, so this - // only ever fires on the first iteration. + // A fully blocked batch closes its zero-step turn as rejected. if (!anyAllowed) { reason = { kind: 'rejected', reason: lastBlockReason } break @@ -437,48 +261,20 @@ async function runTurn( const abort = new AbortController() handle.setAbort(abort) - // Assemble the system prompt for this step. Done HERE (before step/start) - // because the pre-step seam needs it: compaction measures token pressure - // against the system prompt (it counts toward the budget). runStep reuses - // this same assembly for the request, so the prompt is assembled once per - // step. renderPrompt IS the full prompt — the persona is the order-0 - // section (owned by dsh-system-prompt) and `{{variable}}` - // interpolation happens in the render, so there is no separate join. + // Assemble once before pre-step so pressure checks and the request share the same prompt. const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent)) const fullSystemPrompt = renderPrompt(assembly) - // Interruption landing after assembly: dispose() or cancel() in a - // turn-start listener (or a listener whose promise resolved before the - // await above) arms either handle.isDisposed() or handle.isCancelled(). - // The Abort was created first, so any concurrent abort also lands on it. - // Drop the about-to-start step WITHOUT running the seam — no step is open - // yet, so end the turn accordingly (disposed wins for an unambiguous - // reason). + // Cancellation or disposal during assembly ends the turn before any step opens. if (handle.isCancelled() || handle.isDisposed()) { handle.setAbort(undefined) reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() } break } - // Compose the session prefix ONCE per loop instance, lazily before the - // instance's first pre-step: request-only messages placed in front of - // the ENTIRE derived history on every request this instance sends. It - // MUST precede the pre-step seam so compaction gates on THIS instance's - // prefix — reading a previous instance's logged prefix would let a - // resumed/forked instance whose contributor grew skip compaction and - // ship an over-window first request. The result is deep-cloned - // (decoupled from listener-held references), deep-frozen, and cached on - // the transmission bookkeeping, so reuse is structural — the prefix - // cannot change mid-session and the provider prefix cache holds by - // construction (resume = a new instance = a recompose, anchored by its - // 'resume' snapshot). The prefix is not session history — the header - // event in runStep is its only durable record - // (EpochHeader.messagePrefix). The frozen empty seed serves both the - // listener chain and the no-listener fallback: a contribution is a - // RETURNED extension of `await next()`, never an in-place push. This - // runs OUTSIDE the step, before the boundary snapshot: a composing - // listener's session append lands before the boundary and joins the - // CURRENT request. + // Compose the request-only prefix once per loop instance before pressure + // checks. It precedes all derived history and is recorded only in the + // request header, not as session history. if (transmission.sessionPrefix === undefined) { const emptyPrefix: Message[] = deepFreeze([]) const composed = await events.waterfall( @@ -486,16 +282,7 @@ async function runTurn( () => Promise.resolve(emptyPrefix), ) - // Interruption landing during prefix composition: mirror the assembly - // window above — drop the about-to-start step without running the - // seam, and DISCARD the composition instead of caching it. An - // abort-aware listener may have returned a degraded fallback under - // the firing signal; committing it would ship a prefix no request - // ever used (and no header ever logged) on this instance's next real - // request. The next turn recomposes under a live signal — the cache - // only ever holds a fully composed prefix. The cache-hit path needs - // no such check: nothing awaits between the assembly check above and - // the pre-step seam. + // Never cache an interrupted composition; the next turn recomposes it. if (handle.isCancelled() || handle.isDisposed()) { handle.setAbort(undefined) reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() } @@ -504,19 +291,7 @@ async function runTurn( transmission.sessionPrefix = deepFreeze(structuredClone(composed)) } - // Pre-step surface-mutation checkpoint (compaction), fired OUTSIDE the - // step: after `turn/start` (and the prior step's close) but before - // `step/start`, so a compaction's log-only `compact/*` records and its - // replacement node land cleanly outside any step (honest structure that - // crash-safety relies on — a dangling `compact/start` sits before the - // synthetic `turn/end` repair appends). Serial (awaited, in order, no - // veto): each listener completes its surface mutation before the next, so - // concurrent listeners cannot interleave their `session.append`s. A - // throwing listener escapes to the outer catch, which closes the (not-yet- - // open) step as a no-op and ends the turn via failTurn — a broken - // pre-step plugin ends the turn, not the loop. The composed session - // prefix rides along so token-pressure listeners count everything the - // request will actually carry. + // Await surface mutations outside the step; pressure checks receive the pending prefix. await events.serial('agent/pre-step', turn, step, fullSystemPrompt, transmission.sessionPrefix, abort.signal) // Interruption landing during the pre-step seam: do not open an empty step. @@ -526,16 +301,8 @@ async function runTurn( break } - // The reconstruction boundary (the reconstructability RFC): the request's - // messages are snapshotted HERE, in the same synchronous frame as the - // step/start append directly below — so the snapshot is exactly the - // derivation over the log prefix strictly before step/start's seq. - // Anything appended later by the request-window inject seam or a - // concurrent task lands after the boundary and joins the NEXT request. - // session/event itself is observe-only: append reentrancy is rejected - // until the current callback list drains. An external reconstructor - // recovers these exact messages by folding the surface over - // events[0..stepStartSeq). + // Snapshot the exact log prefix before step/start: the reconstruction + // boundary. Appends after this synchronous snapshot join the next request. const boundaryMessages = session.deriveMessages() session.append('step/start', { turn, step }) @@ -582,13 +349,7 @@ async function runTurn( break } - // The successful step's finish reason carries forward: a `max-tokens` - // step makes the whole turn end `max-tokens` (the ACP RFC's rule "any - // max-tokens step surfaces as max-tokens"). `stepFinishReason` returns - // `max-tokens` or `undefined`, so a later ordinary step never resets a - // max-tokens turn back to completed, and a never-truncated turn keeps the - // default `completed`. The disposal/abort/error branches above and the - // continuation-window disposal check below override this — they win. + // Preserve max-token completion unless a later disposal, abort, or error wins. const stepReason = stepFinishReason(stepOutcome.finish) if (stepReason) reason = stepReason @@ -610,24 +371,16 @@ async function runTurn( break } - // A forced `continue` may carry model-facing context: record it as - // next-STEP steering (the steering channel), so the continued turn's next - // iteration drains it before its request — the typed twin of the /goal - // step/end-steer pattern. + // A continuation reason becomes next-step steering. if (decision.action === 'continue' && decision.reason) { handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source }) } let shouldContinue = decision.action === 'continue' - // Steering from step/end session-event or continuation listeners (the - // /goal pattern) demands the model see it — it overrides a stop decision; - // the next iteration's drain records it. + // Pending steering overrides an ordinary stop. if (!shouldContinue && handle.inbox.hasSteering) shouldContinue = true - // Terminal policy runs only AFTER the extensible continuation waterfall, - // its optional reason, and late steering have all been folded. Unlike the - // waterfall, this serial seam is monotonic: the first stop bail wins, and - // no later listener or steering override can resurrect the turn. + // Terminal policy is monotonic and runs after ordinary continuation folding. let terminalStop = false try { const stop = await events.serial('agent/turn-stop', turn) @@ -640,19 +393,12 @@ async function runTurn( } if (terminalStop) { terminalStopped = true - // A continuation reason or listener may have queued steering before the - // terminal checkpoint. Discard only steering (never ordinary queued - // prompts) so it cannot become a next step or be re-enqueued as a fresh - // turn by runLoop's late-steering fallback. + // Terminal stop discards steering but preserves ordinary queued prompts. handle.inbox.drainSteering() shouldContinue = false } - // A cancel that landed during the continuation window — after the step's - // AbortController was cleared (setAbort(undefined)) but before the next - // step starts — has no controller to observe it, so the turn-scoped marker - // ends the turn here. cancel() also cleared the steering FIFO, so the - // override above did not re-arm continuation. + // The marker catches cancellation after the step controller was cleared. if (handle.isCancelled()) { reason = { kind: 'aborted', reason: handle.cancelReason() } break @@ -668,19 +414,11 @@ async function runTurn( // Normal / inline-error loop exit: close the turn. closeTurn() } catch (error: unknown) { - // Decide whether this turn opened from the LOG, not a speculative flag. A - // pre-commit validator or acceptance failure leaves no turn/start and owes - // no turn/end, so it propagates to runLoop's backstop. Once turn/start is - // present, this path balances any committed step and records the failure. + // Close only a turn whose start committed to the log. const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn) if (!turnStartLogged) throw error closeStep() - // Choose the close reason. Disposal wins only if no error was already - // reported: a turn disposed mid-step sets reason=disposed in the step-error - // branch (without reporting an error), so preserve disposed rather than - // overwrite it. Otherwise a mid-step throw on a live agent is a real - // failure → failTurn. (errorReported is mutated only inside the failTurn - // closure, which the analyzer can't follow, hence the inline lint-disable.) + // Preserve an established disposal reason; otherwise report the failure. if (handle.isDisposed() && !errorReported) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition reason = { kind: 'disposed' } } else { @@ -689,19 +427,11 @@ async function runTurn( closeTurn() } - // Durability checkpoint: persistence plugins drain write-behind buffers. - // A failing persistence plugin is reported but doesn't kill the agent. - // Through the store's flush (the carrier owner), never a raw parallel. + // Flush through the store-owned durability checkpoint without killing the driver on failure. try { await ctx.sessions.flush(session) } catch (error: unknown) { - // The turn is already closed (turn/end appended above) and flush must run - // AFTER turn/end to be a checkpoint — so there is no in-turn position left - // for a session `error` event. Appending one here would land it after the - // last turn/end, where the persistence backend treats it as a crash tail - // and drops it on resume (the turn-enclosure RFC: every event is turn-enclosed). Report - // the failure via agent/error + the logger only; persistence keeps the - // buffered events for the next flush/dispose, so nothing is lost. + // The turn is closed, so report the failed flush live rather than append outside a turn. const err = toError(error) ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${err.message}`) try { @@ -722,13 +452,12 @@ function drainSteering(agent: ReactLoopAgent, inbox: Inbox, turn: number): boole return messages.length > 0 } -/** One step: build the request from the boundary snapshot + the step's - * header → compose the session prefix if this instance has none yet → log - * the header event the request owes → stream model → record → execute - * tools. The caller assembles the - * system prompt, fires the `agent/pre-step` seam, snapshots the derivation, - * and opens the step BEFORE calling this, so `boundaryMessages` is exactly - * the surface prefix at step/start and already reflects any compaction. */ +/** + * Run one committed step: transform call config, log the request header, build + * the request from the cached prefix plus the step-boundary snapshot, stream and + * record the response, then execute tools. The caller has already assembled the + * prompt, run `agent/pre-step`, snapshotted history, and opened the step. + */ async function runStep( ctx: Context, events: AgentEventDispatch, @@ -743,40 +472,23 @@ async function runStep( ): Promise<{ hadToolCalls: boolean; finish: FinishReason }> { const { session, options } = agent - // Seed the call config: the first request of THIS loop instance seeds from - // current AgentOptions — explicit options always win over the logged - // baseline, which is what keeps fork model-overrides and resume-time - // reconfiguration correct. Later steps seed from the log's folded header, - // which by then is exactly what this instance last logged. - // One deep-cloned, frozen seed serves BOTH the listener chain and the - // no-listener fallback: structuredClone decouples it from the session's - // cached header fold (a raw reference would let a delegating listener - // mutate the fold in place and silently skip the delta log), and the freeze - // makes in-place shaping unrepresentable — a switch is a RETURNED - // replacement, which the header event below records. + // Seed the first request from agent options and later requests from the logged header; + // detach and freeze so listeners must return an attributable replacement. const seedConfig: LlmCallConfig = deepFreeze(structuredClone(transmission.loggedHeader // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log ? session.requestHeader()!.config : { model: options.model ?? '' })) - // Shape the call config: listeners return a replacement to switch model or - // sampling (the seed is frozen — content shaping is not expressible here; - // model-visible content flows through the log channels). The header event - // below records whatever the request ACTUALLY uses, so a listener's switch - // is a logged, reconstructable fact, never silent drift. + // Listener replacements are recorded in the request header before dispatch. const config = await events.waterfall('agent/request', turn, step, seedConfig, () => Promise.resolve(seedConfig)) if (!config.model) { throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`) } - // The session prefix was composed (once per instance) before this step's - // pre-step seam — the caller guarantees it, so the cache is always set here. // 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 snapshots): canonical form, - // recorded before dispatch so the log always explains the request — - // including the session prefix, which no other event carries. + // Record the canonical header, including the otherwise-unlogged prefix, before dispatch. const header = canonicalHeader({ config, ...system ? { system } : {}, @@ -785,11 +497,7 @@ async function runStep( }) recordRequestHeader(session, transmission, header) - // Build and freeze: the request is a pure function of (boundary snapshot, - // logged header) — llm/stream listeners and adapters read it, mutation - // throws. sessionId + frozen is the loop-built marker the dev invariant - // keys on. Message order: header.messagePrefix, then the boundary - // snapshot — the reconstruction equation the invariant recomputes. + // Freeze the logged header plus boundary snapshot; the prefix precedes derived history. const request: GenerateOptions = deepFreeze({ model: header.config.model, messages: [...header.messagePrefix ?? [], ...boundaryMessages], @@ -813,26 +521,16 @@ async function runStep( assembler.push(chunk) } - // Adapters report provider/transport failures one of two sanctioned ways - // (see the StreamChunk contract in dsh-llm): throw from stream() — already - // handled by the caller's try/catch — OR end the stream with a - // finish-error/aborted chunk. finishError() maps the latter to the step - // error to raise (turn ends error/aborted, not a normal completed message). + // Normalize failure finish chunks into the same path as thrown stream errors. const stepError = finishError(assembler.finish) if (stepError) throw stepError if (assembler.finish.kind === 'max-tokens') { let message: Message = withoutToolCalls(assembler.message()) message = withoutToolCalls(await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message))) - // Fire the assistant/message when there is content OR usage: a max-tokens - // step can be cut off with empty content but still carry token accounting, - // and assistant/message is the only host for usage (there is no standalone - // usage event). An empty-content assistant/message is skipped by - // deriveMessages(), so hosting usage on it never injects a spurious assistant - // turn into derived history. + // Preserve usage even when max-token truncation produced no content. if (message.content.length > 0 || assembler.usage) { - // A max-tokens finish is itself a streamed `finish` chunk, so chunkSeqs is - // never empty here — pass the provenance unconditionally. + // The finish chunk guarantees non-empty provenance here. session.append( 'assistant/message', { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) }, @@ -842,20 +540,11 @@ async function runStep( return { hadToolCalls: false, finish: assembler.finish } } - // The step-result waterfall runs BEFORE the session append so the log (the - // source of truth for derived history and replay) records the message that - // tool dispatch actually uses. + // Record the post-waterfall message that tool dispatch uses. let message: Message = assembler.message() message = await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message)) - // Same content-or-usage guard as the max-tokens branch: a step that finishes - // with neither assembled content nor usage (e.g. a bare `stop` finish that - // streamed nothing) records no assistant/message — an empty-content message - // exists only to host usage, and deriveMessages() skips it either way, so - // appending one with no usage would be a pure trace-only row. - // - // sourceEventSeqs records the assistant/chunk provenance, but is omitted when - // no chunks streamed (the surface invariant rejects an empty sourceEventSeqs). + // Empty messages exist only to carry usage; omit empty provenance. if (message.content.length > 0 || assembler.usage) { session.append( 'assistant/message', @@ -864,15 +553,9 @@ async function runStep( ) } - // --- Tool execution (sequential; parallel execution is a TODO) --- - // ToolRegistry.execute converts tool failures (including aborts) into - // isError results, so abort is re-checked around every call here. + // Tool execution stays sequential; recheck abort around each normalized result. const toolCalls = message.content.filter(block => block.type === 'tool-call') - // Per-step buffer of `additionalContext` attached by tools/post-execute - // listeners. Appended as context/message(s) only AFTER every tool/result for - // the step, so a multi-call step keeps tool-call/result adjacency - // (interleaving context between a call's result and the next call's would - // break the pairing the next model request relies on). + // Buffer context until all results are appended to preserve call/result adjacency. const pendingContext: HookContext[] = [] for (const call of toolCalls) { /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ @@ -884,12 +567,8 @@ async function runStep( } catch { parsedArguments = call.arguments } - // TODO(pre-tool-input-rewrite): tools/pre-execute deliberately cannot rewrite - // `arguments` — tool/call (the audit record) and assistant/message (the - // model-history source) are logged BEFORE execute, and live consumers (ACP, - // tool-bash presentation) read the pre-execution args, so an execution-only - // rewrite would desync the UI from what ran. Designing that consistently is - // its own proposed RFC (docs/rfc/proposed/feature/…-pre-tool-input-rewrite.md). + // TODO(pre-tool-input-rewrite): Keep logged history and live presentation aligned; + // see docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md. const result = await ctx.tools.execute({ callId: call.id, name: call.name, @@ -899,33 +578,24 @@ async function runStep( }) session.append('tool/result', { turn, step, - // The correlation id MUST be the loop's authoritative call.id (the - // model-transcript id that deriveMessages turns into toolCallId), NOT - // result.callId — a post-execute waterfall listener returning a - // mismatched id would otherwise orphan the call↔result pairing in the - // next model request. A listener-internal id, if ever needed, belongs in - // a separate diagnostic field, never overloaded onto callId. + // Correlation comes from the immutable execution input; the result does + // not duplicate this authoritative transcript identity. callId: call.id, content: result.content, isError: result.isError, ...result.error ? { error: result.error } : {}, - // The tool's private presentation payload (e.g. a result-time diff), - // persisted so a UI bridge reproduces the card on replay. + // Persist tool-owned presentation data for replay. ...result.meta !== undefined ? { meta: result.meta } : {}, }, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] }) - // Buffer (don't append yet) any post-execute additionalContext for this call. if (result.additionalContext) pendingContext.push(result.additionalContext) - // signal CAN flip during the await above (abort() inside a tool); - // the analyzer can't see through the await boundary. + // The signal may flip while the tool is awaited. /* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) /* v8 ignore stop */ } - // Append buffered post-execute context AFTER every tool/result, preserving - // tool-call/result adjacency across the whole batch. inject() appends into the - // open turn (a context/message at its chronological position). + // Append buffered context after the complete result batch. for (const context of pendingContext) { agent.inject(context.content, { source: context.source }) } @@ -948,13 +618,8 @@ export function lastTurnNumber(session: Session): number { } /** - * Whether a turn is currently open in the session log (a `turn/start` with no - * matching later `turn/end`). Decided from the LOG, not agent status: status - * can be `running` while no turn is open (an `agent/status` listener firing - * before `turn/start`, or the post-`turn/end` flush window before status - * returns to idle), so status is not a reliable open-turn signal. Used by - * `inject()` to choose between appending into an open turn vs. wrapping the - * injection in its own one-shot turn (the turn-enclosure RFC). + * Whether the session log has an unmatched `turn/start`. Agent status is not + * sufficient during pre-start and post-end windows. * @param session - the session whose log is inspected. * @returns true when the log's last turn boundary is a `turn/start` with no matching `turn/end` yet. */ diff --git a/packages/core/agent-loop/src/request-log.ts b/packages/core/agent-loop/src/request-log.ts index 94e121d0f6..ea6141fea9 100644 --- a/packages/core/agent-loop/src/request-log.ts +++ b/packages/core/agent-loop/src/request-log.ts @@ -1,11 +1,7 @@ /** - * Per-loop-instance transmission bookkeeping for the reconstructability - * contract: which header event to append before a request so the session log - * always explains the request (the reconstructability RFC). The loop is - * 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 full changed-header snapshots from there. + * Per-loop-instance request-header bookkeeping for reconstructability. The + * comparison baseline is folded from the session log; a fresh instance anchors + * it with an initial/resume snapshot and later logs full changed snapshots. * * @module dsh-agent-loop/request-log */ @@ -37,18 +33,8 @@ 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 three - * things happens: - * - * 1. This loop instance has not logged a header yet → a full `request/header` - * snapshot anchors the fold: reason `'initial'` when the log has no header - * events at all (a new conversation), `'resume'` when it does (process - * restart, fork seed — the boundary itself is a recorded fact, so the - * snapshot is appended even when nothing changed). - * 2. The header equals the folded baseline → nothing; the log already - * explains this request. - * 3. It differs → a full snapshot with reason `'change'`. + * Append the full header snapshot owed by this request: initial/resume for the + * instance's first request, nothing when unchanged, or change otherwise. * * @param session - the session whose log explains the request. * @param state - this loop instance's bookkeeping (mutated on first log). diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index a990f0d67d..3aad65a70e 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -1,46 +1,6 @@ /** - * Agent interface and event taxonomy. Every plugin programs against the - * `Agent` handle defined here; the concrete implementation lives in - * `@deepseek-ai/dsh-agent-loop`. - * - * Merge-extensible: `AgentOptions` supports declaration merging for - * plugin-specific creation options. - * - * ## Event-domain semantics (the boundary rule) - * - * The harness has three event domains, each with one job: - * - * - **`session/*`** (`@deepseek-ai/dsh-session`) — the DURABLE, replayable FACT - * log. Owns `SessionEventMap`; every entry is JSON-only (no live objects). - * One `session/event` emit per append, plus the `session/flush` parallel - * durability checkpoint. Answers "what happened, durably/replayably." A - * consumer that wants the live transcript subscribes here. - * - **`agent/*`** (this module) — the LIVE runtime surface. Always carries the - * live `Agent`. Two shapes: INTERCEPTION seams (the `agent/prompt-submit`/ - * `agent/request`/`agent/session-prefix`/`agent/step-result`/ - * `agent/turn-continuation` waterfalls and the serial `agent/pre-step` / - * `agent/turn-stop` checkpoints) that mutate/veto, and TRANSIENT emits - * (`agent/status`, `agent/error`, `agent/created`/ - * `agent/disposed`, `agent/queued`, `agent/session-start`) - * that notify with the `Agent` in hand. Turn/step boundaries are NOT here — - * they are durable `session/event` records. Answers "right now, with the agent - * object — intercept or observe." - * - **`tools/*`** (`@deepseek-ai/dsh-tools`) — the tool registry + execution. - * - * **The rule:** a durable, replayable fact is a SessionEvent; a live - * interception or a transient/live-object signal is an `agent`/`tools` Cordis - * event. A turn/step boundary is a durable fact: it lives in the session log - * and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` - * emit. A consumer that needs the `Agent` handle (or its short id) at a boundary - * keeps a session-id→agent map from `agent/created`/`agent/disposed`. - * See `docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md` - * and `docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`. - * - * The interception waterfalls here (`agent/prompt-submit`, `agent/request`, - * `agent/step-result`, `agent/turn-continuation`) each return a typed Decision; - * the terminal serial `agent/turn-stop` returns the stop-only subset. The - * convention is pinned by - * `docs/rfc/implemented/feature/2026-06-30-interception-seams.md`. + * Public agent types and live-runtime events. Durable transcript facts and + * turn/step boundaries remain `@deepseek-ai/dsh-session` events. * * @module @deepseek-ai/dsh-agent/types */ @@ -66,38 +26,18 @@ import type { Session } from '@deepseek-ai/dsh-session' declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { - /** - * The agent this assembly is for. The agent loop passes it on every - * per-step assembly (via its `assembleContextFor(agent)` helper, which - * also sets the `scope` field to the same agent — the layer selector - * `dsh-system-prompt` reads); variable providers project per-agent facts - * from it (`options.model` → `{{model}}`, `session.header.cwd` → - * `{{cwd}}`). Optional because a bare `assemble()` (tests, diagnostics) - * has no agent — providers must tolerate its absence. Never set `agent` - * without `scope`: the assembly would silently miss the agent's scoped - * sections/tools (the dev invariants flag it). - */ + /** Agent for this assembly; absent on diagnostics. When present, `scope` must identify the same agent. */ agent?: Agent } } -/** - * Options an agent is created with. The persona is NOT here: the - * dsh-system-prompt config supplies the global default, and a scoped - * `deployment:persona` section may override it for one agent. - * Merge-extensible: plugins declare extra fields via declaration merging. - */ +/** Merge-extensible agent creation options. Persona belongs to system-prompt sections. */ export interface AgentOptions { /** Model name (must have a registered adapter at call time). */ model?: string } -/** - * Options for {@link Agent.send}/{@link Agent.steer}/{@link Agent.inject}. An - * absent `source` resolves to `{ kind: 'user' }`, so a plugin supplying content - * must label itself here or its message is recorded as a user prompt (see - * {@link HookContext} on why that label is load-bearing). - */ +/** Message options; an omitted source resolves to `{ kind: 'user' }`, so plugins must label their own content. */ export interface SendOptions { source?: MessageSource } @@ -110,54 +50,22 @@ export interface SendOptions { */ export type AgentStatus = 'idle' | 'running' | 'disposed' -/** - * Model-facing context an interception listener wants the agent to SEE on the - * next request — the canonical shape behind every "inject extra context" - * decision ({@link PromptDecision}, {@link PostToolDecision}, - * {@link ContinuationDecision}). It is `agent.inject()`ed as a - * `context/message`, so it carries a REQUIRED {@link MessageSource}: `inject()` - * defaults a missing source to `{kind:'user'}`, which would MISLABEL plugin - * context as a user prompt and corrupt derived history. A bridge sets - * `{kind:'plugin', plugin:'…'}`; a native plugin names itself. Required, not - * optional — the label is load-bearing, never defaulted here. - */ +/** Model-facing context injected by a listener; `source` prevents plugin text from being labeled as user input. */ export interface HookContext { content: ContentBlock[] source: MessageSource } /** - * The decision an {@link Agent} `agent/prompt-submit` waterfall listener returns - * for ONE drained queued message, before it becomes a `user/message`. Maps onto - * the Claude Code `UserPromptSubmit` hook's allow/block + `additionalContext`. - * - * - `allow` proceeds with the prompt; optional `content` REPLACES the prompt - * bytes (a rewrite), and optional `additionalContext` is `inject()`ed as a - * separate `context/message` the next request also sees. - * - `block` drops the prompt (it never becomes a `user/message`); `reason` is - * the durable record of why. The loop appends a `prompt/blocked` session event - * (carrying the original content, source, and `reason`) in place of the - * dropped `user/message`, so the veto survives replay even in a MIXED batch - * where a sibling prompt is allowed. A batch whose EVERY prompt is blocked - * additionally opens a zero-step turn that ends with {@link TurnEndReason} - * `rejected` (so the boundary stays balanced and a UI can render "blocked by - * hook"). + * Prompt interception result. `allow.content` replaces the prompt and + * `additionalContext` becomes a separate context message. `block` records a + * durable `prompt/blocked`; an all-blocked batch ends a zero-step rejected turn. */ export type PromptDecision = | { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext } | { kind: 'block'; reason: string } -/** - * The decision an {@link Agent} `agent/turn-continuation` waterfall listener - * returns. The loop computes the default (`continue` when the step had tool - * calls or steering was injected, else `stop`); listeners override it to - * force-continue (`/goal`, `/loop`) or force-stop (budget guards). - * - * A `continue` may carry a `reason`: model-facing context recorded as next-STEP - * steering within the SAME turn (the loop enqueues it through the steering - * channel, so the continued turn's next step sees it). This is the typed twin of - * the existing "steer from a step/end listener" `/goal` pattern. - */ +/** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */ export type ContinuationDecision = | { action: 'stop' } | { action: 'continue'; reason?: HookContext } @@ -169,47 +77,21 @@ export type ContinuationDecision = */ export type ContinuationStop = Extract -/** - * Why an agent's session lifecycle began, carried by `agent/session-start`. A - * bridge keys its SessionStart hook's matcher on this (Claude Code's - * `startup`/`resume`/`clear`/`compact` source set). `startup` = a fresh create - * (including a seeded/forked create — a seed is NOT a resume); `resume` = a - * persisted session reloaded via `ctx.agents.resume()`. `clear`/`compact` are - * driven by those subsystems (compact = `TODO(compaction)`). - */ +/** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */ export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' -/** - * The agent handle — the surface every plugin (UI, hooks, orchestrators) - * programs against. The concrete implementation lives in - * `@deepseek-ai/dsh-agent-loop` (class `ReactLoopAgent`); nothing outside the loop - * package should depend on the implementation. - */ +/** Public agent handle; the concrete driver belongs to `@deepseek-ai/dsh-agent-loop`. */ export interface Agent { readonly id: AgentId readonly options: AgentOptions readonly session: Session readonly status: AgentStatus - /** - * The agent's scope context (`@deepseek-ai/dsh-scope`, key = this agent). - * Registrations through it — tools, prompt sections/variables, event - * listeners, restrictions — are visible to THIS agent only and unwind when - * the agent is disposed; `agent.ctx.on('agent/…')` listeners fire only for - * this agent's dispatches (zero self-filtering). Service resolution through - * it flows through the loop plugin's dependency surface — handing out - * `agent.ctx` hands out that capability. Live for exactly the agent's - * lifetime: registrations after disposal throw Cordis's INACTIVE_EFFECT. - */ + /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context /** - * Queue a user message. Starts a turn when idle; otherwise waits for the next - * turn. Content and the resolved source are accepted as one detached, - * deeply-frozen lossless-JSON record before notification or enqueue, so - * caller or `agent/queued` listener in-place mutation cannot change later - * log/model input. Throws synchronously when either value is not losslessly - * JSON-serializable; `agent/prompt-submit` may still return an explicit - * replacement. + * Queue detached, frozen lossless-JSON input; starts a turn when idle. + * Invalid input throws synchronously before notification or enqueue. */ send(content: ContentBlock[], options?: SendOptions): void @@ -221,317 +103,137 @@ export interface Agent { steer(content: ContentBlock[], options?: SendOptions): void /** - * Inject in-session context (file-change notices, skill content, cron - * notifications, …): appends a `context/message` session event the next model - * request sees at its chronological position, rendered as tagged synthetic - * context rather than a user prompt. Does not run the model. - * - * Turn-enclosure (the turn-enclosure RFC): an inject while a turn is open joins that turn; - * an inject while idle wraps its `context/message` in a one-shot `injection` - * turn (`turn/start` → `context/message` → `turn/end`) and checkpoints it for - * durability, so every event stays inside a turn and a persistence backend - * never loses a between-turn notice. The idle checkpoint is fire-and-forget - * from this synchronous method, but lifecycle disposal awaits it before - * unregistering the agent or detaching its session. A failing flush is - * reported via `agent/error` (step `0`) and the logger, never thrown into the - * caller. - * - * Live-adapter review has validated the tagged-envelope rendering against - * current DeepSeek behavior; provider-specific mismatches belong in that - * adapter, not in the canonical session vocabulary. + * Append model-facing context without running the model. Idle injection uses + * a one-shot turn and durability checkpoint, while injection during an open + * turn joins it at the current log position. Disposal awaits idle checkpoints; + * flush failures are reported through `agent/error`, not thrown to the caller. */ inject(content: ContentBlock[], options?: SendOptions): void /** - * Cancel ALL pending work for the agent. `cancel()`: - * - * - clears the queued FIFO (un-started prompts never run) and the steering - * FIFO (steering for the cancelled turn is dropped, not re-enqueued); - * - aborts the in-flight step if one is running (the turn ends `aborted`); - * - drops a turn that is about to start (a `cancel()` landing in the - * pre-step window — after a `send()` queued but before the loop flips to - * `running`, or after `running` is emitted but before the first step) so - * that queued prompt does not run and cannot be batched into the cancelled - * turn. - * - * After `cancel()`, `whenIdle()` resolves on the post-cancel quiescent state. - * `cancel()` on an idle agent with nothing queued or running is a safe no-op - * — it does NOT arm anything that would drop a later legitimate prompt. + * Clear queued and steering work, including work waiting to start, and abort + * the active step. The supplied reason is preserved across pre-step and active + * cancellation windows, and `whenIdle()` resolves after cancellation reaches + * quiescence. Idle cancellation is a no-op and does not arm a later cancel. */ cancel(reason?: string): void - /** - * Resolve once the agent has reached quiescence after settling out of - * `running`, or immediately if it is already idle with no queued work. A - * non-owner's quiescence-observation hook: a consumer that does NOT own the - * agent's lifecycle awaits this to proceed only after queued/running work has - * fully stopped, rather than returning while the driver is still streaming or - * about to start a queued turn — without itself tearing the agent down. (A - * lifecycle OWNER does not need it: `AgentHandle.dispose()` already awaits the - * loop-exit promise directly as part of stopping and unregistering. So this is - * for a non-owning observer — e.g. a test awaiting a turn to settle, or a - * monitor — that wants the settle signal but must not dispose the agent.) - * - * "Quiescence", not merely "status changed": a disposed agent emits - * `agent/status('disposed')` from inside its disposer, BEFORE the driver loop - * has unwound — so `whenIdle()` resolving on `disposed` must wait for the loop - * to actually exit (the implementation chains the loop-exit promise), not just - * observe the status flip. A mid-step disposal that never reaches `idle` still - * unblocks the await this way. - */ + /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ whenIdle(): Promise - // Subagent delegation is realized on top of this interface by the - // `@deepseek-ai/dsh-subagent` seam, not by a method here: a backend creates - // the child through `ctx.agents.create` (fork seeds the child Session with a - // balanced prefix of the parent's log via `CreateAgentOptions.seed`; spawn - // starts fresh) and drives it as an ordinary Agent handle, so steer() and - // event subscription work uniformly. See docs/core-data-structures/subagent.md. } declare module 'cordis' { interface Events { // ---- lifecycle (emit) ---- /** - * An agent's fully composed scoped world was published in the - * {@link AgentRegistry}. Its session is already live in the session store. - * Setup is composition-only by contract; the subsequent - * `agent/session-start` boundary is the first supported place to inject or - * queue startup work. A synchronous listener throw - * vetoes publication and rollback emits the matching disposal edges; - * returned-promise rejection is observed and logged but cannot - * retroactively veto this synchronous boundary. A synchronous listener - * that requests the advanced registry detach does not remove the entry - * immediately: removal and the paired `agent/disposed` edge wait until the - * creation dispatch unwinds, so no later creation listener observes a - * disposal that preceded its own creation callback. + * A fully configured agent and live session were published. Setup is + * composition-only; `agent/session-start` is the first startup-driving seam. + * Synchronous listener failure vetoes publication, while returned-promise + * rejection is reported. Detach requested during dispatch waits until every + * creation listener has observed the stable entry. * @param agent - the newly registered agent with its live session and completed setup. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ 'agent/created'(this: Scoped, agent: Agent): void /** - * An agent was removed from the registry. The concrete AgentLoop lifecycle - * emits this only after its driver and any in-flight turn reach quiescence; - * a custom agent registered through the public registry owns its own driver - * contract, which the registry cannot infer. Ordered teardown may still be - * detaching the session and unwinding scoped registrations when this runs. + * An agent left the registry; AgentLoop emits this after driver quiescence + * but before session detachment and scoped-registration unwind. Custom + * registry users own their driver-ordering contract. * @param agent - the exact agent removed from the registry. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ 'agent/disposed'(this: Scoped, agent: Agent): void /** - * Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive - * lifecycle off this transition, never off a status you just requested — - * `send()` does not flip status to `running` before it returns. + * Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does + * not enter `running` synchronously; drive lifecycle from this event. * @param agent - the agent whose status flipped. * @param status - the status just entered (the transition's destination). - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void /** - * A message entered the agent's inbox (queued or steering). Content and the - * resolved source are the detached, deeply-frozen values retained by the - * inbox. `source` has defaults applied and is not the caller's raw options. + * Detached, frozen content entered the agent's inbox. Source defaults have + * already been applied, so these are the exact values retained for the log. * @param agent - the agent whose inbox received the message. * @param content - the accepted content blocks retained by the inbox. * @param info - the accepted source plus whether it entered as steering. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ 'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void // ---- session lifecycle (emit) ---- /** - * The agent's session lifecycle began, fired once before its first turn. - * `source` says why ({@link SessionStartSource}: fresh startup, a resumed - * persisted session, …). A pure NOTIFICATION (emit, not waterfall): a - * listener cannot veto by returning a decision or throwing. A listener that - * wants to seed context does so via `agent.inject()` (a `context/message` the - * first request sees). A lifecycle owner can still dispose its structural - * ownership edge during this notification; publication rechecks liveness and - * then aborts before the driver starts. + * The session lifecycle began, once before the first turn. Use + * `agent.inject()` to seed model-facing context. This is a notification, not + * a veto; disposal requested by a lifecycle owner is rechecked before the + * driver starts. * @param agent - the agent whose session lifecycle began. * @param source - why the session started (fresh startup, resume, …). - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ 'agent/session-start'(this: Scoped, agent: Agent, source: SessionStartSource): void - // Turn and step boundaries are NOT mirrored as agent/* emits: a consumer - // that needs them reads the durable `turn/start`/`turn/end`/`step/start`/ - // `step/end` session events off the `session/event` feed (the session log is - // the live transcript feed). See the module doc's three-domain rule and the - // "remove agent boundary mirror events" RFC. + // Turn and step boundaries are durable session events, not agent events. // ---- step/request extension seams (serial + waterfall) ---- /** - * Awaited pre-step surface-mutation checkpoint, fired once per step AFTER - * `turn/start` (and after the prior step closed) but BEFORE this step's - * `step/start` — so anything a listener appends lands OUTSIDE the step, - * between `turn/start`/`step/end` and the upcoming `step/start`. `step` is - * the number of the step about to start. The loop awaits - * `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then - * opens the step and derives the request history ONCE from whatever the - * surface now holds. This is where compaction belongs: it mutates the session - * surface in place (shadowing an older range with a summary node) with its - * log-only `compact/*` records cleanly outside any step, and the single - * subsequent derive reflects the mutation — so there is no double-derive and - * no listener can see (or be expected to act on) an assembled `messages` - * array that does not exist yet. - * - * Serial (awaited in registration order), not a waterfall: a listener - * mutates the surface as a side effect; there is nothing to transform, but - * the loop must wait for the mutation to complete before opening the step - * and deriving. Cordis `serial` bails early if a listener returns a bail - * value; this event is typed and documented as `void`, so listeners must not - * return a semantic veto value. `fullSystemPrompt` is the assembled prompt a - * listener needs to measure pressure (the system prompt counts toward the - * budget), and `sessionPrefix` is the instance's composed - * {@link agent/session-prefix} product for the same reason — every request - * carries it in front of the derived history, and it is composed BEFORE - * this seam fires precisely so a pressure gate counts the prefix the - * request will actually send (never a stale logged one). `signal` cancels - * any in-flight work a listener starts (e.g. a - * summarization model call). - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. - * @param agent - the agent about to open the step. - * @param turn - the already-open turn this step belongs to. - * @param step - the number of the step about to start. - * @param fullSystemPrompt - the assembled prompt, for measuring token pressure. - * @param sessionPrefix - the instance's frozen session prefix, for the same measurement. - * @param signal - aborts in-flight listener work when the turn is torn down. + * Awaited serial checkpoint for session-surface mutation after prompt + * assembly and before `step/start`; appends land outside the pending step. + * The loop derives history once afterward, so compaction records and + * replacements are included without rewriting an assembled request. The + * prompt and prefix are the exact pressure inputs for that request, and + * `signal` cancels listener work. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param agent - the agent opening the step. + * @param turn - the open turn number. + * @param step - the pending step number. + * @param fullSystemPrompt - the assembled prompt. + * @param sessionPrefix - the frozen request prefix. + * @param signal - the turn abort signal. * @mode serial */ - // TODO: `fullSystemPrompt`/`sessionPrefix` are a smell on a generic - // per-step seam — compaction - // is their only consumer, so a wide event carries payloads just one listener - // reads. Revisit if no second consumer appears: e.g. hand listeners a lazy - // prompt provider, or move token-pressure measurement behind a - // compaction-specific seam instead of the shared pre-step checkpoint. + // TODO: Move prompt-pressure inputs behind a compaction-specific seam if no second consumer appears. 'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise | void /** - * Waterfall: decide what happens to ONE drained queued message before it - * becomes a `user/message` — allow (optionally rewriting the prompt bytes or - * attaching `additionalContext`) or block it. Fires inside the already-open - * turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook. - * Call `next()` to delegate to the default (allow unchanged), or return a - * {@link PromptDecision} without calling `next()` to short-circuit. + * Allow, rewrite, or block one drained prompt before it becomes a user + * message. Call `next()` for the unchanged default. * @param agent - the agent draining its inbox. * @param content - the drained message's blocks, as queued. * @param source - the message's resolved source. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ 'agent/prompt-submit'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise /** - * Waterfall: shape the step's call configuration — model switching, - * sampling overrides — by returning a replacement {@link 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 {@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 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 {@link agent/pre-step}. Call `next()` to - * delegate, or return an {@link LlmCallConfig} without it to - * short-circuit. + * Replace the frozen call configuration. Model-visible content must use + * logged channels; this seam cannot mutate messages. Injection here joins + * the next request because the current step boundary is already fixed. * @param agent - the agent making the model call. * @param turn - the open turn number. * @param step - the step whose request this is. * @param config - the config the loop would use (frozen); return a replacement to switch. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ 'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise /** - * Waterfall: compose the SESSION PREFIX — request-only messages placed in - * front of the ENTIRE derived history (directly after the provider's - * system slot) on every request this loop instance sends. Fired ONCE per - * loop instance, lazily before its first step's {@link agent/pre-step} - * seam — BEFORE the pre-step so a token-pressure gate (compaction) counts - * the prefix this instance will actually send, never a previous - * instance's logged one. The composed - * result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the - * instance's anchoring `'initial'`/`'resume'` header snapshot, and reused - * verbatim for every subsequent request — never recomputed mid-session, - * so the provider prefix cache holds by construction (a process restart - * or `ctx.agents.resume()` is a new instance: it recomposes, and any - * drift lands attributably on the `'resume'` snapshot). Composition runs - * outside the step, before the boundary snapshot: a composing listener's - * session append joins the CURRENT request's derived history. A - * composition interrupted by a cancel/dispose landing inside the - * waterfall is discarded — never cached, logged, or sent — and the next - * turn recomposes under a live signal, so an abort-aware listener's - * degraded fallback cannot leak into later requests. - * - * This is the home for session-stable openers the model must always see - * but that must NOT become durable history — a skills catalog, an - * AGENTS.md digest, a workspace baseline: `Session.deriveMessages()` - * never returns the prefix, and the header events are its only durable - * record, so the request stays reconstructable from the log. Content - * that CHANGES mid-session belongs in the append-only history channels - * instead — `agent.inject()`, a `tools/post-execute` decision's - * `additionalContext`, prompt-submit `additionalContext` — each a - * durable `context/message` paid once and prefix-cached thereafter. - * - * The seed is a frozen empty list; a contributing listener returns a NEW - * array — never an in-place push. The canonical contribution is a - * PREPEND, `[mine, ...await next()]`: the waterfall unwinds - * innermost-first (the LAST-registered listener's `next()` resolves - * first), so prepending yields registration order on the wire, and every - * plugin using it composes deterministically. The append form - * `[...await next(), mine]` is legal but places a contribution AFTER - * every later-registered plugin's — reverse registration order when all - * contributors append. Call `next()` to - * delegate, or return a list without it to short-circuit. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Compose request-only messages placed before derived history. The frozen + * result is computed once per loop instance, logged on its anchoring request + * header, and reused so the provider prefix remains stable. Interrupted + * composition is discarded. Composition precedes the first `agent/pre-step` + * and request boundary, so listener appends join the current request and + * pressure accounting sees the composed prefix. Changing context belongs in + * history; contributors should prepend to `await next()` to preserve registration order. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @param agent - the agent whose session prefix is being composed. - * @param prefix - the frozen empty seed; return an extended replacement to contribute. - * @param signal - aborts in-flight listener work (e.g. a discovery scan) when the step is torn down. + * @param prefix - the frozen seed; return an extended replacement. + * @param signal - aborts composition when the step is torn down. * @mode waterfall */ 'agent/session-prefix'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise @@ -542,47 +244,27 @@ declare module 'cordis' { * @param turn - the open turn number. * @param step - the step that produced the message. * @param message - the assistant message as assembled from the stream. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ 'agent/step-result'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, next: () => Promise): Promise /** - * Waterfall: override the turn-continuation decision via a typed - * {@link ContinuationDecision}. The loop's `defaultDecision` is `continue` - * when the step had tool calls or steering was injected, else `stop`. - * Listeners force-continue (`/goal`, `/loop` — optionally attaching a - * `reason` recorded as next-step steering) or force-stop (budget guards). - * Call `next()` to delegate to the default, or return a decision to override. + * Override whether the turn continues. The default continues after tool + * calls or steering and stops otherwise; a continue reason becomes steering. * @param agent - the agent deciding whether to run another step. * @param turn - the turn being continued or stopped. * @param defaultDecision - what the loop would do absent an override. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ 'agent/turn-continuation'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise /** - * Serial terminal-stop checkpoint after the ordinary - * `agent/turn-continuation` waterfall, any `continue.reason`, and the - * pending-steering continuation override have been folded. A listener - * returns `{ action: 'stop' }` to make this turn terminal, or `undefined` - * to abstain. Terminal stop is monotonic: listener order and steering - * cannot resume the turn, and pending steering is discarded rather than - * becoming another step or turn. + * Monotonic terminal-stop checkpoint after continuation and steering are + * folded; a stop remains authoritative through turn close and flush: + * steering queued in that window is discarded, while ordinary sends survive. * @param agent - the agent whose composed continuation outcome may be stopped. * @param turn - the turn at its terminal-stop checkpoint. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode serial */ 'agent/turn-stop'(this: Scoped, agent: Agent, turn: number): ContinuationStop | undefined @@ -595,11 +277,7 @@ declare module 'cordis' { * @param turn - the turn in which the failure surfaced. * @param step - the step at which the failure surfaced. * @param error - the failure, verbatim. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ 'agent/error'(this: Scoped, agent: Agent, turn: number, step: number, error: Error): void diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 1072809fa1..3f11876c3b 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -75,7 +75,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) ### Extension points - Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log. -- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The constructor reads each seed entry once and uses the same one-pass lossless-JSON snapshot and exact surface-metadata shape checks as `append`, then enforces contiguous seqs and deep-freezes every accepted record; a stateful caller, exotic nested value, marker-less or malformed surface event, metadata on a non-surface event, or retained seed reference therefore cannot silently change the reconstructed history. Broader turn-enclosure checks stay in `dsh-invariants` and persistence repair. Ordinary live-session forks use `ctx.sessions.fork(source, boundary?, childSessionId?)`, where `boundary` is the inclusive source event seq to fork through. +- Replay/fork: `create(id, { seed })` validates and freezes a contiguous log and rebuilds its surface. `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage. - Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint. ## Model Experience diff --git a/packages/core/session/src/tool-pairing.ts b/packages/core/session/src/tool-pairing.ts index 8f1b35708f..e7eaae8eaa 100644 --- a/packages/core/session/src/tool-pairing.ts +++ b/packages/core/session/src/tool-pairing.ts @@ -1,36 +1,7 @@ /** - * Tool-pairing balance over a session's SURFACE: is a given cut point in the - * surface a safe edge for a collapsed region (e.g. compaction)? - * - * The invariant a consumer needs: a collapsed region must never separate an - * `assistant/message`'s `tool-call` blocks from their answering `tool/result`s - * — that would leave the rehydrated transcript with a dangling tool-call or an - * orphaned tool-result, which every provider rejects. (This is the - * compaction-time mirror of the crash-recovery imbalance that - * {@link interruptedTurnClosers} repairs on load.) Steps were once used as a - * proxy for this bracketing, but a compaction REWRITES the surface — it lands a - * replacement node at a high log seq whose SURFACE position is the head — so a - * scan over the LOG's `step/*` markers mis-reads such a node's neighbours. The - * pairing the invariant actually protects lives in the surface nodes' own - * content (a `tool-call` block's id, a `tool/result`'s `callId`), which travels - * with the node through any reshaping, so alignment is decided over the surface - * directly. - * - * A **cut** is a gap between two adjacent surface nodes (named by the node it - * sits immediately before), or the after-tail gap (`null`). Walking the surface - * head→tail and assigning each node a delta — `+1` per `tool-call` block on an - * `assistant/message`, `-1` per `tool/result`, `0` otherwise — the depth at a - * cut is the number of still-unanswered tool calls before it. A cut is - * **balanced** when that depth is `0`. A region `[start..end]` is safe to - * collapse iff BOTH its edges are balanced cuts: the cut before `start` and the - * cut after `end`. Nodes that belong to no step (a pre-step `user/message`, an - * inter-step `steering/message`, an injection `context/message`) carry no - * pairing, contribute `0`, and so are free boundaries — exactly as before, but - * now as a consequence of the balance rather than a special case. An open - * trailing step (an assistant whose `tool/result`s have not landed yet) keeps - * the depth positive through the tail, so no cut inside it is balanced — the - * old explicit open-step check falls out of the same counter. - * + * Tool-pairing balance over a session surface. Compaction changes surface + * positions, so safe cuts are derived from tool-call/result content on the + * surface rather than step markers in the append-only log. * @module @deepseek-ai/dsh-session/tool-pairing */ @@ -56,33 +27,14 @@ function nodeDelta(event: SessionEvent): number { } /** - * Whether the surface prefix ending at the given cut has BALANCED tool-call / - * tool-result brackets — i.e. every `tool-call` block on the surface before the - * cut has its answering `tool/result` before the cut too, so the cut is a safe - * edge for a collapsed region (it cannot split an assistant↔result pair). + * Check that a surface cut does not split a tool call from its result. A region + * is safe to collapse only when both edge cuts return true. * - * `nodes` is the surface sequence list in head→tail order (e.g. - * `session.surface.nodes`); `events` is the session log, used to look each - * 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 (`nodes[index + 1]`), or `null` when `end` is the tail — - * for the cut after `end`. - * - * @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 - * (the unanswered-call depth at the cut is zero). - * @throws if the surface prefix drives the unanswered-call depth negative — a - * `tool/result` with no preceding open `tool-call` on the surface. That is a - * corrupt surface (a structural invariant violation), surfaced loudly here - * rather than silently mis-classifying a boundary. + * @param nodes - surface event sequence numbers in head-to-tail order. + * @param events - the session log indexed by those sequence numbers. + * @param beforeSeq - event immediately after the cut; null or an absent seq means after-tail. + * @returns whether every call before the cut is answered before it. + * @throws if a result appears without a preceding open call. */ export function isToolPairingBalanced( nodes: readonly number[], @@ -99,7 +51,6 @@ export function isToolPairingBalanced( 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 - // surface): the whole-surface prefix is balanced iff depth returned to 0. + // A missing cut sequence means the after-tail boundary. return depth === 0 } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index c2997c0b47..0b45a2a57d 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -14,33 +14,17 @@ export function SessionId(id: string): SessionId { } /** - * The on-disk session format version, stamped into every newly-written - * {@link SessionHeader} and enforced by every persistence backend on load. The - * single source of truth for the version — write sites and the load-time check - * all read it. - * - * It is **`0`** deliberately: while the harness is unreleased the on-disk format - * is **unstable / pre-release, with no compatibility implied**. Breaking changes - * to the persisted {@link SessionEventMap} shape (folding fields onto an event, - * removing a variant, …) happen freely and do NOT bump this — v0 absorbs all - * pre-release churn, and a backend simply REJECTS any log not at v0 (there is no - * migration; no persisted user data exists to preserve). A real, monotonically - * bumped version policy begins at the first tagged release, when a specific - * format boundary becomes worth distinguishing. + * The on-disk session format version, stamped into every newly-written {@link SessionHeader} + * and enforced by every persistence backend on load. The single source of truth for the + * version — write sites and the load-time check all read it. + * While the harness is unreleased it is pinned at `0`: no compatibility is + * implied, incompatible logs are rejected, and no migration is provided. A + * monotonic version policy starts with the first tagged release. */ export const SESSION_FORMAT_VERSION = 0 /** - * Immutable session metadata — written once at creation and never rewritten. - * {@link Session} enforces that contract at runtime: it validates and detaches - * the accepted scalar fields, requires this header's id to match the session - * id, and deep-freezes the published record. - * - * Kept SEPARATE from the event log deliberately: format-version, cwd, and - * lineage are storage concerns, not conversation events, so they stay out of - * {@link SessionEventMap} and never reach `deriveMessages()`. Every reference - * system (pi's `version: 3` header, Codex's `SessionMeta`, Claude Code's tail - * metadata) writes such a header. + * Immutable validated storage metadata, kept outside the conversation event log. */ export interface SessionHeader { /** @@ -58,13 +42,8 @@ export interface SessionHeader { /** The session this one was forked from (seed lineage), if any. */ readonly parentSession?: SessionId /** - * How many leading events were INHERITED via a seed rather than produced by - * this session — the seed boundary. Set when a fork seeds a child with a - * prefix of the parent's log (= the seeded prefix length); absent/0 means the - * session produced all its own events. Persisted so a reload reconstructs the - * boundary instead of re-deriving it from the full stored log, and so a replay - * harness can skip the inherited prefix when deriving the child's OWN script - * (the seeded events are the parent's, not this child's model calls). + * How many leading events were inherited through a seed. Persisting this + * boundary lets resume and replay distinguish parent history from child work. */ readonly seedLength?: number } @@ -78,17 +57,8 @@ export interface CreateSessionOptions { /** Events to seed the new session with (replay/fork). */ readonly seed?: readonly SessionEvent[] /** - * Creation metadata. The store reads this plain record and each accepted - * field once, then fills in `version`/`id` and defaults - * `createdAt` to now; the caller supplies the storage-level fields (validated - * absolute `cwd`, `parentSession` lineage, the seed boundary `seedLength`, and - * — when reconstructing a persisted session — the original `createdAt` to - * preserve it). - * - * `seedLength` is EXPLICIT, not inferred from `seed.length`: a reconstruction - * (resume/load) seeds the WHOLE stored log, so its `seed.length` is the full - * length, not the original boundary — the caller must pass the persisted - * boundary back. A fresh fork passes its actual seeded-prefix length. + * Storage metadata read once before publication. `seedLength` is explicit + * because a resumed seed contains the full stored log, not only its inherited prefix. */ readonly meta?: { readonly cwd?: string @@ -119,21 +89,7 @@ export interface TurnTriggerMap { export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap] /** - * Why a turn ended. - * Merge-extensible sum type. - * - * `max-tokens` mirrors the model-call `FinishReasonMap` variant (DeepSeek's - * `length`): the turn ended because a step hit the output-token ceiling, not - * because the model chose to stop. The agent-loop surfaces it via the rule - * "any `max-tokens` step in the turn makes the turn end `max-tokens`" (a - * continuation plugin can run further steps after one, but the cut-short fact - * still wins). It is distinct from `completed` so a consumer (e.g. the ACP - * bridge mapping to `StopReason: 'max_tokens'`) can tell a clean stop from a - * truncated one. The next variants to add — when an adapter/loop first emits - * them — are `refusal` and `max_turn_requests` (both named by the ACP RFC as ACP - * stop reasons); no current adapter produces a `refusal` finish (unknown - * DeepSeek finish reasons collapse to `error`), so it is deliberately omitted - * until one does. + * Why a turn ended. Merge-extensible sum type. */ export interface TurnEndReasonMap { completed: { kind: 'completed' } @@ -146,26 +102,16 @@ export interface TurnEndReasonMap { */ error: { kind: 'error'; step: number; message: string; code?: string } disposed: { kind: 'disposed' } + /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } /** - * The turn's entire prompt batch was BLOCKED before any step ran — every - * drained queued message was vetoed by an `agent/prompt-submit` listener (a - * hook). The turn still opened (so the boundary stays balanced and the block - * is a durable in-turn fact), but ran zero steps. `reason` carries the block - * message from the vetoing decision. Distinct from `aborted` (a user-driven - * cancel) and `error` (a failure): the prompt was rejected by policy, not - * interrupted or broken. A UI renders it as "prompt blocked by hook". + * Policy blocked every prompt before the first step. The zero-step turn still + * records a balanced durable boundary and the veto reason. */ rejected: { kind: 'rejected'; reason: string } /** - * The turn never ended on its own: the process crashed mid-turn and a - * persistence backend later closed the orphaned (open) turn on reload so the - * log stays balanced. SYNTHESIZED by the backend's crash-recovery repair — no - * loop ever emits this. Its events are real (they were durably appended before - * the crash) and are PRESERVED, not discarded: a single turn can be huge in a - * long-horizon task (many steps, large tool output), so truncating it would - * lose real work. The marker records that the turn was cut short, not that the - * model completed it. See the session-persistence RFC. + * A persistence backend closed a crash-orphaned turn on reload. The loop never + * emits this marker, and the events recorded before the crash remain intact. */ interrupted: { kind: 'interrupted' } } @@ -192,14 +138,9 @@ export interface TodoItem { } /** - * The request header: everything about an LLM request besides its derived - * message history — the call configuration plus the rendered system prompt, - * tool schemas, and the session prefix. Logged session state (the - * reconstructability RFC): 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. + * Logged request state outside derived history: call config, system prompt, + * tools, and prefix. The latest full `request/header` snapshot reconstructs it; + * canonical empty optional fields are absent. */ export interface EpochHeader { /** The conversation's call configuration (model + sampling scalars). */ @@ -227,24 +168,10 @@ export interface EpochHeader { export type RequestHeaderReason = 'initial' | 'resume' | 'change' /** - * The session event vocabulary — the append-only source of truth for an - * agent's whole interaction history. The LLM message history is *derived* - * from this log; nothing else is authoritative. Replay = re-derive from the - * same events; trace/telemetry = subscribe to the log. - * - * Merge-extensible: plugins declare extra event types via declaration merging - * (e.g. the compaction plugin adds `'compact/start'`, `'compact/summary'`, - * `'compact/end'`). - * - * Durability contract (what a persistence backend relies on): the durable log - * persists every event verbatim, INCLUDING `assistant/chunk` — `seq` must stay - * contiguous (`seq = log.length`), so chunks cannot be filtered out of the - * canonical log. All `event.data` must be JSON-serializable — `Session.append` - * (and the seed path in the constructor) enforces this at the source (throwing - * on non-serializable data), so a bad event never enters the log and - * `session.events` always equals what a backend can persist. Adding a new event - * type that carries non-serializable data, or that breaks the turn/step nesting - * the invariants plugin checks, is a breaking change to the on-disk format. + * The merge-extensible, append-only source of truth for an agent interaction. + * Message history is derived from this log. Every event is lossless JSON and + * sequence numbers stay contiguous, including raw chunks, so persistence can + * store the canonical log verbatim. */ export interface SessionEventMap { /** @@ -267,14 +194,8 @@ export interface SessionEventMap { /** A user-visible prompt (queued message drained at turn start). */ 'user/message': { content: ContentBlock[]; source: MessageSource } /** - * A queued prompt an `agent/prompt-submit` listener VETOED — the durable - * record of a blocked prompt and why. Appended in place of the `user/message` - * the prompt would have become, so the block survives replay even in a MIXED - * batch where another queued prompt is allowed (there the turn does not end - * `rejected`, so the boundary reason alone would not preserve it). `content` - * is the original prompt the listener rejected; `reason` is the veto text - * ({@link PromptDecision} `block.reason`). NOT a {@link SurfaceEventType}: a - * blocked prompt produces no LLM message and never reaches `deriveMessages()`. + * Durable record of a prompt veto and its reason. It is log-only: the blocked + * prompt never enters the model-visible surface, including in a mixed batch. */ 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } /** @@ -310,30 +231,11 @@ export interface SessionEventMap { 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown } /** Steering content injected between steps of a running turn. */ 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } - /** - * The agent's whole todo list, carried as a full snapshot and replaced - * wholesale on each write — the current list is the most recent `todo/write` - * (last-write-wins on replay, no fold). Appended by an owning agent via - * `session.append('todo/write', { todos })`. - * - * NOT a {@link SurfaceEventType}: it produces no LLM message and never reaches - * `deriveMessages()`, so it carries no `surfaceOp` and stays off the surface — - * it is durable, replayable UI state, distinct from the conversation history. - * It is a `SessionEventMap` member riding the existing `session/event` emit, - * not a first-class Cordis `interface Events` notification, so it has no - * cordis-catalog row. - */ + /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ 'todo/write': { todos: TodoItem[] } /** - * Full snapshot of the {@link EpochHeader} the NEXT request is built under, - * with the {@link RequestHeaderReason} it was recorded whole. Appended by - * the loop inside the step, before dispatch, on a loop instance's first - * request-building step (`'initial'`/`'resume'`) or when a 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). + * Full header for the next request, appended inside its step before dispatch. + * It is log-only; the latest snapshot reconstructs the request header. */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } } @@ -381,16 +283,8 @@ export type SurfaceOp = | { op: 'replace'; start: number; end: number } /** - * Surface metadata passed to {@link Session.append}. - * `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). - * - * Required for {@link SurfaceEventType} events — every message-producing event - * MUST declare how it enters the surface, because the surface is the sole - * source of derived history. Non-surface event types (`turn/start`, - * `assistant/chunk`, `error`, …) cannot carry surface metadata. + * Surface placement and provenance for {@link Session.append}. Required on + * message-producing events and forbidden on log-only events. */ export interface SurfaceIntent { surfaceOp: SurfaceOp diff --git a/packages/core/session/tests/tool-pairing.spec.ts b/packages/core/session/tests/tool-pairing.spec.ts index 8e7b8b4a03..6f0bed7b63 100644 --- a/packages/core/session/tests/tool-pairing.spec.ts +++ b/packages/core/session/tests/tool-pairing.spec.ts @@ -4,24 +4,8 @@ import { Session, SessionId, isToolPairingBalanced } 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 - * the surface (a gap before a given surface node, or the after-tail gap) is a - * safe edge for a collapsed region (compaction): a region must never split an - * `assistant/message`'s tool-calls from their `tool/result`s. A cut is balanced - * when no unanswered tool-call sits before it on the surface. Nodes belonging to - * no step (pre-step user message, inter-step steering, injection context) are - * pairing-neutral, so their cuts are free boundaries. - * - * The fixtures are built through a real {@link Session} so the 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. - * - * Builders mirror the agent loop's real append order: queued user messages land - * BEFORE `step/start`; within a step the order is `assistant/message` then - * `tool/result`(s); injection turns are a bare `turn/start → context/message → - * turn/end` with no step. + * Tool-pairing cut coverage over real session surfaces, including replacement + * nodes whose surface order differs from append-log order. */ const SURFACE = { surfaceOp: 'append' as const } @@ -182,10 +166,8 @@ describe('isToolPairingBalanced — multiple tool calls in one assistant message }) describe('isToolPairingBalanced — a mid-step injection context/message', () => { - // A background task-done inject() lands a context/message INSIDE an open step, - // between the assistant (with a tool-call) and its tool/result. It is - // pairing-neutral, so the cut on EITHER side of it is unbalanced (the call is - // still open across it) — it is NOT a free boundary in this position. + // The injected context is pairing-neutral, but both adjacent cuts remain + // unbalanced because the tool call is still open across them. function midStepInjection(): Session { const s = new Session(SessionId('mid-inject')) s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -236,11 +218,8 @@ describe('isToolPairingBalanced on an injection turn (no step)', () => { }) describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace op', () => { - // The case the log-position scan got wrong. After a compaction, a replacement - // user/message lands at a HIGH log seq but sits at the SURFACE head, beside - // the still-open step whose events follow it in the log. It carries no - // tool-call/result pair (just summarized prose), so it must be a balanced cut - // on BOTH sides regardless of its log neighbours. + // A replacement checkpoint has a high log seq but sits at the surface head; + // its cuts are balanced regardless of later raw-log neighbors. function checkpointHeadedSession(): Session { const s = new Session(SessionId('checkpoint')) // A closed turn with a tool step → surface [u1, asst(call), result]. @@ -275,9 +254,8 @@ describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace const s = checkpointHeadedSession() const nodes = s.surface.nodes 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. + // The checkpoint heads the surface while the open step's assistant follows + // it in append-log order. const laterSurfaceInLog = s.events.find( e => e.seq > checkpointSeq && nodes.includes(e.seq), ) @@ -291,10 +269,7 @@ describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace }) it('end cut after the head checkpoint is balanced (it carries no tool pair)', () => { - // This is the exact assertion the log-position scan failed: the forward log - // scan from the checkpoint reached the open step's assistant/message and - // wrongly reported mid-step. The surface balance sees a neutral node whose - // following cut closes no open call. + // The neutral checkpoint closes no open call at its following surface cut. const s = checkpointHeadedSession() 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 19bb95bd12..19ec9fbc3e 100644 --- a/packages/llm/llm/src/call-config.ts +++ b/packages/llm/llm/src/call-config.ts @@ -1,14 +1,8 @@ /** - * The call configuration of a conversation and its comparison/freeze - * utilities. `LlmCallConfig` is the non-content third of the request header - * (see `EpochHeader` in dsh-session): everything about a request besides its - * message content that can undermine provider KV-cache reuse — `model` - * selects the cache namespace outright, and the sampling scalars are treated - * 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` snapshot. - * + * Conversation call configuration and freeze utilities. Model and sampling + * values are request-header state that can affect cache reuse; request + * waterfalls replace them and the loop logs changed snapshots instead of + * allowing silent per-call drift. * @module dsh-llm/call-config */ @@ -39,16 +33,9 @@ export function callConfigEquals(a: LlmCallConfig, b: LlmCallConfig): boolean { } /** - * Deep-freeze a value in place so any later mutation throws (ESM code runs in - * strict mode), and return it. The loop freezes every request it builds - * before dispatch — `llm/stream` listeners and adapters read the request, - * never rewrite it, so the wire bytes cannot silently desync from what the - * session log reconstructs. Guards against cycles with a WeakSet: loop-built - * requests hold `structuredClone`d JSON-validated session data, but the - * helper accepts arbitrarily constructed values. One exemption: an - * `AbortSignal` is never entered or frozen — it is the request's live - * cancellation channel, and freezing one breaks `AbortController.abort()` - * outright (Node stores the aborted flag as an own property of the signal). + * Deep-freeze a value in place, guarding cycles, so later mutation throws. + * {@link AbortSignal} objects are deliberately skipped because they are the + * request's live cancellation channel and freezing them breaks abort. * @param value - the value to freeze in place. * @returns the same value, frozen. */ diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 523092bf09..945aad156b 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -1,32 +1,14 @@ /** - * The ACP snapshot suite factory (REPLAY by default, keyless). A suite is a - * scenario table plus a snapshots directory: each scenario under - * `//` ships an `input.json` (the client stdin script) and - * a `session.jsonl` fixture; replay boots the real agent subprocess - * (./harness.ts), drives it, and diffs the normalized stdout transcript - * against the committed `stdout.golden.jsonl`. For model scenarios it ALSO - * checks the re-persisted session log — against the `session.jsonl` fixture - * itself, not a separate golden: the fixture doubles as the replay source - * (recorded scenarios) and the expected produced log (both sides normalized - * before comparing). + * Keyless-by-default ACP snapshot suite factory. Each scenario drives the real + * subprocess and compares normalized stdout; comparable session fixtures are + * both replay input and expected output. Record mode refreshes reproducible + * model scenarios from the live API, while refresh mode replays committed + * scripts and rewrites derived artifacts without a key. * - * Request-header content is pinned by exactly ONE scenario per HEADER CLASS — - * scenarios that boot the same config compose the same header. Every JSONL - * fixture scrubs the system prompt to `{{system}}`; each class's pinning - * 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 changed headers (see the - * pinned-header RFC, - * docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md). - * - * `pnpm run test:snapshot:record` (DSH_SNAPSHOT=record + -u) re-records the - * `session.jsonl` fixtures against the real API and refreshes the stdout golden - * in one pass. `pnpm run test:snapshot:refresh` (DSH_SNAPSHOT=refresh) instead - * replays the committed model scripts keylessly and writes the current stdout - * + persisted-log goldens back without calling a live LLM. The caller resolves - * that env into {@link SnapshotSuiteOptions} (env reading stays at the suite - * edge, not in this library). + * Exactly one scenario per header-composition class pins tool schemas in JSONL + * and the system prompt in Markdown. Every live header is checked against that + * pin, so session-dependent composition must declare a separate class instead + * of escaping coverage. * * @module @deepseek-ai/dsh-acp-snapshot/suite */ @@ -89,20 +71,8 @@ export interface Scenario { */ childSessions?: number /** - * Whether THIS scenario pins its header class's model-facing request-header - * content. Its actual composed prompt is maintained as a readable - * `system-prompt.golden.md`; its JSONL keeps full tool schemas but stores the prompt - * as `{{system}}`. Every other scenario of the class stores tools as - * `{{tools}}` too ({@link scrubRequestHeaders}). A prompt or tool-schema - * change therefore shows up in one focused artifact per class, not every - * session fixture. One pin per class suffices because - * header composition is class-uniform (parent, spawn child, and fork child - * all compose the same prompt-modulo-cwd and the same tools) — and that - * premise is ASSERTED, not assumed: every non-pinning run's live headers - * must equal its class's pinned fixture's (normalized), so a - * session-dependent header (say, a restricted subagent toolset) fails loud - * until it gets its own pinning scenario. - * Defaults to false. + * Whether this scenario is its header class's sole request-header pin. Its Markdown file owns + * the prompt, its JSONL keeps tool schemas, and every classmate is checked for equality. */ pinsHeader?: boolean /** @@ -161,17 +131,9 @@ export function childFixturePaths(dir: string, childSessions: number): string[] } /** - * Derive the {@link NormalizeContext} for a `session.jsonl` fixture from its own - * header line (`{ type: 'session', id, cwd }`). A committed fixture carries the - * session id and cwd of the run that harvested it — different from the live - * replay run — so normalizing it against the live run's ctx would leave those - * recorded values unscrubbed. Reading them from the header scrubs the fixture's - * own id/cwd to the same `{{sessionId}}`/`{{cwd}}` tokens the replay output gets. - * An authored fixture whose header is already normalized (`id:'{{sessionId}}'`, - * `cwd:'{{cwd}}'`) yields those tokens as the volatile values, so scrubbing them - * is an idempotent no-op. A header with no `cwd` falls back to a sentinel that - * cannot occur in a log (NOT `''`, which `String.split` would match on every - * character boundary and corrupt the output). + * Derive normalization values from a fixture's own session header. Recorded ids and cwd differ + * from the live replay run; the non-empty sentinel for missing cwd avoids accidental empty- + * string replacement. * * @param fixture The committed `session.jsonl` content. * @returns The fixture's own volatile values, ready for {@link normalizeSessionLog}. @@ -383,10 +345,8 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { for (const scenario of scenarios) { describe(`snapshot: ${scenario.name}`, () => { - // In RECORD mode, only re-run the `recorded` (live-API) scenarios; the - // `authored` ones (sidecar-driven errors/cancel) are never re-recorded. - // REFRESH mode is replay-backed and deterministic, so it runs every - // scenario and rewrites the comparable fixtures from that replay run. + // In RECORD mode, only re-run the `recorded` (live-API) scenarios; the `authored` ones + // (sidecar-driven errors/cancel) are never re-recorded. it.skipIf(RECORDING && !scenario.recorded)('matches the goldens', async () => { const dir = join(snapshotsDir, scenario.name) const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript @@ -408,10 +368,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { ...scenario.configPath !== undefined ? { configPath: scenario.configPath } : {}, }) - // Scrub every volatile id the run produced: the ACP server-issued session - // id plus every harvested log's recorded id (a subagent child id never - // surfaces over ACP, but it appears in the child's own log header). The - // normalizer's UUID catch-all covers any we don't enumerate. + // Scrub every volatile id the run produced: the ACP server-issued session id plus every + // harvested log's recorded id (a subagent child id never surfaces over ACP, but it + // appears in the child's own log header). const ctx: NormalizeContext = { sessionIds: [ ...result.sessionId !== undefined ? [result.sessionId] : [], @@ -420,15 +379,8 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { cwd: result.cwd, } - // RECORD mode (recorded model scenarios only): persist the freshly-harvested - // live logs back to their fixtures. REFRESH mode does the same from a - // keyless replay run for every comparable log, including authored - // scenarios that live record deliberately skips. The primary goes to - // session.jsonl, each child to session..jsonl in harvest order. A - // Every fixture is written with its system prompt scrubbed. A pinning - // scenario keeps the remaining header content (notably tool schemas); - // every other scenario scrubs that bulk too. Record/refresh therefore - // cannot smuggle prompt text back into JSONL or duplicate schemas. + // Record writes live model fixtures; keyless refresh writes every comparable replayed + // fixture. Pins keep tools but all JSONL files scrub prompt text. const scrub = scenario.pinsHeader === true ? scrubSystemPrompts : scrubRequestHeaders @@ -471,14 +423,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { // A model turn always produces a log worth comparing; a hook scenario can // produce one without a model turn (a `rejected` turn carrying `hook/*`). if (comparesLog) { - // The harvested logs (primary-first) must match their committed fixtures - // 1:1. Each side passes through normalizeSessionLog, scrubbed against ITS - // OWN volatile values — the live run's via `ctx`, the committed fixture's - // via its own header (a committed file cannot share the live run's ids). - // Both sides pass through the scenario's idempotent scrub: every live - // prompt becomes the fixture's `{{system}}`; non-pinning scenarios - // additionally tokenize tools/prefix. The dedicated header guard below - // compares those omitted values against their class's pin artifacts. + // The harvested logs (primary-first) must match their committed fixtures 1:1. expect(result.sessionLogs.length, 'this scenario must persist a session log').toBe(childSessions + 1) for (let i = 0; i < fixtureFiles.length; i++) { const harvested = scrub((result.sessionLogs[i] as HarvestedLog).content) @@ -544,18 +489,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { }) it('every registered scenario has its required fixture files', () => { - // Every scenario has an input script and an stdout golden. EVERY scenario - // also needs `session.jsonl`: the suite boots `llm-replay` with that path - // as the replay source for ALL scenarios (the factory passes - // `fixtureFile: /session.jsonl` unconditionally), and `loadReplayScript` - // throws "fixture not found" when it is absent and no override replaces it. - // A no-model scenario ships a header-only `session.jsonl` (it derives to an - // empty script — no model call is made); a model scenario's fixture also - // doubles as the expected-log artifact the run is diffed against. The - // `replay.override.json` sidecar is matched BOTH ways against the table's - // `overridden` flag: required when set, forbidden when not — the harness - // forwards the file purely on existence, so an unregistered stray sidecar - // would silently replace the derived script. + // Every scenario has an input script and an stdout golden. for (const { name, overridden, childSessions, pinsHeader } of scenarios) { const dir = join(snapshotsDir, name) expect(existsSync(join(dir, 'input.json')), `${name}/input.json`).toBe(true) @@ -574,10 +508,8 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { }) it('exactly one scenario pins the request-header content of each header class', () => { - // Zero pins would drop a class's prompt/schema surface from the suite - // entirely; two would split it. One pin per class is the design - // (pinned-header RFC); WHICH scenario pins is the scenario table's - // reviewable choice. + // Zero pins would drop a class's prompt/schema surface from the suite entirely; two would + // split it. const pins = new Map() for (const scenario of scenarios.filter(s => s.pinsHeader === true)) { const cls = classOf(scenario) From 25a5dc35ba4498a3dbb75264aa74b3937c9782d2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:53:51 +0800 Subject: [PATCH 8/9] Refresh Cordis event API summaries --- .../cordis/tool-cordis/src/api-catalog.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 9102e928db..5d16b5d496 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -259,13 +259,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/created', mode: 'emit', signature: '\'agent/created\'(this: Scoped, agent: Agent): void', - summary: 'An agent\'s fully composed scoped world was published in the AgentRegistry.', + summary: 'A fully configured agent and live session were published.', }, { name: 'agent/disposed', mode: 'emit', signature: '\'agent/disposed\'(this: Scoped, agent: Agent): void', - summary: 'An agent was removed from the registry.', + summary: 'An agent left the registry; AgentLoop emits this after driver quiescence but before session detachment and scoped-registration unwind.', }, { name: 'agent/error', @@ -277,37 +277,37 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/pre-step', mode: 'serial', signature: '\'agent/pre-step\'(this: Scoped, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise | void', - summary: 'Awaited pre-step surface-mutation checkpoint, fired once per step AFTER `turn/start` (and after the prior step closed) but BEFORE this step\'s `step/start` — so anything a listener appends lands OUTSIDE the step, between `turn/start`/`step/end` and the upcoming `step/start`.', + summary: 'Awaited serial checkpoint for session-surface mutation after prompt assembly and before `step/start`; appends land outside the pending step.', }, { name: 'agent/prompt-submit', mode: 'waterfall', signature: '\'agent/prompt-submit\'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise', - summary: 'Waterfall: decide what happens to ONE drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContext`) or block it.', + summary: 'Allow, rewrite, or block one drained prompt before it becomes a user message.', }, { name: 'agent/queued', mode: 'emit', signature: '\'agent/queued\'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void', - summary: 'A message entered the agent\'s inbox (queued or steering).', + summary: 'Detached, frozen content entered the agent\'s inbox.', }, { name: 'agent/request', mode: 'waterfall', signature: '\'agent/request\'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise', - summary: '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).', + summary: 'Replace the frozen call configuration.', }, { name: 'agent/session-prefix', mode: 'waterfall', signature: '\'agent/session-prefix\'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise', - summary: 'Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the ENTIRE derived history (directly after the provider\'s system slot) on every request this loop instance sends.', + summary: 'Compose request-only messages placed before derived history.', }, { name: 'agent/session-start', mode: 'emit', signature: '\'agent/session-start\'(this: Scoped, agent: Agent, source: SessionStartSource): void', - summary: 'The agent\'s session lifecycle began, fired once before its first turn.', + summary: 'The session lifecycle began, once before the first turn.', }, { name: 'agent/status', @@ -325,13 +325,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/turn-continuation', mode: 'waterfall', signature: '\'agent/turn-continuation\'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise', - summary: 'Waterfall: override the turn-continuation decision via a typed ContinuationDecision.', + summary: 'Override whether the turn continues.', }, { name: 'agent/turn-stop', mode: 'serial', signature: '\'agent/turn-stop\'(this: Scoped, agent: Agent, turn: number): ContinuationStop | undefined', - summary: 'Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` waterfall, any `continue.reason`, and the pending-steering continuation override have been folded.', + summary: 'Monotonic terminal-stop checkpoint after continuation and steering are folded; a stop remains authoritative through turn close and flush: steering queued in that window is discarded, while ordinary sends survive.', }, { name: 'approval/request', From 6825918eaddca6bb7113f5a889a95ffc9f386470 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:15:58 +0800 Subject: [PATCH 9/9] docs: finish session simplification prose cleanup --- .../2026-07-05-reconstructable-requests.md | 20 +++++----- .../2026-06-18-compaction-capability-seam.md | 14 +++---- .../implemented/feature/2026-07-06-sandbox.md | 14 +++---- packages/compact/compact-basic/src/index.ts | 18 ++------- .../compact-basic/tests/compact-basic.spec.ts | 26 +----------- .../tests/request-reconstruction.spec.ts | 11 ++--- packages/core/session/README.md | 40 +++++++++---------- packages/core/session/tests/surface.spec.ts | 4 -- packages/ui/user-approval/README.md | 12 +++--- 9 files changed, 56 insertions(+), 103 deletions(-) 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 a5bc946af4..e93b4e0bd9 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -20,35 +20,35 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro **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). 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. +`EpochHeader` records the request's non-history state: call config, rendered system prompt, tool schemas, and session prefix, with empty values canonicalized to absence. `request/header` always writes a full snapshot: the first loop instance uses reason `initial`, later instances use `resume`, and an in-instance change uses `change`. `foldRequestHeader` selects the latest snapshot. Legacy `request/header-delta` events and the removed `fallback` reason are rejected when appended or loaded. -**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. +Each step rebuilds prompt assembly. On the instance's first step, `agent/session-prefix` extends a frozen empty seed with request-only opener messages; the result is frozen and cached for that loop instance. `agent/pre-step` then receives the composed prefix before messages are snapshotted immediately ahead of `step/start`. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header. `agent/request` may replace only that frozen config seed, while model-visible content enters through logged channels. The loop records the owed header event—the prefix's only durable home—builds `GenerateOptions` from prefix, snapshot, and header, and deep-freezes it while leaving `AbortSignal` live. Per-instance state is only the cached prefix and whether its anchoring snapshot has been written. -**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. +**`step/start` is the reconstruction boundary.** A step derives messages from events before that sequence. Injection after the snapshot joins the next request, and reentrant appends are rejected during event publication. `agent/pre-step` is the seam for content needed by the current request. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written. -**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. +**Enforcement.** In development, `dsh-invariants` independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. Loop requests are identified by their frozen shape and session id; direct one-shots are excluded. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step. ### The MiniCode shape: adopted, with the provenance arrow inverted -What survives from `LLMClient`: the conversation is maintained, not rebuilt — one projection per message, ever; requests advance append-only; resets happen only for a system-prompt/tool change, a config change, or compaction, each now a *logged* fact. What is deliberately inverted: MiniCode's client is the source of truth and its event stream derives from client appends (`on_event(MessageAdded)`), which suits an advisory event stream. Here the log is contractual — persistence, crash recovery, fork seeding, transcript rendering, and the snapshot harness all replay it — and it carries strictly more than a message list (turn/step boundaries, raw chunk streams, tool-call pairing, provenance, log-only records), so a message-list client cannot generate it. The arrow therefore points log → client: the conversation state IS the log plus two cached folds inside `Session` (messages, header), and the "client" the loop talks to is the session itself. What the inversion buys over the original: the reconstruction is *checkable* against an independent record on every request — MiniCode's client has nothing to check itself against. +Like MiniCode, the conversation advances append-only and resets only when model-visible state changes. Unlike MiniCode, the event log remains the source of truth because it also owns persistence, recovery, boundaries, tool pairing, and provenance. `Session` caches message and header folds derived from that log, making every request independently checkable. ## Alternatives considered - **Client as source of truth** (literal MiniCode): a second operative truth beside the log — the two drift and nothing notices; see the section above. -- **A stateful transmission client mirroring the log** (a `PromptPrefix` class holding committed/open message zones with an append/editTail/reset vocabulary, the log pushed into it per event): behaviorally equivalent on the happy path, but it duplicates conversation state outside the session, needs transactional rollback around listener seams, keeps an unlogged content-shaping surface (`editTail`) whose divergence the invariant must specially allow, and still cannot answer "what header did the model see" from the log. Dissolving it into the session's own caches plus logged header events made every one of those problems unrepresentable instead of guarded. (PR #162 is the archaeology of this alternative, three designs deep.) +- **A stateful transmission client mirroring the log** — duplicates conversation state, needs rollback around listeners, leaves an unlogged edit surface, and still cannot reconstruct request headers. Session-owned caches plus logged headers avoid those split truths. - **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. -- **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. +- **A custom header-delta codec** (system line edits, name-keyed tool edits, whole config/prefix replacements): reduced repeated bytes but duplicated the representation and its diff/apply/fallback machinery. Full snapshots retain one replay representation. +- **Narrative changed-field lists on header snapshots**: derivable by comparing consecutive snapshots. The `reason` remains because an instance boundary is not derivable from the snapshot values. ## 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 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. +- What still costs full price at the provider is inherent and logged: compaction (its `compact/*` events and replacement entry), a real prompt/tool/config change (`request/header` with reason `change`), or a process boundary with drift (a differing `resume` snapshot). 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-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. +- Session logs grow one `request/header` snapshot per loop instance plus snapshots on real changes. This is larger than a delta codec but small beside chunk-heavy logs and retains one replay representation. `SESSION_FORMAT_VERSION` stays `0`; legacy delta events are 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 a9efbee2b4..955a39fcf6 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 @@ -46,19 +46,17 @@ messages = session.deriveMessages() ⟵ single derive, reflects the compaction request = waterfall agent/request ⟵ pure request transform (hooks, model switch) ``` -This makes the layering correct *by construction*: compaction mutates the surface, the loop derives **once** from the result (no double-derive), and at `pre-step` the assembled `messages` do not yet exist — so a listener structurally *cannot* see or be expected to act on downstream-injected context. `agent/request` reverts to a pure request transformer. Firing the seam **before** `step/start` (not inside the open step) is load-bearing for crash-safety: compaction's log-only `compact/*` records and its replacement node land *outside* any step, so the honest log structure a crash leaves (a dangling `compact/start` sitting before the synthetic `turn/end` that turn-repair appends) holds without a half-open step to reconcile. The seam is `serial` (awaited, in registration order), not `parallel`: a listener mutates the surface as a side effect — there is nothing to transform or return — and serial isolates listeners from each other so two surface-mutating listeners can never interleave their `session.append`s. Cordis `serial` does bail early if a listener returns a bail value, so `agent/pre-step` listeners are typed/documented to return `void` and must not use that bail channel as a semantic veto surface. - -This **amends** the original RFC's claim of "NO changes to `dsh-agent-loop`; compaction is a pure plugin." That claim was load-bearing for a wrong design — reusing `agent/request` was the mistake. Per the pre-release "foundation over blast radius" stance, adding the correct seam (one event declaration in `dsh-agent`, one awaited emit in the loop) beats preserving a no-change boast that locked in the double-derive. +The loop derives messages once after `agent/pre-step`. Running before `step/start` keeps compaction records outside any half-open step, simplifying crash repair. The seam is awaited and serial so surface mutations cannot interleave; listeners return `void` and do not use Cordis bail values as vetoes. ### Retention is turn-agnostic; tool-pairing balance is the only structural guard 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 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. +`compactIfNeeded` retains the smallest tail of whole surface units whose estimated size reaches `retainTokens` and compacts older nodes. A unit is a complete closed step or one no-step message. If the token cutoff lands inside a step, retention expands until the cut is tool-pairing balanced. Balance is checked on surface order, not log sequence, because replacement summaries have new sequence numbers at old surface positions. `compactRegion` rejects boundaries that split a tool call from its result. The in-flight turn receives no special retention. 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. -**Single-unit overflow is out of scope, by design.** If a single retained unit — one closed step, or a large free node such as a pasted `user/message` — *alone* exceeds the budget, compaction cannot help and the next model call may go out over-budget. Bounding an individual unit's size is a separate concern (output truncation), handled elsewhere; compaction makes no promise about it, and the harness without such a mechanism can still break on a single oversized unit. This is named honestly rather than papered over. +**Single-unit overflow is out of scope, by design.** If a single retained unit — one closed step, or a large free entry such as a pasted `user/message` — *alone* exceeds the budget, compaction cannot help and the next model call may go out over-budget. Bounding an individual unit's size is a separate concern (output truncation), handled elsewhere; compaction makes no promise about it, and the harness without such a mechanism can still break on a single oversized unit. This is named honestly rather than papered over. ### Head-anchoring: one auto checkpoint, always at the head @@ -70,7 +68,7 @@ Auto-compaction always starts at the surface head, merging the prior checkpoint ### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary -Because `SurfaceEventType` is closed, the summary cannot ride on a `compact/*` event. The backend instead appends a **single `user/message`** with `surfaceOp: { op: 'replace', start, end }` whose `content` is the (framed) summary and whose `sourceEventSeqs` covers the shadowed nodes *and* the bookkeeping events. The `compact/*` events are pure log records (lock + provenance). The surface mutation sits **inside** the lock — `compact/end` is the last event appended: +Because `SurfaceEventType` is closed, the summary cannot ride on a `compact/*` event. The backend instead appends a **single `user/message`** with `surfaceOp: { op: 'replace', start, end }` whose `content` is the (framed) summary and whose `sourceEventSeqs` covers the shadowed entries *and* the bookkeeping events. The `compact/*` events are pure log records (lock + provenance). The surface mutation sits **inside** the lock — `compact/end` is the last event appended: ``` compact/start → log-only. Acquires the lock. @@ -81,7 +79,7 @@ user/message → surfaceOp { op:'replace', start, end }. THE surface mutatio compact/end → log-only. Releases the lock (carries `error` on a recoverable failure). ``` -`deriveMessages()` then yields `[summary_as_user_message, ...retained_nodes]`. Reusing `user/message` is honest rather than a workaround: a summary genuinely *is* user-role context. +`deriveMessages()` then yields `[summary_as_user_message, ...retained_entries]`. Reusing `user/message` is honest rather than a workaround: a summary genuinely *is* user-role context. ### Checkpoint framing + incremental merge (backend-private) @@ -116,7 +114,7 @@ Two failure paths, both documented: - **New loop seam**: `agent/pre-step` (`@mode serial`) declared in `dsh-agent` and emitted by `dsh-agent-loop` after system assembly and before `step/start`. This is a documented change to the loop — `docs/architecture.md` records it and the generated cordis catalog carries its signature. - **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry. - **`dsh-session`** gains the tool-pairing balance predicate (`isToolPairingBalanced`, in `tool-pairing.ts`, exported from the package index) that `compactRegion`/`compactIfNeeded` use to keep a collapsed region from splitting a step's tool-call/result pair. The surface `replace` op and the surface-metadata runtime guard already existed and are reused. -- **`dsh-invariants`** drops its `surface replace: start must be <= end` assertion: a head-anchored compaction lands a high-seq replacement node at an older range's *position*, so `start > end` numerically is normal and valid (the range is positional, validated by the surface's `indexOf` checks that remain). The turn-enclosure invariant is reused unchanged. +- **`dsh-invariants`** drops its `surface replace: start must be <= end` assertion: a head-anchored compaction lands a high-seq replacement entry at an older range's *position*, so `start > end` numerically is normal and valid (the range is positional, validated by the surface's `indexOf` checks that remain). The turn-enclosure invariant is reused unchanged. - **Wiring**: `dsh-compact-basic` is loaded in `examples/coding-agent`'s `cordis.yml`, so the seam ships in the real demo (it was previously loaded nowhere). ## Testing diff --git a/docs/rfc/implemented/feature/2026-07-06-sandbox.md b/docs/rfc/implemented/feature/2026-07-06-sandbox.md index 3e2180484e..e236154cdc 100644 --- a/docs/rfc/implemented/feature/2026-07-06-sandbox.md +++ b/docs/rfc/implemented/feature/2026-07-06-sandbox.md @@ -105,11 +105,11 @@ 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`). +Sandbox mode is not narrated in the prompt; denial results report the mode when it matters, avoiding preemptive refusal based on a standing label. Approval policy is different: only `'never'` is stated because automatic rejection otherwise looks like a user decision. Policy-change notices are coalesced and delivered by the next pre-step, with log-derived fallback after restart. The notice source is inferred from event position: a knob event after the last request header is user-driven; unlogged drift is operator or config driven. **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. When `ctx.permission` is composed, the bridge advertises one `permission` select (category `mode`) in `session/new` and `session/load`; its options are the deployment's preset table, and its `currentValue` is `PermissionService.current()` over the session log plus composition defaults. The shipped `workspace-write` and `danger-full-access` presets each bundle a sandbox mode with an approval policy and write through to both domain setters; a knob combination outside the table is reported as switch-away-only `custom`. `session/set_config_option` validates and switches through the permission service, then returns the complete refreshed state (the spec contract). -**Anchoring: turn-enclosure is the commit boundary.** The turn-enclosure contract makes a bare between-turns append invalid (the JSONL backend treats a post-`turn/end` tail as crash garbage; dev invariants throw). A switch while a turn is open appends immediately — openness read from the LOG (last boundary event is `turn/start`), not `agent.status`, which stays `running` between queued turns. An idle switch is held on the bridge's session record and anchored at the next turn's `agent/prompt-submit` — inside the turn, before anything in it assembles or executes, last write per knob, and OUTSIDE any `session/event` emit (appending from inside that feed reorders events for later-registered listeners — a bug the dev invariants caught live). Until anchored, the switch exists only in bridge memory: responses overlay it truthfully, and a crash before the next turn reverts it — `session/load` then reports the fold's truth, so the editor UI self-corrects rather than lies. +**Turn enclosure is the commit boundary.** A switch during an open turn appends immediately. An idle switch remains pending on the bridge record and is appended at the next prompt submission, before assembly or execution; last write wins per knob. Openness comes from log boundaries rather than `agent.status`, and setters do not append from inside a `session/event` listener because that would reorder later observers. Until anchoring, responses overlay the pending value. A crash discards it, and reload returns the durable fold. #### In-process tools @@ -119,10 +119,10 @@ FIXME: Revisit this tool-local boundary. The follow-up design needs to determine ### Testing -- 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 (the acp-agent example's `escalation.e2e.ts`): the real default `cordis.yml` tree advertises the one permission option, honors switches end to end, and rejects out-of-vocabulary values. -- With-key e2e (`examples/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/acp-agent/tests/acp.snapshot.ts`): the keyless config-option wire; the recorded permission-switching arc as the pinned header of its class — necessarily, since mid-session switches emit the changed `request/header` snapshot the uniformity guard licenses only in the pin — committing one `workspace-write`→`danger-full-access` preset switch (the `permission/preset` event written through to both knobs), the changed approval prompt section and its "changed by the user" notice; and both recorded escalation branches over scripted `permissionAnswers` (grant runs under the granted `danger-full-access`; rejection executes nothing and pins the fail-closed text). Snapshot mode starts the shared example tree at `danger-full-access` so established fixtures remain runner-independent; the switching and escalation inputs explicitly select `workspace-write` before exercising the policy path. 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 real-runner tiers above. +- **Unit:** pin platform selection and profiles, fail-closed runner classification, per-call facts, escalation validation and outcomes, permission preset folding and write-through, narrator coalescing, ACP advertisement and validation, and turn-enclosed config writes. +- **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; packed-install coverage proves the registry launcher remains executable. The real ACP composition pins permission switching and rejects unknown presets. CI rejects a silent all-skip. +- **With-key:** drive a real model, runner, bridge answerer, and disk effect through granted and rejected escalation; unavailable credentials or runners self-skip. +- **Snapshot:** pin the permission config-option wire, preset and knob events, prompt deltas and notices, and both scripted approval branches. Snapshot mode starts unconfined so unrelated fixtures remain platform-independent; policy scenarios switch explicitly. Real denial stderr stays on platform tests because its dialect is runner-specific. ## Deferred phases @@ -155,7 +155,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 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. +- **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. - **ACP session modes instead of config options** — rejected: the preset is already one deployment-defined config-option select, and modes are slated for removal in ACP v2. ## Consequences diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index c879dabfa1..d32264cd2f 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -337,13 +337,7 @@ export class BasicCompactService extends CompactService { agent: Agent, signal?: AbortSignal, ): Promise { - // Resolve the range by surface POSITION, not numeric seq interval. A prior - // replace lands a fresh high-seq summary node AT the shadowed range's - // 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 `seq >= start && seq <= end` interval test would mis-collect - // nodes (and `start > end` would falsely reject) once that happens. + // Resolve by surface position: a newer replacement seq may occupy an older slot. const nodes = session.surface.nodes const startIdx = nodes.indexOf(start) const endIdx = nodes.indexOf(end) @@ -510,14 +504,8 @@ export class BasicCompactService extends CompactService { // The whole surface fits the retain budget — nothing to compact. if (keepFromIdx === 0) return null - // Round the cutoff to a tool-pairing boundary: if the cut before - // `nodes[keepFromIdx]` is unbalanced (an unanswered tool-call sits before - // it — i.e. it is mid-step), extend the retained side head-ward until the - // cut is balanced, so the compacted range ends without splitting an - // assistant↔result pair. A node that belongs to no step is already a - // balanced (free) boundary. Decline if no balanced cut exists at or below - // `keepFromIdx` (the compactable range is only an un-splittable open tail - // step — retry once it closes). + // Round the cutoff head-ward to a tool-pairing boundary; decline when no + // safe compactable prefix exists. while (keepFromIdx > 0) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!)) break diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 44ae51ccad..140ad5efb0 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -1548,40 +1548,23 @@ describe('BasicCompactService edge cases', () => { describe('BasicCompactService positional range (surface seqs are not monotonic after a replace)', () => { it('compacts a second region after the first replace lands a high-seq summary at the head position', async () => { - // A replace inserts the new summary node (a high seq) AT the shadowed - // range's surface position, so the surface becomes - // [highSeqSummary, …olderRetainedLowerSeqs]. A second compaction over a - // range whose start node has a HIGHER seq than its end node must still - // succeed — the range is positional, not a numeric seq interval. + // Replacement can make surface seqs non-monotonic; ranges remain positional. const svc = createTestService({ auto: false }) const session = multiTurnSession(4, 1) - // First compaction: shadow the two oldest surface nodes. const nodes0 = session.surface.nodes 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]!).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]! 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]!, nodes1[1]!, nodes1[2]!]) - // The surface still derives cleanly: a new head replace node + the rest. const finalNodes = session.surface.nodes expect(finalNodes[0]!).toBeGreaterThanOrEqual(second.summarySeq) expect(session.deriveMessages().length).toBe(finalNodes.length) @@ -1591,20 +1574,13 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a const svc = createTestService({ auto: false }) const session = multiTurnSession(3, 1) - // 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]!, 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]!, n1[2]!, 'm') - // The extracted transcript follows surface order: the checkpoint (head) - // first, then the older retained messages — matching deriveMessages(). const { text } = svc.summarizeCalls[0]! const checkpointIdx = text.indexOf('compacted-summary') const olderIdx = text.indexOf('turn 2 user') diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 7054b683cb..721c2a1eff 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -1,11 +1,8 @@ /** - * 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 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 - * at the bottom is the theorem stated end-to-end. + * Loop-level reconstructability: every request the loop sends is a pure function of the + * session log — messages derive at the step/start boundary and the header is the latest + * request/header snapshot. Each request extends its predecessor unless a logged compaction + * replacement or header change explains the difference. */ import { describe, expect, it } from 'vitest' diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 3f11876c3b..a65765df39 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 (an ordered sequence of message-producing event seqs) 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 projection of message-producing events) is maintained on top of the raw log for efficient derivation and compaction. ## Service: `SessionStore` (ctx key: `sessions`) @@ -8,35 +8,35 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API -- `ctx.sessions.create(id?: SessionId, options?: { seed?: readonly SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. The persistence/replay seed and resulting header are validated, detached, and deep-frozen at this durable boundary. The store fills `version`/`id` and defaults `createdAt` to now; a persisted reconstruction supplies the original `createdAt` and `seedLength`. Disposed with the calling fiber. -- `ctx.sessions.flush(session: Session): Promise` Dispatch the awaited `session/flush` durability checkpoint with the carrier captured at enter — THE flush entry point (the loop's turn-end checkpoint and idle injection call it; never dispatch a raw `ctx.parallel`). Every captured listener starts, the call waits for all of them to settle, and a failure rejects only after the other listeners finish. Rejects a prepared, detached, or stale same-id object instead of inventing a subject-less carrier. +- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt` and `seedLength`. +- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject. - `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` #### Advanced: ordered-teardown lifecycle primitives -`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before the store attachment and publication hooks are removed — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect: +Use the split lifecycle only when teardown must be ordered with another resource: -- `ctx.sessions.prepare(id?, options?): Session` — validate durable seed/header data and construct the `Session` WITHOUT entering it into the store. Same options as `create`. -- `ctx.sessions.enter(session): () => void` — perform the authoritative ID collision check, install append publication state, and insert the exact session without announcing it. Returns an idempotent detach bound to the captured entry object, so a stale disposer cannot remove a later same-ID replacement. Concurrent same-ID preparation is allowed; only one final entry succeeds. -- `ctx.sessions.announce(session): void` — begin the one allowed `session/created` announcement for an entered session; repeat and reentrant calls reject before dispatch. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, so another creation listener cannot observe `session/disposed` before its own `session/created` callback. Detach emits `session/disposed` exactly once, including rollback after a partially delivered creation notification; a never-announced entry emits neither edge. +- `prepare(id?, options?)` validates and constructs without publication. +- `enter(session)` performs the collision check, publishes without announcing, and returns an entry-bound idempotent detach. Concurrent same-id preparations are allowed, but only one entry succeeds; a stale detach cannot remove its replacement. +- `announce(session)` emits the single creation edge and rejects repeat or reentrant announcements. Detach during that dispatch is deferred and later emits the paired disposal edge; an unannounced entry emits neither lifecycle edge. -`dsh-agent-loop` is the canonical consumer: after unpublished agent setup it enters both session and agent before announcing either, then nests loop stop, agent removal, session detach, and scope unwind in one ordered lifecycle. The final flush therefore settles before this package detaches the session, whether teardown starts from an `AgentHandle` or owner-fiber unload. +`dsh-agent-loop` uses this split so final loop flush precedes session detach; see the [ownership RFC](../../../docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md). ### Live service events -The store pairs announced creation with disposal, publishes each append, and provides an awaited durability checkpoint. Before the log push it resolves the scoped `session/event` callback list. The push is the commit point; callback throws or returned-promise rejections are logged and contained per observer. A committed append therefore returns normally, later observers still run, and detach waits until publication unwinds. Exact `session/*` signatures, modes, and scope-carrier behavior live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the append-only payload vocabulary is separately generated into the [persistence catalog](../../../docs/persistence-catalog.md). +The store pairs announced creation with disposal, publishes post-commit append notifications with per-listener containment, and provides an awaited durability checkpoint. Exact signatures and scope behavior live in the generated [event catalog](../../../docs/cordis-catalog/events.md); payloads live in the [persistence catalog](../../../docs/persistence-catalog.md). ### Class: `Session` Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. -- `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. At this durable boundary, data and surface metadata are lossless-JSON snapshotted and deep-frozen. 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 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. +- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs. +- `session.deriveMessages()` incrementally projects each new surface entry once and returns a fresh array over shared frozen messages. A surface rewrite rebuilds the projection; there is no raw-log fallback. +- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants. +- `session.surface` lazily folds only new `surfaceOp` markers; `replaceGeneration` changes on every rewrite. +- `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen. - `session.seq`, `session.id` — current sequence and readonly typed identity. - `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates the durable record and requires its id to match `session.id`. @@ -49,11 +49,11 @@ Durable values need one accepted representation, not a check followed by a secon - `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. - `foldSurface(events)` — replay the canonical surface transitions into detached current event sequences and actual replacement ranges, rejecting surface-eligible events that lack their mandatory marker. `SurfaceManager` shares the same transitions while retaining only its incremental sequence cache. -- `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, used to detect a surface-eligible event MISSING its marker when validating a seed or loaded log. +- `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully formed surface event; the second is the type-only check used to detect a surface-eligible event missing its marker when validating a seed or loaded log. ### Request-header reconstruction (`request-header.ts`) -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` event or its full-snapshot `fallback` reason are rejected rather than partially replayed. +`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. `messagePrefix` remains separate from derived history. See the [reconstructable-requests RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md). ### Session event vocabulary (`types.ts`) @@ -65,7 +65,7 @@ Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types Every `SessionEvent` carries two optional top-level fields (structural metadata): -- `sourceEventSeqs?: number[]` — seq numbers of provenance sources (e.g., the `assistant/chunk` seqs behind an `assistant/message`, or the shadowed nodes behind a compaction replace node). +- `sourceEventSeqs?: number[]` — seq numbers of provenance sources (e.g., the `assistant/chunk` seqs behind an `assistant/message`, or the shadowed entries behind a compaction replacement entry). - `surfaceOp?: SurfaceOp` — how this event entered the surface. Absent for non-surface events (boundaries, chunks, usage, errors). ### Metadata types (`types.ts`) @@ -76,15 +76,15 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) - Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log. - Replay/fork: `create(id, { seed })` validates and freezes a contiguous log and rebuilds its surface. `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage. -- Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint. +- Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface entries behind a summary checkpoint. ## Model Experience ### Derived message history -**What the model sees**: The model receives projections of `user/message`, `assistant/message`, and `tool/result` surface nodes verbatim. A `context/message` is a user-role message containing exactly ``, its content blocks, and ``; `steering/message` uses the identical `` / `` wrapper. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message. +**What the model sees**: The model receives projections of `user/message`, `assistant/message`, and `tool/result` surface entries verbatim. A `context/message` is a user-role message containing exactly ``, its content blocks, and ``; `steering/message` uses the identical `` / `` wrapper. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message. -**Token effect**: Appended surface nodes are resent on later steps. A `replace` surface operation removes the shadowed nodes from future inputs without deleting their raw log records. +**Token effect**: Appended surface entries are resent on later steps. A `replace` surface operation removes the shadowed entries from future inputs without deleting their raw log records. ### Crash-repair result diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 5f09efe089..53c9abfccc 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -108,14 +108,10 @@ describe('SurfaceManager', () => { it('rebuild with replace operation splices out shadowed nodes', () => { const s = surfaceSession() - // seq: 0=turn/start, 1=user, 2=assistant, 3=turn/end - // Surface nodes: seq 1 (user), seq 2 (assistant). - // Replace both with a compaction marker. Both 1 and 2 are valid surface seqs. s.append('assistant/message', { turn: 2, step: 1, content: [{ type: 'text', text: 'summary' }] }, { surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] }, ) - // Now the surface should have just the compaction node. expect(s.surface.nodes).toEqual([4]) }) diff --git a/packages/ui/user-approval/README.md b/packages/ui/user-approval/README.md index b4fe3420ef..cebd25275e 100644 --- a/packages/ui/user-approval/README.md +++ b/packages/ui/user-approval/README.md @@ -1,16 +1,14 @@ # @deepseek-ai/dsh-user-approval -User-approval seam. Owns the `ctx.approval` service ([`ApprovalService`](src/index.ts)) and the one-shot permission vocabulary the harness shares: `ApprovalRequest` (agent + tool identity + reason + abort signal), the closed `ApprovalOutcome` union (`allowed-once` / `rejected` / `cancelled` / `unavailable`), the `ApprovalRequestId` brand pairing the two log-only audit events (`approval/asked` / `approval/decided`), and the `approval/request` waterfall the answerers listen on. It lives in the UI group because its purpose is human permission, while remaining channel-neutral: it depends only on Cordis and core vocabulary packages, never on a concrete UI. +Channel-neutral one-shot approval seam. `ctx.approval.request(req)` returns `allowed-once`, `rejected`, `cancelled`, or `unavailable`; missing or failing answerers fail closed, and a grant applies only to the requested action. Exact event signatures live in the generated [Cordis catalog](../../../docs/cordis-catalog/events.md). -The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and its answerer phase always produces an outcome: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. `ApprovalRequest` is a readonly same-process contract: the service borrows the exact request, agent, session, and abort signal rather than cloning or freezing them. The request requires an open turn because the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask rejects before appending. Either audit append may reject before commit because returning an unlogged decision would violate the pair. Session contains post-commit observer failures, so an authoritative audit append cannot reject the request or suppress its matching event. +Each request must belong to an open agent turn. The service appends a paired `approval/asked` and `approval/decided` audit record, while the model sees only the resulting logged tool outcome. An aborted request resolves `cancelled`; an audit append that fails before commit rejects rather than returning an unlogged decision. -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. +Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP bridge is the shipped human answerer. -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`). +`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise. -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). - -Answerers today: the ACP bridge ([`@deepseek-ai/dsh-acp`](../../ui/acp/)) forwards to the editor's `session/request_permission` prompt for agents it owns. The audit events are log-only session records — the model only ever sees the tool result the asker derives from the outcome. +The tools pipeline routes `ask` decisions through this seam and fails closed when it is absent; the sandboxed bash tool also uses it for escalated retries. The ACP bridge is the shipped human answerer for calls it owns. Audit events remain log-only, so the model sees only the asking consumer's result. See the [approval-seam RFC](../../../docs/rfc/implemented/feature/2026-07-06-approval-seam.md) and [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). ## Model Experience